Files
pyramid/integration_test/ui_audit_tour_test.dart
Bernd SteckmeisterandClaude Opus 5.5 391ebf0ffa feat(ui): untere Leiste (Chats/Server/Du) als Option, Server-Uebersicht, Du-Tab, Feinschliff
- Einstellung 'Navigation: Seitenmenue | Untere Leiste' (Handy, Standard
  bleibt Seitenmenue): Tabs Chats/Server/Du, Server-Uebersicht als Karten,
  Du-Tab mit Profil + Einstellungen, Zurueck-Taste ueber die Tabs
- CallDock als eigenes Widget (Anrufleiste auch ueber der Tab-Leiste)
- Sprachkanaele am Handy als Kachel mit 'wer ist drin'
- Suchfeld mit Lupe, 'Kanaele durchsuchen' im Server, Filter-Chips am PC
  umbrechend, '#' nur bei Server-Kanaelen, englische Reste entfernt
- Anrufansicht merkt sich Bubbles/Kacheln, Bubbles mit leichtem Glanz
- UI-Tour: Gruppenanruf (echter zweiter LiveKit-Teilnehmer), Bubbles mit
  5/7 Personen, untere Leiste; Version 0.1.42

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 14:22:02 +02:00

414 lines
19 KiB
Dart
Raw Permalink 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:livekit_client/livekit_client.dart' as lk;
import 'package:pyramid/core/livekit_token.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart' show bottomNavProvider;
import 'package:pyramid/features/call_signaling/call_signaling_service.dart';
import 'package:pyramid/features/call_ui/voice_channel.dart' show CallBubblesDemo;
import 'package:pyramid/features/group_call/group_call.dart';
import 'package:pyramid/features/voice_channel/voice_channel_service.dart';
import 'package:pyramid/layout/mobile_tabs.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();
// Flüssig pumpen (~60 Bilder/s), damit Animationen wie auf dem Gerät
// zur Ruhe kommen (pumpFor zeichnet nur alle 100 ms).
Future<void> smooth(Duration d) async {
final end = DateTime.now().add(d);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 16));
}
}
Future<void> both(String name, {Duration settle = const Duration(milliseconds: 1500)}) async {
phone();
await smooth(settle);
await _shot(tester, 'p_$name');
desktop();
await smooth(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));
final groupsChip = find.text('Gruppen');
if (groupsChip.evaluate().isNotEmpty) {
await tester.tap(groupsChip.first);
await both('03_chats_gruppen');
await tester.tap(find.text('Alle').first);
await pumpFor(tester, const Duration(milliseconds: 500));
}
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 = 'dms';
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 = 'dms';
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;
// ── 7. Gruppenanruf: Beitreten-Karte, Anruf mit zwei Personen ──
// Anwesenheit für alle erlauben (wer darf, ändert die Rechte).
await tester.runAsync(() async {
for (final c in [me, partner.client]) {
final r = c.getRoomById(group.id);
if (r != null && r.canChangePowerLevel) {
await allowVoicePresenceForAll(c, group.id).catchError((_) {});
break;
}
}
});
await pumpFor(tester, const Duration(seconds: 2));
Future<void> partnerPresence(bool active) => tester.runAsync(() =>
partner.client.setRoomStateWithKey(
group.id,
'io.pyramid.voice.presence',
partner.client.userID!,
active
? {
'active': true,
'joined_at': DateTime.now().millisecondsSinceEpoch,
'display_name': 'pyramidtest2',
}
: {'active': false},
).then((_) {}));
var presenceWritten = false;
try {
await partnerPresence(true);
presenceWritten = true;
} catch (e) {
logStep('Gegenseite darf keine Anwesenheit schreiben: $e');
}
container.read(activeSpaceIdProvider.notifier).state = 'dms';
container.read(activeRoomIdProvider.notifier).state = group.id;
if (presenceWritten) {
await pumpUntil(tester, () => find.text('Gruppenanruf läuft').evaluate().isNotEmpty,
timeout: const Duration(seconds: 20), what: 'Beitreten-Karte');
}
await both('19_gruppenanruf_karte', settle: const Duration(seconds: 2));
// Gegenseite tritt dem LiveKit-Raum wirklich bei (zweite Bubble).
lk.Room? partnerLk;
try {
partnerLk = await tester.runAsync(() async {
final t = await LiveKitTokenService.fetch(
room: group.id,
matrixToken: partner.client.accessToken!,
);
final r = lk.Room();
await r.connect(t.url, t.token);
return r;
});
} catch (e) {
logStep('Gegenseite konnte LiveKit nicht beitreten: $e');
}
final lkCall = container.read(voiceChannelProvider);
await tester.tap(find.byTooltip('Gruppenanruf beitreten').evaluate().isNotEmpty
? find.byTooltip('Gruppenanruf beitreten').first
: find.byTooltip('Gruppenanruf').first);
await pumpUntil(tester, () => lkCall.isActive && !lkCall.isConnecting,
timeout: const Duration(seconds: 30), what: 'Gruppenanruf verbunden');
await both('20_gruppenanruf_bubbles', settle: const Duration(seconds: 3));
await tester.tap(find.byTooltip('Kachelansicht').first);
await both('21_gruppenanruf_kacheln', settle: const Duration(seconds: 2));
await tester.tap(find.byTooltip('Bubble-Ansicht').first);
await pumpFor(tester, const Duration(seconds: 1));
container.read(viewModeProvider.notifier).state = ViewMode.chat;
await both('22_gruppenanruf_minimiert_chat', settle: const Duration(seconds: 2));
await tester.runAsync(() => lkCall.hangUp());
await tester.runAsync(() async => partnerLk?.disconnect());
if (presenceWritten) await partnerPresence(false);
await pumpFor(tester, const Duration(seconds: 2));
// ── 8. Bubbles mit mehreren Personen (Vorführ-Daten) ──
final overlay = Overlay.of(
tester.element(find.byType(Scaffold).first),
rootOverlay: true,
);
final demo = OverlayEntry(builder: (_) => const CallBubblesDemo());
overlay.insert(demo);
await both('23_bubbles_5_personen', settle: const Duration(seconds: 3));
demo.remove();
final demo7 = OverlayEntry(builder: (_) => const CallBubblesDemo(count: 7));
overlay.insert(demo7);
await both('24_bubbles_7_personen', settle: const Duration(seconds: 3));
demo7.remove();
await pumpFor(tester, const Duration(milliseconds: 500));
// ── 9. Handy: untere Leiste statt Seitenmenü (Vergleich) ──
phone();
container.read(activeRoomIdProvider.notifier).state = null;
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(true));
container.read(mobileTabProvider.notifier).state = MobileTab.chats;
container.read(activeSpaceIdProvider.notifier).state = 'dms';
await pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, 'p_30_unten_chats');
await tester.tap(find.text('Server').last);
await pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, 'p_31_unten_server');
await tester.tap(find.text(_spaceName).first);
await pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, 'p_32_unten_server_kanaele');
await tester.tap(find.text('Du').last);
await pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, 'p_33_unten_du');
container.read(activeRoomIdProvider.notifier).state = group.id;
await pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, 'p_34_unten_chat_offen');
container.read(activeRoomIdProvider.notifier).state = null;
await pumpFor(tester, const Duration(seconds: 1));
await tester.tap(find.text('Chats').last);
await pumpFor(tester, const Duration(seconds: 1));
await tester.runAsync(() => container.read(bottomNavProvider.notifier).set(false));
await pumpFor(tester, const Duration(seconds: 1));
},
skip: profile == null || accounts == null,
timeout: const Timeout(Duration(minutes: 12)),
);
}
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');
}