From c817de924a346786623d248cc5f04100e4430093 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Wed, 7 Oct 2026 23:33:59 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Haken=20live,=20Einladung=20im=20Chat=20?= =?UTF-8?q?annehmen,=20Trenner=20verschwindet=20zuverl=C3=A4ssig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bernds Test mit der PC-Testinstanz: - ✓✓ erschien erst nach erneutem Öffnen: eine Lesebestätigung ohne neue Nachricht zeichnete nichts neu (Raum-Update kommt im SDK vor der Bestätigung). Jetzt nach jedem Sync Fingerabdruck der Lesepositionen prüfen und bei Änderung neu zeichnen. - Einladung: Chat zeigte leeren Verlauf, Annehmen ging nur in der Liste, und danach blieb der Chat leer bis zum App-Neustart. Jetzt Annehmen/Ablehnen direkt im Chat; Annehmen wartet auf den Beitritts-Sync, der Verlauf baut sich bei invite→join selbst neu auf. - Trenner blieb stehen, wenn nach dem Öffnen/Zurückkommen nicht gescrollt wurde. Jetzt: neueste Nachrichten sichtbar (≤ halbe Bildschirmhöhe hochgescrollt) → nach 6 s weich ausblenden, auch ohne Scrollen. - Live-Test mit beiden Testkonten (Gegenprobe: ohne Fix rot) in scripts/test.ps1 -Ui; Testlauf-Sperre erkennt nur noch eigene Testläufe. Co-Authored-By: Claude Opus 5.5 --- .../live_receipts_invite_test.dart | 243 ++++++++++++++++++ lib/features/chat/chat_provider.dart | 56 ++++ lib/features/chat/view/chat_misc.dart | 139 ++++++++++ lib/features/chat/view/chat_view_core.dart | 37 ++- .../rooms/panel/rooms_invite_item.dart | 2 +- lib/features/rooms/rooms_panel.dart | 1 + scripts/test.ps1 | 17 +- 7 files changed, 479 insertions(+), 16 deletions(-) create mode 100644 integration_test/live_receipts_invite_test.dart diff --git a/integration_test/live_receipts_invite_test.dart b/integration_test/live_receipts_invite_test.dart new file mode 100644 index 0000000..80afcda --- /dev/null +++ b/integration_test/live_receipts_invite_test.dart @@ -0,0 +1,243 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +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/main.dart' as app; + +/// Live mit beiden Testkonten (Bernds Fehlerbericht 2026-10-07): +/// 1. ✓✓ erscheint LIVE, sobald der Partner liest – ohne den Chat neu zu +/// öffnen (vorher zeichnete eine reine Lesebestätigung nichts neu). +/// 2. Einladung lässt sich direkt im Chat annehmen, danach ist der Verlauf +/// sofort da (vorher leerer Chat bis zum App-Neustart). +/// +/// Die App läuft mit dem Testprofil (PYRAMID_PROFILE_DIR, pyramidtest1). +/// pyramidtest2 wird per Client-Server-API mit einem kurzlebigen Gerät +/// gesteuert, das am Ende abgemeldet wird – andere Sitzungen (z. B. Bernds +/// Testinstanz mit profile2) bleiben unberührt. +/// +/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1" +/// flutter test integration_test/live_receipts_invite_test.dart -d windows +const _hs = 'https://steggi-matrix.work'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final profile = Platform.environment['PYRAMID_PROFILE_DIR']; + final accounts = _loadAccounts(); + + testWidgets( + 'Lesebestätigung live + Einladung im Chat annehmen', + (tester) async { + tester.view + ..physicalSize = const Size(1080, 2340) + ..devicePixelRatio = 2.625; + addTearDown(tester.view.reset); + + final partner = await tester.runAsync( + () => _RestUser.login(accounts!['user2']!, accounts['pass2']!), + ); + addTearDown(() => tester.runAsync(() => partner!.logout())); + + app.main(); + await _pumpUntil(tester, find.byType(MaterialApp)); + final container = ProviderScope.containerOf( + tester.element(find.byType(MaterialApp).first), + ); + final client = (await tester.runAsync( + () => container.read(matrixClientProvider.future), + ))!; + expect(client.userID, '@pyramidtest1:steggi-matrix.work'); + await _pumpFor(tester, const Duration(seconds: 4)); + + // ── 1. Lesebestätigung live ── + final dm = client.rooms.firstWhere( + (r) => + r.membership == Membership.join && + r.directChatMatrixID == '@pyramidtest2:steggi-matrix.work', + ); + container.read(activeRoomIdProvider.notifier).state = dm.id; + await _pumpFor(tester, const Duration(seconds: 4)); + + final text = 'Lesetest ${DateTime.now().millisecondsSinceEpoch}'; + final eventId = await tester.runAsync(() => dm.sendTextEvent(text)); + await _pumpUntil(tester, find.textContaining(text)); + await _pumpFor(tester, const Duration(seconds: 3)); + expect( + find.byIcon(Icons.done_rounded), + findsOneWidget, + reason: 'eigene neueste Nachricht sollte noch ✓ (ungelesen) zeigen', + ); + + await tester.runAsync(() => partner!.readReceipt(dm.id, eventId!)); + // Ohne den Chat neu zu öffnen: ✓ wird zu ✓✓ direkt unter der Nachricht. + await _pumpUntil( + tester, + find.byIcon(Icons.done_rounded), + gone: true, + timeout: const Duration(seconds: 30), + ); + final msgY = tester.getCenter(find.textContaining(text).first).dy; + final readIcons = find.byIcon(Icons.done_all_rounded).evaluate(); + expect( + readIcons.any((e) { + final box = e.renderObject as RenderBox; + final y = box.localToGlobal(box.size.center(Offset.zero)).dy; + return y > msgY && y - msgY < 120; + }), + isTrue, + reason: '✓✓ erscheint nicht live unter der gelesenen Nachricht', + ); + + // ── 2. Einladung im Chat annehmen ── + final welcome = 'Willkommen im Einladungstest ${DateTime.now().millisecondsSinceEpoch}'; + final inviteRoomId = await tester.runAsync(() async { + final id = await partner!.createRoom('Einladungstest'); + await partner.sendText(id, welcome); + await partner.invite(id, client.userID!); + return id; + }); + addTearDown(() => tester.runAsync(() => partner!.leave(inviteRoomId!))); + + // Einladung kommt per Sync an → Chat öffnen (wie aus der Benachrichtigung). + final end = DateTime.now().add(const Duration(seconds: 30)); + while (client.getRoomById(inviteRoomId!)?.membership != Membership.invite && + DateTime.now().isBefore(end)) { + await _pumpFor(tester, const Duration(milliseconds: 500)); + } + container.read(activeRoomIdProvider.notifier).state = inviteRoomId; + await _pumpUntil(tester, find.text('Annehmen')); + await tester.tap(find.text('Annehmen')); + // Ohne App-Neustart: Verlauf mit der Begrüßung erscheint. + await _pumpUntil( + tester, + find.textContaining(welcome), + timeout: const Duration(seconds: 40), + ); + expect(find.text('Annehmen'), findsNothing); + + // Aufräumen: Testraum verlassen + vergessen. + await tester.runAsync(() async { + final r = client.getRoomById(inviteRoomId); + await r?.leave(); + await r?.forget().catchError((_) {}); + }); + container.read(activeRoomIdProvider.notifier).state = null; + await _pumpFor(tester, const Duration(seconds: 1)); + }, + skip: profile == null || accounts == null, + timeout: const Timeout(Duration(minutes: 4)), + ); +} + +Map? _loadAccounts() { + final home = Platform.environment['USERPROFILE'] ?? ''; + final file = File('$home\\.pyramid-autopilot\\test-accounts.txt'); + if (!file.existsSync()) return null; + final map = {}; + for (final line in file.readAsLinesSync()) { + final t = line.trim(); + if (t.isEmpty || t.startsWith('#')) continue; + final i = t.indexOf('='); + if (i > 0) map[t.substring(0, i)] = t.substring(i + 1); + } + return map; +} + +/// Minimaler Matrix-Zugang per Client-Server-API (kurzlebiges Gerät). +class _RestUser { + _RestUser(this._token); + final String _token; + int _txn = 0; + + static Future<_RestUser> login(String user, String pass) async { + final res = await http.post( + Uri.parse('$_hs/_matrix/client/v3/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'type': 'm.login.password', + 'identifier': {'type': 'm.id.user', 'user': user}, + 'password': pass, + 'initial_device_display_name': 'Pyramid-Livetest (kurzlebig)', + }), + ); + if (res.statusCode != 200) throw StateError('Login ${res.statusCode}'); + return _RestUser(jsonDecode(res.body)['access_token'] as String); + } + + Future> _call(String method, String path, + [Object? body]) async { + final req = http.Request(method, Uri.parse('$_hs/_matrix/client/v3$path')) + ..headers['Authorization'] = 'Bearer $_token' + ..headers['Content-Type'] = 'application/json' + ..body = jsonEncode(body ?? {}); + final res = await http.Response.fromStream(await req.send()); + if (res.statusCode != 200) { + throw StateError('$method $path → ${res.statusCode}'); + } + return jsonDecode(res.body) as Map; + } + + String _enc(String s) => Uri.encodeComponent(s); + + Future readReceipt(String roomId, String eventId) => _call( + 'POST', + '/rooms/${_enc(roomId)}/read_markers', + {'m.fully_read': eventId, 'm.read': eventId}, + ); + + Future createRoom(String name) async => + (await _call('POST', '/createRoom', { + 'name': name, + 'preset': 'private_chat', + }))['room_id'] as String; + + Future sendText(String roomId, String text) => _call( + 'PUT', + '/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}', + {'msgtype': 'm.text', 'body': text}, + ); + + Future invite(String roomId, String userId) => + _call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId}); + + Future leave(String roomId) async { + try { + await _call('POST', '/rooms/${_enc(roomId)}/leave'); + await _call('POST', '/rooms/${_enc(roomId)}/forget'); + } catch (_) {} + } + + Future logout() async { + try { + await _call('POST', '/logout'); + } catch (_) {} + } +} + +Future _pumpFor(WidgetTester tester, Duration d) async { + final end = DateTime.now().add(d); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + bool gone = false, + Duration timeout = const Duration(seconds: 60), +}) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 200)); + final found = finder.evaluate().isNotEmpty; + if (found != gone) return; + } + fail('Zeitüberschreitung: $finder ${gone ? "verschwindet nicht" : "nicht gefunden"}'); +} diff --git a/lib/features/chat/chat_provider.dart b/lib/features/chat/chat_provider.dart index 7d53d92..a531f1b 100644 --- a/lib/features/chat/chat_provider.dart +++ b/lib/features/chat/chat_provider.dart @@ -10,7 +10,9 @@ import 'package:pyramid/core/matrix_client.dart'; class TimelineNotifier extends FamilyAsyncNotifier { Timeline? _timeline; StreamSubscription? _roomUpdateSub; + StreamSubscription? _syncSub; Timer? _redecryptTimer; + String _receiptSig = ''; @override Future build(String arg) async { @@ -21,6 +23,7 @@ class TimelineNotifier extends FamilyAsyncNotifier { _timeline?.cancelSubscriptions(); _roomUpdateSub?.cancel(); + _syncSub?.cancel(); _redecryptTimer?.cancel(); final diag = ref.read(e2eeDiagnosticsProvider.notifier); @@ -49,6 +52,28 @@ class TimelineNotifier extends FamilyAsyncNotifier { }); }); + // Nach jedem Sync (erst dann sind Lesebestätigungen verarbeitet – das + // Raum-Update kommt im SDK VORHER): + // • Einladung angenommen (invite → join): Verlauf neu aufbauen; der beim + // Einladen gebaute blieb sonst leer, bis man die App neu startete. + // • Lesebestätigung eines anderen hat sich geändert: neu zeichnen, damit + // ✓✓ live erscheint. Neue Nachrichten allein lösen _onUpdate schon aus, + // eine Bestätigung ohne neue Nachricht bisher nicht. + final membershipAtBuild = room.membership; + _receiptSig = _receiptSignature(room); + _syncSub = client.onSync.stream.listen((_) { + if (membershipAtBuild != Membership.join && + room.membership == Membership.join) { + ref.invalidateSelf(); + return; + } + final sig = _receiptSignature(room); + if (sig != _receiptSig) { + _receiptSig = sig; + _onUpdate(); + } + }); + // Ensure device keys are downloaded for all room members so outbound // Megolm session key sharing works on the first send. if (room.encrypted && client.encryptionEnabled) { @@ -57,6 +82,7 @@ class TimelineNotifier extends FamilyAsyncNotifier { ref.onDispose(() { _roomUpdateSub?.cancel(); + _syncSub?.cancel(); _redecryptTimer?.cancel(); _timeline?.cancelSubscriptions(); _timeline = null; @@ -70,6 +96,36 @@ class TimelineNotifier extends FamilyAsyncNotifier { } } +/// Kurzer Fingerabdruck der Lesepositionen der anderen Mitglieder. +String _receiptSignature(Room room) { + final b = StringBuffer(); + for (final users in [ + room.receiptState.global.otherUsers, + room.receiptState.mainThread?.otherUsers ?? const {}, + ]) { + users.forEach((userId, data) => b + ..write(userId) + ..write('=') + ..write(data.eventId) + ..write(';')); + } + return b.toString(); +} + +/// Einladung annehmen und warten, bis der Raum als beigetreten +/// synchronisiert ist – erst dann hat der Verlauf Inhalt. +Future acceptInvite(WidgetRef ref, Room room) async { + final client = room.client; + // Vor dem Beitreten anfangen zu warten, sonst kann der Sync durchrutschen. + final synced = client + .waitForRoomInSync(room.id, join: true) + .timeout(const Duration(seconds: 20)) + .then((_) {}, onError: (_) {}); + await room.join(); + await synced; + ref.invalidate(timelineProvider(room.id)); +} + final timelineProvider = AsyncNotifierProvider.family( TimelineNotifier.new, diff --git a/lib/features/chat/view/chat_misc.dart b/lib/features/chat/view/chat_misc.dart index ce923bd..b718c8a 100644 --- a/lib/features/chat/view/chat_misc.dart +++ b/lib/features/chat/view/chat_misc.dart @@ -151,3 +151,142 @@ class _FloatingComposerArea extends StatelessWidget { ); } } + +/// Einladung in diesen Chat (z. B. aus einer Benachrichtigung geöffnet): +/// Annehmen/Ablehnen direkt hier statt eines leeren Verlaufs – vorher ging +/// das nur in der Chatliste (Bernd 2026-10-07). +class _InvitePanel extends ConsumerStatefulWidget { + final Room room; + final PyramidTheme pt; + const _InvitePanel({required this.room, required this.pt}); + + @override + ConsumerState<_InvitePanel> createState() => _InvitePanelState(); +} + +class _InvitePanelState extends ConsumerState<_InvitePanel> { + bool _busy = false; + String? _error; + + String get _inviter { + final me = widget.room.client.userID; + final sender = me == null + ? null + : widget.room.getState(EventTypes.RoomMember, me)?.senderId; + if (sender == null) return 'Jemand'; + return widget.room.unsafeGetUserFromMemoryOrFallback(sender).calcDisplayname(); + } + + Future _accept() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await acceptInvite(ref, widget.room); + } catch (_) { + if (mounted) { + setState(() { + _busy = false; + _error = 'Annehmen hat nicht geklappt – bitte noch einmal versuchen.'; + }); + } + } + } + + Future _decline() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await widget.room.leave(); + if (mounted) ref.read(activeRoomIdProvider.notifier).state = null; + } catch (_) { + if (mounted) { + setState(() { + _busy = false; + _error = 'Ablehnen hat nicht geklappt – bitte noch einmal versuchen.'; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final isDm = widget.room.isDirectChat; + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isDm ? Icons.person_add_alt_1_rounded : Icons.group_add_rounded, + size: 48, + color: pt.accent, + ), + const SizedBox(height: 16), + Text( + isDm + ? '$_inviter möchte mit dir chatten' + : '$_inviter lädt dich in „${widget.room.getLocalizedDisplayname()}“ ein', + textAlign: TextAlign.center, + style: TextStyle( + color: pt.fg, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Nach dem Annehmen siehst du die Nachrichten und kannst antworten.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13.5), + ), + const SizedBox(height: 24), + if (_busy) + SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2.5, color: pt.accent), + ) + else + Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: [ + OutlinedButton( + onPressed: _decline, + style: OutlinedButton.styleFrom( + foregroundColor: pt.fgMuted, + side: BorderSide(color: pt.border), + padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14), + ), + child: const Text('Ablehnen'), + ), + FilledButton( + onPressed: _accept, + style: FilledButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 14), + ), + child: const Text('Annehmen'), + ), + ], + ), + if (_error != null) ...[ + const SizedBox(height: 14), + Text(_error!, + textAlign: TextAlign.center, + style: TextStyle(color: pt.danger, fontSize: 13)), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/chat/view/chat_view_core.dart b/lib/features/chat/view/chat_view_core.dart index e8bb42f..4270c4b 100644 --- a/lib/features/chat/view/chat_view_core.dart +++ b/lib/features/chat/view/chat_view_core.dart @@ -284,7 +284,7 @@ class _ChatViewState extends ConsumerState { cancelNotificationForRoom(widget.roomId); final room = ref.read(roomProvider(widget.roomId)); - if (room == null) return; + if (room == null || room.membership != Membership.join) return; if (room.markedUnread) room.markUnread(false).catchError((_) {}); final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull; final latest = timeline?.events @@ -326,14 +326,21 @@ class _ChatViewState extends ConsumerState { _sessionHasUnread = true; _unreadFading = false; }); - _scheduleUnreadFadeIfAtNewest(); } } + if (_sessionHasUnread) _scheduleUnreadFadeIfAtNewest(); _markRead(timeline); } - bool get _atNewest => - !_scrollCtrl.hasClients || _scrollCtrl.position.pixels < 60; + // Neueste Nachrichten sind zu sehen: höchstens eine halbe Bildschirmhöhe + // hochgescrollt. (Nach dem Sprung zum Trenner steht die Liste meist ein + // Stück oberhalb des Endes – mit der strengen 60-px-Grenze blieb der + // Trenner dann für immer stehen.) + bool get _atNewest { + if (!_scrollCtrl.hasClients) return true; + final pos = _scrollCtrl.position; + return pos.pixels < 60 || pos.pixels < pos.viewportDimension * 0.5; + } /// „Neue Nachrichten“-Trenner nach [_unreadDwell] weich ausblenden – nur /// wenn man dann noch bei den neuesten Nachrichten ist. Wer zwischendurch @@ -342,7 +349,10 @@ class _ChatViewState extends ConsumerState { if (!_sessionHasUnread || _unreadFading) return; if (_unreadDwellTimer?.isActive ?? false) return; _unreadDwellTimer = Timer(_unreadDwell, () { - if (!mounted || !_sessionHasUnread || !_atNewest) return; + if (!mounted || !_sessionHasUnread) return; + // App im Hintergrund oder weggescrollt: stehen lassen, beim nächsten + // Scrollen/Zurückkommen beginnt die Wartezeit neu. + if (!_atNewest || !ref.read(windowFocusedProvider)) return; setState(() => _unreadFading = true); _unreadFadeTimer?.cancel(); _unreadFadeTimer = Timer(const Duration(milliseconds: 900), () { @@ -365,12 +375,10 @@ class _ChatViewState extends ConsumerState { final pixels = _scrollCtrl.position.pixels; final maxExtent = _scrollCtrl.position.maxScrollExtent; - if (pixels < 60) { - _markRead(null); - // Bei den neuesten Nachrichten angekommen → Trenner nach einer - // Verweildauer ausblenden. - _scheduleUnreadFade(); - } + if (pixels < 60) _markRead(null); + // Bei den neuesten Nachrichten angekommen → Trenner nach einer + // Verweildauer ausblenden. + if (_atNewest) _scheduleUnreadFade(); final shouldShow = pixels > 400; if (shouldShow != _showScrollDown) { setState(() => _showScrollDown = shouldShow); @@ -439,6 +447,9 @@ class _ChatViewState extends ConsumerState { } final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; + // Noch nicht beigetreten (Einladung): statt eines leeren Verlaufs gleich + // hier Annehmen/Ablehnen anbieten. + final isInvite = room?.membership == Membership.invite; final body = Stack( fit: StackFit.expand, children: [ @@ -510,7 +521,9 @@ class _ChatViewState extends ConsumerState { ), const _ConnectionBanner(), Expanded( - child: Stack( + child: isInvite + ? _InvitePanel(room: room!, pt: pt) + : Stack( children: [ timelineAsync.when( loading: () => Center( diff --git a/lib/features/rooms/panel/rooms_invite_item.dart b/lib/features/rooms/panel/rooms_invite_item.dart index 5ee17a5..72e8219 100644 --- a/lib/features/rooms/panel/rooms_invite_item.dart +++ b/lib/features/rooms/panel/rooms_invite_item.dart @@ -16,7 +16,7 @@ class _InviteItemState extends ConsumerState<_InviteItem> { Future _accept() async { setState(() => _accepting = true); try { - await widget.room.join(); + await acceptInvite(ref, widget.room); if (mounted) { ref.read(activeRoomIdProvider.notifier).state = widget.room.id; ref.read(viewModeProvider.notifier).state = ViewMode.chat; diff --git a/lib/features/rooms/rooms_panel.dart b/lib/features/rooms/rooms_panel.dart index d058f9b..c819438 100644 --- a/lib/features/rooms/rooms_panel.dart +++ b/lib/features/rooms/rooms_panel.dart @@ -12,6 +12,7 @@ import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/notification_content.dart' show notificationBody; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/features/rooms/rooms_provider.dart'; +import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite; import 'package:pyramid/features/call_ui/mini_call_widget.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; import 'package:pyramid/features/voice_channel/voice_channel_service.dart'; diff --git a/scripts/test.ps1 b/scripts/test.ps1 index 4b7e1cc..256c901 100644 --- a/scripts/test.ps1 +++ b/scripts/test.ps1 @@ -109,15 +109,26 @@ if ($Ui) { $uiProfile = Join-Path $env:USERPROFILE ".pyramid-autopilot\profile1" if (-not (Test-Path (Join-Path $uiProfile "pyramid.sqlite"))) { Add-Skipped "UI-Durchlauf (Handy-Format)" "Testprofil fehlt: $uiProfile" - } elseif (Get-Process pyramid -ErrorAction SilentlyContinue) { - # Nie zwei Matrix-Clients auf derselben DB – und Bernds Fenster nicht stören. - Add-Skipped "UI-Durchlauf (Handy-Format)" "Pyramid läuft gerade – erst beenden" + } elseif (Get-Process pyramid -ErrorAction SilentlyContinue | + Where-Object { $_.Path -like "*\build\windows\*" }) { + # Nie zwei Matrix-Clients auf derselben DB: ein noch laufender Testlauf + # (Build-Ordner) nutzt profile1. Bernds echte Pyramid und seine + # Testinstanz (profile2, C:\Users\nordm\pyramid-testinstanz) stören nicht. + Add-Skipped "UI-Durchlauf (Handy-Format)" "Ein Testlauf läuft noch – erst beenden" } else { $env:PYRAMID_PROFILE_DIR = $uiProfile try { Invoke-Step "UI-Durchlauf (Handy-Format, Testprofil)" { flutter test integration_test/mobile_ui_screens_test.dart -d windows } + $accounts = Join-Path $env:USERPROFILE ".pyramid-autopilot\test-accounts.txt" + if (Test-Path $accounts) { + Invoke-Step "Live: Lesebestätigung + Einladung (2 Testkonten)" { + flutter test integration_test/live_receipts_invite_test.dart -d windows + } + } else { + Add-Skipped "Live: Lesebestätigung + Einladung" "test-accounts.txt fehlt" + } } finally { Remove-Item Env:PYRAMID_PROFILE_DIR -ErrorAction SilentlyContinue }