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
+109
View File
@@ -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);
});
}