fix: Haken live, Einladung im Chat annehmen, Trenner verschwindet zuverlässig

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 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 23:33:59 +02:00
co-authored by Claude Opus 5.5
parent a6461fe23c
commit c817de924a
7 changed files with 479 additions and 16 deletions
@@ -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<String, String>? _loadAccounts() {
final home = Platform.environment['USERPROFILE'] ?? '';
final file = File('$home\\.pyramid-autopilot\\test-accounts.txt');
if (!file.existsSync()) return null;
final map = <String, String>{};
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<Map<String, dynamic>> _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, dynamic>;
}
String _enc(String s) => Uri.encodeComponent(s);
Future<void> readReceipt(String roomId, String eventId) => _call(
'POST',
'/rooms/${_enc(roomId)}/read_markers',
{'m.fully_read': eventId, 'm.read': eventId},
);
Future<String> createRoom(String name) async =>
(await _call('POST', '/createRoom', {
'name': name,
'preset': 'private_chat',
}))['room_id'] as String;
Future<void> 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<void> invite(String roomId, String userId) =>
_call('POST', '/rooms/${_enc(roomId)}/invite', {'user_id': userId});
Future<void> leave(String roomId) async {
try {
await _call('POST', '/rooms/${_enc(roomId)}/leave');
await _call('POST', '/rooms/${_enc(roomId)}/forget');
} catch (_) {}
}
Future<void> logout() async {
try {
await _call('POST', '/logout');
} catch (_) {}
}
}
Future<void> _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<void> _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"}');
}