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,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