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); // ── 3. „Neue Nachrichten“-Trenner MITTEN in einer Gruppe verschwindet ── // (Bernd 2026-10-08: blieb manchmal stehen – bei mehreren Nachrichten // desselben Absenders hintereinander, gelesen bis zur ersten.) container.read(activeRoomIdProvider.notifier).state = null; await _pumpFor(tester, const Duration(seconds: 1)); final ids = (await tester.runAsync(() async => [ await partner!.sendText(inviteRoomId, 'Gruppe 1'), await partner.sendText(inviteRoomId, 'Gruppe 2'), await partner.sendText(inviteRoomId, 'Gruppe 3'), ]))!; final room = client.getRoomById(inviteRoomId)!; // Erst warten, bis alle drei da sind, dann bis „Gruppe 1“ gelesen melden. final tEnd = DateTime.now().add(const Duration(seconds: 30)); while (room.lastEvent?.eventId != ids[2] && DateTime.now().isBefore(tEnd)) { await _pumpFor(tester, const Duration(milliseconds: 300)); } await tester.runAsync(() => room.setReadMarker(ids[0], mRead: ids[0])); final fEnd = DateTime.now().add(const Duration(seconds: 30)); while (room.fullyRead != ids[0] && DateTime.now().isBefore(fEnd)) { await _pumpFor(tester, const Duration(milliseconds: 300)); } expect(room.fullyRead, ids[0], reason: 'Lesemarker kam nicht an'); container.read(activeRoomIdProvider.notifier).state = inviteRoomId; await _pumpUntil(tester, find.text('Neue Nachrichten'), timeout: const Duration(seconds: 20)); // Nach der Verweildauer (6 s) + Ausblenden muss er weg sein. await _pumpUntil( tester, find.text('Neue Nachrichten'), gone: true, timeout: const Duration(seconds: 15), ); // 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) async => (await _call( 'PUT', '/rooms/${_enc(roomId)}/send/m.room.message/t${DateTime.now().microsecondsSinceEpoch}${_txn++}', {'msgtype': 'm.text', 'body': text}, ))['event_id'] as String; 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"}'); }