Files
pyramid/integration_test/ui_audit_tour_test.dart
T
Bernd SteckmeisterandClaude Opus 5.5 87b472a373 fix(ui): UX-Audit – Sackgassen, tote Knöpfe, englische Reste; Screenshot-Tour
Bernds Wunsch 2026-10-08: UI grundsätzlich prüfen (Navigation, Sackgassen,
Anruf-Logik). Audit + Konzept in docs/UI_AUDIT.md, belegt mit neuer
Screenshot-Tour integration_test/ui_audit_tour_test.dart (35 Zustände,
Handy + PC, zwei Testkonten, Test-Space mit Sprachkanal).

Sofort behoben:
- S1/S2: Gegenseite legt auf / Anruf scheitert / Sprachkanal bricht ab →
  automatisch zurück zum Chat mit Hinweis (Fehler sichtbar statt still);
  leere Anrufansicht deutsch mit Zurück-Knopf (vorher englische Sackgasse
  ohne Zurück am Handy).
- S4: toter „Threads“-Knopf entfernt. S5: Space betreten (PC) öffnet
  letzten bzw. ersten Textkanal. S6: keine doppelte Anrufsteuerung am PC.
- Annehmen-Fenster mit Anrufer-Avatar und Anrufart; eigene Kachel mit
  eigenem Initial statt „D“; Mitgliederliste rund mit Profilbild und „(Du)“;
  Tastaturkürzel nur am PC; englische Texte in Anruf/Leerzuständen deutsch.
- Tests: Fehlerempfänger des Testrahmens nach app.main() zurück (vorher
  verschwanden Fehlermeldungen), Lesebestätigungs-Test im Wegwerf-Raum statt
  in der DM, robustere Schritte; test.ps1 räumt liegengebliebene
  Test-Prozesse weg; gemeinsame Hilfen integration_test/support/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:35:40 +02:00

279 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/main.dart' as app;
import 'support/test_partner.dart';
/// UI-Tour für das UX-Audit (Bernd 2026-10-08): fährt alle wichtigen
/// Zustände ab und speichert jeden als Screenshot im Handy- UND im
/// PC-Format nach build/ui_audit/ (p_* = Handy, d_* = PC).
///
/// Testkonten: App = pyramidtest1 (Testprofil), Gegenseite = pyramidtest2
/// (kurzlebiges Gerät). Legt beim ersten Lauf einen Test-Space mit Text- und
/// Sprachkanal an (bleibt für spätere Touren bestehen) und für den Anruf einen
/// Wegwerf-Raum. Nutzt kurz Mikrofon (Anruf, Sprachkanal).
///
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
/// flutter test integration_test/ui_audit_tour_test.dart -d windows
const _spaceName = 'UI-Test Space';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
final accounts = loadTestAccounts();
testWidgets(
'UI-Tour: alle Zustände, Handy + PC',
(tester) async {
void phone() => tester.view
..physicalSize = const Size(1080, 2340)
..devicePixelRatio = 2.625
..padding = const FakeViewPadding(top: 63, bottom: 42)
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
void desktop() => tester.view
..physicalSize = const Size(1600, 1000)
..devicePixelRatio = 1.0
..padding = FakeViewPadding.zero
..viewPadding = FakeViewPadding.zero;
addTearDown(tester.view.reset);
phone();
Future<void> both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async {
phone();
await pumpFor(tester, settle);
await _shot(tester, 'p_$name');
desktop();
await pumpFor(tester, settle);
await _shot(tester, 'd_$name');
phone();
await pumpFor(tester, const Duration(milliseconds: 400));
}
// app.main() ersetzt FlutterError.onError – danach den Empfänger des
// Testrahmens zurücksetzen, sonst verschwinden Fehlermeldungen.
final testOnError = FlutterError.onError;
app.main();
await pumpUntil(tester, () => find.byType(MaterialApp).evaluate().isNotEmpty);
FlutterError.onError = testOnError;
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
final me = (await tester.runAsync(
() => container.read(matrixClientProvider.future),
))!;
await pumpFor(tester, const Duration(seconds: 5));
final partner = (await tester.runAsync(
() => TestPartner.start(accounts!['user2']!, accounts['pass2']!),
))!;
String? callRoomId;
addTearDown(() => tester.runAsync(() async {
await partner.dispose(callRoomId);
final r = callRoomId == null ? null : me.getRoomById(callRoomId);
await r?.leave().catchError((_) {});
await r?.forget().catchError((_) {});
}));
// ── Fixture: Test-Space mit Text- und Sprachkanal (einmalig) ──
var space = me.rooms.where((r) => r.isSpace && r.name == _spaceName).firstOrNull;
if (space == null) {
logStep('lege Test-Space an');
final ids = (await tester.runAsync(() async {
final c = partner.client;
final spaceId = await c.createRoom(
name: _spaceName,
creationContent: {'type': 'm.space'},
preset: CreateRoomPreset.privateChat,
invite: [me.userID!],
);
final textId = await c.createRoom(
name: 'allgemein',
preset: CreateRoomPreset.privateChat,
invite: [me.userID!],
);
final voiceId = await c.createRoom(
name: 'Sprachkanal',
preset: CreateRoomPreset.privateChat,
creationContent: {'type': 'io.pyramid.voice_channel'},
invite: [me.userID!],
);
final pl = Map<String, dynamic>.from(
await c.getRoomStateWithKey(voiceId, EventTypes.RoomPowerLevels, ''),
);
pl['events'] = {
...Map<String, dynamic>.from(pl['events'] as Map? ?? {}),
'io.pyramid.voice.presence': 0,
};
await c.setRoomStateWithKey(voiceId, EventTypes.RoomPowerLevels, '', pl);
for (final child in [textId, voiceId]) {
await c.setRoomStateWithKey(spaceId, 'm.space.child', child, {
'via': ['steggi-matrix.work'],
});
}
await c.sendMessage(textId, EventTypes.Message,
'm${DateTime.now().microsecondsSinceEpoch}',
{'msgtype': 'm.text', 'body': 'Willkommen im Test-Space 👋'});
return [spaceId, textId, voiceId];
}))!;
for (final id in ids) {
await pumpUntil(tester, () => me.getRoomById(id)?.membership == Membership.invite,
what: 'Einladung $id');
await tester.runAsync(() => me.getRoomById(id)!.join());
}
await pumpUntil(tester, () =>
me.rooms.any((r) => r.isSpace && r.name == _spaceName && r.membership == Membership.join),
what: 'Space beigetreten');
space = me.rooms.firstWhere((r) => r.isSpace && r.name == _spaceName);
await pumpFor(tester, const Duration(seconds: 3));
}
// ── 1. Listen ──
container.read(activeSpaceIdProvider.notifier).state = 'dms';
container.read(activeRoomIdProvider.notifier).state = null;
await both('01_dms');
tester.state<ScaffoldState>(find.byType(Scaffold).first).openDrawer();
await pumpFor(tester, const Duration(seconds: 1));
await _shot(tester, 'p_02_drawer');
await tester.tapAt(const Offset(390, 400));
await pumpFor(tester, const Duration(seconds: 1));
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
await both('03_raeume');
container.read(activeSpaceIdProvider.notifier).state = space.id;
await both('04_space');
// ── 2. Chats ──
final dm = me.rooms.firstWhere((r) =>
r.membership == Membership.join &&
r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work');
container.read(activeSpaceIdProvider.notifier).state = 'dms';
container.read(activeRoomIdProvider.notifier).state = dm.id;
await both('05_dm_chat', settle: const Duration(seconds: 3));
final group = me.rooms.firstWhere((r) => r.getLocalizedDisplayname() == 'UI-Test Gruppe');
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
container.read(activeRoomIdProvider.notifier).state = group.id;
await both('06_gruppe', settle: const Duration(seconds: 3));
container.read(membersPanelOpenProvider.notifier).state = true;
await both('07_mitglieder');
container.read(membersPanelOpenProvider.notifier).state = false;
final textRoom = space.spaceChildren
.map((c) => c.roomId == null ? null : me.getRoomById(c.roomId!))
.whereType<Room>()
.firstWhere((r) => r.getLocalizedDisplayname() == 'allgemein');
container.read(activeSpaceIdProvider.notifier).state = space.id;
container.read(activeRoomIdProvider.notifier).state = textRoom.id;
await both('08_space_textkanal', settle: const Duration(seconds: 3));
// ── 3. Modale ──
container.read(activeModalProvider.notifier).state = ModalKind.settings;
await both('09_einstellungen');
container.read(activeModalProvider.notifier).state = ModalKind.search;
await both('10_suche');
container.read(activeModalProvider.notifier).state = ModalKind.none;
// ── 4. Sackgasse: Anruf-Ansicht ohne Anruf ──
container.read(viewModeProvider.notifier).state = ViewMode.voice;
await both('11_anrufansicht_ohne_anruf');
container.read(viewModeProvider.notifier).state = ViewMode.chat;
// ── 5. Sprachkanal beitreten (echter Tipp in der Space-Liste) ──
container.read(activeRoomIdProvider.notifier).state = null;
container.read(activeSpaceIdProvider.notifier).state = space.id;
await pumpFor(tester, const Duration(seconds: 2));
final voiceTile = find.text('Sprachkanal');
if (voiceTile.evaluate().isNotEmpty) {
await tester.tap(voiceTile.first);
final lk = container.read(voiceChannelProvider);
await pumpUntil(tester, () => lk.isActive && !lk.isConnecting,
timeout: const Duration(seconds: 30), what: 'Sprachkanal verbunden');
await both('12_sprachkanal_verbunden_liste', settle: const Duration(seconds: 2));
container.read(viewModeProvider.notifier).state = ViewMode.voice;
await both('13_sprachkanal_ansicht', settle: const Duration(seconds: 2));
container.read(viewModeProvider.notifier).state = ViewMode.chat;
await tester.runAsync(() => lk.hangUp());
await pumpFor(tester, const Duration(seconds: 2));
} else {
logStep('Sprachkanal nicht in der Liste gefunden');
await _shot(tester, 'p_12_sprachkanal_fehlt');
}
// ── 6. 1:1-Anruf: klingelt, läuft, minimiert ──
callRoomId = await tester.runAsync(() => partner.client.createRoom(
invite: [me.userID!],
preset: CreateRoomPreset.privateChat,
name: 'pyramidtest2 (Anruftest)',
));
await pumpUntil(tester, () => me.getRoomById(callRoomId!)?.membership == Membership.invite);
await tester.runAsync(() => me.getRoomById(callRoomId!)!.join());
await pumpFor(tester, const Duration(seconds: 3));
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
container.read(activeRoomIdProvider.notifier).state = callRoomId;
await pumpFor(tester, const Duration(seconds: 2));
final voip = container.read(callSignalingProvider);
await tester.runAsync(() => partner.run(() => partner.voip.inviteToCall(
partner.client.getRoomById(callRoomId!)!,
CallType.kVoice,
)));
final answerBtn = find.byWidgetPredicate(
(w) => w is FloatingActionButton && w.heroTag == 'answer_call',
);
await pumpUntil(tester, () => answerBtn.evaluate().isNotEmpty, what: 'klingelt');
await both('14_anruf_klingelt');
await tester.tap(answerBtn.first);
await pumpUntil(tester, () => voip.currentCall?.state == CallState.kConnected,
what: 'verbunden');
await both('15_anruf_laeuft', settle: const Duration(seconds: 2));
container.read(viewModeProvider.notifier).state = ViewMode.chat;
await both('16_anruf_minimiert_chat');
container.read(activeRoomIdProvider.notifier).state = null;
await both('17_anruf_minimiert_liste');
// Gegenseite legt auf → wo landet man?
container.read(viewModeProvider.notifier).state = ViewMode.voice;
await pumpFor(tester, const Duration(seconds: 1));
await tester.runAsync(() => partner.run(
() => partner.delegate.call!.hangup(reason: CallErrorCode.userHangup)));
await pumpUntil(tester, () => voip.currentCall == null, what: 'aufgelegt');
// Keine Sackgasse mehr: automatisch zurück zum Chat (UI-Audit S1).
await pumpUntil(
tester,
() => container.read(viewModeProvider) == ViewMode.chat,
timeout: const Duration(seconds: 10),
what: 'nach Auflegen zurück im Chat',
);
await pumpFor(tester, const Duration(seconds: 1)); // Ausgleiten abwarten
expect(find.text('Kein Anruf'), findsNothing);
await both('18_nach_auflegen_durch_gegenseite');
container.read(viewModeProvider.notifier).state = ViewMode.chat;
},
skip: profile == null || accounts == null,
timeout: const Timeout(Duration(minutes: 8)),
);
}
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_audit/$name.png');
file.parent.createSync(recursive: true);
file.writeAsBytesSync(bytes!.buffer.asUint8List());
logStep('Screenshot $name');
}