refactor: lokale Nachrichtensuche als eigenes Modul (lib/features/search) + 5 Vertragstests

Verhaltensgleich aus search_modal.dart herausgeloest (gleiche Deckel 1500/60/500,
gleiche Schleife; Medien-Filter jetzt Parameter). Tests laufen ueber die echte
SDK-DB-Schicht mit synthetischen Daten (test/support/synthetic_matrix_data.dart).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 11:55:28 +02:00
co-authored by Claude Opus 5.5
parent b2413ef627
commit d5ff7982a0
4 changed files with 377 additions and 40 deletions
+200
View File
@@ -0,0 +1,200 @@
import 'dart:math';
import 'package:matrix/matrix.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
/// Synthetische Matrix-Daten für Tests und `benchmark/` – komplett offline:
/// kein Homeserver, keine echten Konten, keine Secrets.
///
/// Die Daten gehen über die ECHTE SDK-Datenbankschicht (`MatrixSdkDatabase`,
/// `storeRoomUpdate`/`storeEventUpdate` in Sync-artigen Transaktionen) – also
/// denselben Weg wie beim echten Sync. Dadurch messen/prüfen Tests und
/// Benchmarks die Speicherstruktur, die die App wirklich auf der Platte hat.
class SyntheticMatrixData {
SyntheticMatrixData._();
static const homeserverDomain = 'bench.invalid';
static const _senders = 5;
/// Wörter für die Nachrichtentexte (bewusst mit Umlauten – die Suche
/// arbeitet mit `toLowerCase`).
static const _words = [
'hallo',
'morgen',
'treffen',
'kaffee',
'bahnhof',
'urlaub',
'foto',
'danke',
'später',
'gleich',
'abend',
'essen',
'kino',
'wetter',
'regen',
'sonne',
'arbeit',
'pause',
'auto',
'zug',
'buch',
'musik',
'spiel',
'garten',
'hund',
'katze',
'einkaufen',
'termin',
'grüße',
'übermorgen',
];
static String roomId(int r) => '!room$r:$homeserverDomain';
static String eventId(int r, int i) => '\$r${r}e$i';
static String sender(int i) => '@user${i % _senders}:$homeserverDomain';
/// Öffnet die SDK-Datenbank auf [db] und baut einen Offline-Client darauf.
static Future<Client> openClient(
Database db, {
String name = 'PyramidBench',
}) async {
final sdkDb = await MatrixSdkDatabase.init('pyramid', database: db);
return Client(name, database: sdkDb);
}
/// Schreibt [rooms] Räume mit je [eventsPerRoom] Nachrichten in die DB.
///
/// Wie beim echten Sync landen die Events in Transaktionen à [batchSize]
/// (≈ eine Sync-Antwort) und in zeitlicher Reihenfolge (älteste zuerst).
/// Jede 10. Nachricht ist ein Bild (`m.image`). Ist [needleEvery] gesetzt,
/// enthält jede n-te Nachricht zusätzlich das Wort [needle] – so kennen
/// Tests die exakte Trefferzahl der Suche.
static Future<void> populate(
Client client, {
required int rooms,
required int eventsPerRoom,
int batchSize = 50,
String needle = 'nadelwort',
int? needleEvery,
int seed = 42,
}) async {
final db = client.database;
final rnd = Random(seed);
final baseTs = DateTime.utc(2026, 1, 1).millisecondsSinceEpoch;
for (var r = 0; r < rooms; r++) {
final id = roomId(r);
await db.transaction(() async {
await db.storeRoomUpdate(
id,
JoinedRoomUpdate(
summary: RoomSummary.fromJson({
'm.joined_member_count': _senders,
'm.invited_member_count': 0,
}),
),
null,
client,
);
for (final state in _roomState(r)) {
await db.storeEventUpdate(id, state, EventUpdateType.state, client);
}
});
for (var start = 0; start < eventsPerRoom; start += batchSize) {
final end = min(start + batchSize, eventsPerRoom);
await db.transaction(() async {
for (var i = start; i < end; i++) {
final hasNeedle = needleEvery != null && i % needleEvery == 0;
await db.storeEventUpdate(
id,
_message(r, i, baseTs, rnd, hasNeedle ? needle : null),
EventUpdateType.timeline,
client,
);
}
});
}
}
}
/// Lädt die Raumliste aus der DB in `client.rooms` – das, was beim
/// App-Start `client.init()` macht, ohne Netzwerk/Login.
static Future<List<Room>> loadRooms(Client client) async {
final rooms = await client.database.getRoomList(client);
client.rooms
..clear()
..addAll(rooms);
return rooms;
}
static List<MatrixEvent> _roomState(int r) {
final ts = DateTime.utc(2025, 12, 1).millisecondsSinceEpoch;
MatrixEvent state(
String type,
String stateKey,
Map<String, Object?> content,
int n,
) => MatrixEvent(
type: type,
eventId: '\$state${r}_$n',
senderId: sender(0),
originServerTs: DateTime.fromMillisecondsSinceEpoch(ts + n),
stateKey: stateKey,
content: content,
roomId: roomId(r),
);
return [
state(EventTypes.RoomCreate, '', {'creator': sender(0)}, 0),
state(EventTypes.RoomName, '', {'name': 'Raum $r'}, 1),
for (var u = 0; u < _senders; u++)
state(EventTypes.RoomMember, sender(u), {
'membership': 'join',
'displayname': 'Nutzer $u',
}, 2 + u),
];
}
static MatrixEvent _message(
int r,
int i,
int baseTs,
Random rnd,
String? needle,
) {
final isImage = i % 10 == 9;
final Map<String, Object?> content;
if (isImage) {
content = {
'msgtype': 'm.image',
'body': 'IMG_${r}_$i.jpg',
'url': 'mxc://$homeserverDomain/img$r$i',
'info': {
'w': 1920,
'h': 1080,
'mimetype': 'image/jpeg',
'size': 250000,
},
};
} else {
final words = List.generate(
4 + rnd.nextInt(20),
(_) => _words[rnd.nextInt(_words.length)],
);
if (needle != null) words.insert(rnd.nextInt(words.length), needle);
content = {'msgtype': 'm.text', 'body': words.join(' ')};
}
return MatrixEvent(
type: EventTypes.Message,
eventId: eventId(r, i),
senderId: sender(i),
originServerTs: DateTime.fromMillisecondsSinceEpoch(
baseTs + r * 1000 + i * 60000,
),
content: content,
roomId: roomId(r),
);
}
}