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:
co-authored by
Claude Opus 5.5
parent
b2413ef627
commit
d5ff7982a0
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
/// Lokale Nachrichtensuche über die entschlüsselten Events in der Client-DB.
|
||||||
|
///
|
||||||
|
/// Die server-seitige `/search`-API sieht bei E2EE nur Chiffretext und liefert
|
||||||
|
/// für verschlüsselte Räume nichts – deshalb wird (wie bei Element) die lokale
|
||||||
|
/// Datenbank durchsucht. Aus `search_modal.dart` herausgelöst, damit Tests und
|
||||||
|
/// `benchmark/` genau diesen Codepfad prüfen/messen können.
|
||||||
|
///
|
||||||
|
/// Bei [mediaOnly] werden nur Medien-Nachrichten geliefert, deren
|
||||||
|
/// Dateiname/Beschriftung passt. Ergebnis: neueste zuerst, höchstens
|
||||||
|
/// [searchTotalCap] Treffer; pro Raum werden höchstens
|
||||||
|
/// [searchPerRoomScanCap] Events gescannt.
|
||||||
|
Future<List<Event>> localMessageSearch(
|
||||||
|
Client client,
|
||||||
|
String query, {
|
||||||
|
bool mediaOnly = false,
|
||||||
|
}) async {
|
||||||
|
final db = client.database;
|
||||||
|
final lower = query.toLowerCase();
|
||||||
|
const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'};
|
||||||
|
|
||||||
|
final results = <Event>[];
|
||||||
|
for (final room in client.rooms.where((r) => !r.isSpace)) {
|
||||||
|
var start = 0;
|
||||||
|
var scanned = 0;
|
||||||
|
try {
|
||||||
|
while (scanned < searchPerRoomScanCap) {
|
||||||
|
final events = await db.getEventList(
|
||||||
|
room,
|
||||||
|
start: start,
|
||||||
|
limit: searchScanChunk,
|
||||||
|
);
|
||||||
|
if (events.isEmpty) break;
|
||||||
|
for (final e in events) {
|
||||||
|
if (e.type != EventTypes.Message || e.redacted) continue;
|
||||||
|
final msgtype = e.content.tryGet<String>('msgtype') ?? '';
|
||||||
|
if (mediaOnly && !mediaTypes.contains(msgtype)) continue;
|
||||||
|
if (!e.body.toLowerCase().contains(lower)) continue;
|
||||||
|
results.add(e);
|
||||||
|
}
|
||||||
|
start += searchScanChunk;
|
||||||
|
scanned += events.length;
|
||||||
|
final roomExhausted = events.length < searchScanChunk;
|
||||||
|
if (roomExhausted || results.length >= searchTotalCap) break;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (results.length >= searchTotalCap) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs));
|
||||||
|
return results.take(searchTotalCap).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Höchstens so viele Events werden pro Raum gescannt.
|
||||||
|
const searchPerRoomScanCap = 1500;
|
||||||
|
|
||||||
|
/// Höchstens so viele Treffer liefert die Suche insgesamt.
|
||||||
|
const searchTotalCap = 60;
|
||||||
|
|
||||||
|
/// So viele Events werden pro DB-Abfrage geladen.
|
||||||
|
const searchScanChunk = 500;
|
||||||
@@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart';
|
|||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
|
import 'package:pyramid/features/search/local_message_search.dart';
|
||||||
import 'package:pyramid/widgets/mxc_image.dart';
|
import 'package:pyramid/widgets/mxc_image.dart';
|
||||||
|
|
||||||
// ─── State ────────────────────────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
@@ -110,7 +111,11 @@ class _SearchModalState extends ConsumerState<SearchModal> {
|
|||||||
List<Event> messageResults = [];
|
List<Event> messageResults = [];
|
||||||
if (_filter != _Filter.people) {
|
if (_filter != _Filter.people) {
|
||||||
try {
|
try {
|
||||||
messageResults = await _localMessageSearch(client, q);
|
messageResults = await localMessageSearch(
|
||||||
|
client,
|
||||||
|
q,
|
||||||
|
mediaOnly: _filter == _Filter.media,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _state = _state.copyWith(
|
setState(() => _state = _state.copyWith(
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -129,45 +134,6 @@ class _SearchModalState extends ConsumerState<SearchModal> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scans the local (decrypted) event database across all rooms for messages
|
|
||||||
// whose body matches the query. For the media filter, only media messages
|
|
||||||
// whose filename/caption matches are returned.
|
|
||||||
Future<List<Event>> _localMessageSearch(Client client, String q) async {
|
|
||||||
final db = client.database;
|
|
||||||
final lower = q.toLowerCase();
|
|
||||||
final media = _filter == _Filter.media;
|
|
||||||
const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'};
|
|
||||||
const perRoomScanCap = 1500;
|
|
||||||
const totalCap = 60;
|
|
||||||
const chunk = 500;
|
|
||||||
|
|
||||||
final results = <Event>[];
|
|
||||||
for (final room in client.rooms.where((r) => !r.isSpace)) {
|
|
||||||
var start = 0;
|
|
||||||
var scanned = 0;
|
|
||||||
try {
|
|
||||||
while (scanned < perRoomScanCap) {
|
|
||||||
final events = await db.getEventList(room, start: start, limit: chunk);
|
|
||||||
if (events.isEmpty) break;
|
|
||||||
for (final e in events) {
|
|
||||||
if (e.type != EventTypes.Message || e.redacted) continue;
|
|
||||||
final msgtype = e.content.tryGet<String>('msgtype') ?? '';
|
|
||||||
if (media && !mediaTypes.contains(msgtype)) continue;
|
|
||||||
if (!e.body.toLowerCase().contains(lower)) continue;
|
|
||||||
results.add(e);
|
|
||||||
}
|
|
||||||
start += chunk;
|
|
||||||
scanned += events.length;
|
|
||||||
if (events.length < chunk || results.length >= totalCap) break;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
if (results.length >= totalCap) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs));
|
|
||||||
return results.take(totalCap).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openRoom(String roomId, {String? eventId}) {
|
void _openRoom(String roomId, {String? eventId}) {
|
||||||
widget.onClose();
|
widget.onClose();
|
||||||
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
ref.read(activeRoomIdProvider.notifier).state = roomId;
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/features/search/local_message_search.dart';
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
|
||||||
|
import 'support/synthetic_matrix_data.dart';
|
||||||
|
|
||||||
|
/// Vertragstests für die lokale Nachrichtensuche (Suchdialog, Strg+K).
|
||||||
|
/// Laufen auf der echten SDK-Datenbankschicht mit normalem sqlite – die
|
||||||
|
/// Suche selbst ist von der Verschlüsselung unabhängig (die misst
|
||||||
|
/// `benchmark/db_benchmark_test.dart`).
|
||||||
|
void main() {
|
||||||
|
late Client client;
|
||||||
|
|
||||||
|
Future<void> setUpClient({
|
||||||
|
required int rooms,
|
||||||
|
required int eventsPerRoom,
|
||||||
|
int? needleEvery,
|
||||||
|
}) async {
|
||||||
|
final factory = createDatabaseFactoryFfi(
|
||||||
|
ffiInit: sqfliteFfiInit,
|
||||||
|
noIsolate: true,
|
||||||
|
);
|
||||||
|
final db = await factory.openDatabase(inMemoryDatabasePath);
|
||||||
|
client = await SyntheticMatrixData.openClient(
|
||||||
|
db,
|
||||||
|
name: 'PyramidSearchTest',
|
||||||
|
);
|
||||||
|
await SyntheticMatrixData.populate(
|
||||||
|
client,
|
||||||
|
rooms: rooms,
|
||||||
|
eventsPerRoom: eventsPerRoom,
|
||||||
|
needleEvery: needleEvery,
|
||||||
|
);
|
||||||
|
await SyntheticMatrixData.loadRooms(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await client.dispose(closeDatabase: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'findet genau die Nachrichten mit dem Suchwort, neueste zuerst',
|
||||||
|
() async {
|
||||||
|
// 3 Räume × 40 Nachrichten, jede 7. Text-Nachricht trägt das Suchwort.
|
||||||
|
// Jede 10. Nachricht ist ein Bild (ohne Suchwort im Dateinamen).
|
||||||
|
await setUpClient(rooms: 3, eventsPerRoom: 40, needleEvery: 7);
|
||||||
|
final expectedPerRoom = [
|
||||||
|
for (var i = 0; i < 40; i += 7) i,
|
||||||
|
].where((i) => i % 10 != 9).length;
|
||||||
|
|
||||||
|
final results = await localMessageSearch(client, 'nadelwort');
|
||||||
|
|
||||||
|
expect(results, hasLength(3 * expectedPerRoom));
|
||||||
|
expect(results.every((e) => e.body.contains('nadelwort')), isTrue);
|
||||||
|
for (var i = 1; i < results.length; i++) {
|
||||||
|
expect(
|
||||||
|
results[i - 1].originServerTs.isAfter(results[i].originServerTs) ||
|
||||||
|
results[i - 1].originServerTs == results[i].originServerTs,
|
||||||
|
isTrue,
|
||||||
|
reason: 'Ergebnisse müssen absteigend nach Zeit sortiert sein',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('Groß-/Kleinschreibung egal, auch bei Umlauten', () async {
|
||||||
|
await setUpClient(rooms: 1, eventsPerRoom: 30, needleEvery: 3);
|
||||||
|
for (final (lowerQuery, upperQuery) in [
|
||||||
|
('nadelwort', 'NADELWORT'),
|
||||||
|
('übermorgen', 'ÜBERMORGEN'),
|
||||||
|
]) {
|
||||||
|
final lower = await localMessageSearch(client, lowerQuery);
|
||||||
|
final upper = await localMessageSearch(client, upperQuery);
|
||||||
|
expect(lower, isNotEmpty, reason: '"$lowerQuery" kommt in den Daten vor');
|
||||||
|
expect(upper.map((e) => e.eventId), lower.map((e) => e.eventId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Medien-Filter liefert nur Medien-Nachrichten (Dateiname passt)',
|
||||||
|
() async {
|
||||||
|
await setUpClient(rooms: 2, eventsPerRoom: 30);
|
||||||
|
// "g" steckt in "IMG_…jpg" UND in vielen Textwörtern ("morgen", …).
|
||||||
|
final all = await localMessageSearch(client, 'g');
|
||||||
|
final media = await localMessageSearch(client, 'g', mediaOnly: true);
|
||||||
|
expect(
|
||||||
|
all.any((e) => e.content['msgtype'] == 'm.text'),
|
||||||
|
isTrue,
|
||||||
|
reason: 'ohne Filter müssen auch Text-Treffer kommen',
|
||||||
|
);
|
||||||
|
// Jede 10. Nachricht ist ein Bild: i = 9, 19, 29 → 3 pro Raum.
|
||||||
|
expect(media, hasLength(6));
|
||||||
|
expect(media.every((e) => e.content['msgtype'] == 'm.image'), isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('Trefferzahl ist auf searchTotalCap gedeckelt', () async {
|
||||||
|
// Jede Nachricht trägt das Suchwort → weit mehr Treffer als der Deckel.
|
||||||
|
await setUpClient(rooms: 2, eventsPerRoom: 80, needleEvery: 1);
|
||||||
|
final results = await localMessageSearch(client, 'nadelwort');
|
||||||
|
expect(results, hasLength(searchTotalCap));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kein Treffer → leere Liste, kein Fehler', () async {
|
||||||
|
await setUpClient(rooms: 2, eventsPerRoom: 20);
|
||||||
|
expect(await localMessageSearch(client, 'gibtsnicht'), isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user