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]>
279 lines
13 KiB
Dart
279 lines
13 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: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');
|
||
}
|