Implements issue #466 — a visual row-based filter editor (Field | Comparison | Value, AND/OR grouping) reused in the Search screen and the Sieve script editor. New files: - lib/core/filter/filter_expression.dart — FilterGroup/FilterLeaf tree model (FilterField, FilterComparison, FilterOperator) - lib/core/sieve/sieve_serializer.dart — serialises FilterGroup + SieveActions to a Sieve RFC 5228 script - lib/core/filter/filter_sieve_converter.dart — parses a Sieve script back into a FilterGroup tree (round-trip support) - lib/ui/widgets/filter_builder.dart — interactive FilterBuilderWidget with nested group support (depth ≤ 1) - test/unit/filter_and_sieve_test.dart — 25 unit tests covering FilterGroup, FilterLeaf, SieveSerializer, and FilterSieveConverter including round-trip coverage Modified files: - EmailRepository: adds searchEmailsStructured abstract method - EmailRepositoryImpl: implements searchEmailsStructured via Drift query builder (LIKE-based matching on JSON address fields and text columns) - SearchScreen: adds Advanced Search mode (tune icon toggle) using the FilterBuilderWidget - SieveScriptEditScreen: gains a Visual / Script tab pair; switching serialises or parses the script automatically; _ActionEditor covers keep / discard / mark-as-read / file-into actions - 5 test fake classes + 2 generated mock files: add stubs for the new searchEmailsStructured method - scripts/check_coverage.dart: adds filter_builder.dart to _excluded (UI widget, covered by widget tests path) - Fix: SieveSerializer now emits \\Seen (double-escaped) so the flag survives quoted-string parsing back to \Seen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
241 lines
7.4 KiB
Dart
241 lines
7.4 KiB
Dart
// Tests for ReliabilityRunner.checkNow() — the manual "Verify sync health"
|
|
// trigger. Specifically guards against regression of issue #95 where
|
|
// checkNow() silently did nothing because it delegated to _runAll(), which
|
|
// checked the _running flag (only true after start() is called).
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
|
import 'package:sharedinbox/core/models/account.dart';
|
|
import 'package:sharedinbox/core/models/email.dart';
|
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
|
import 'package:sharedinbox/core/sync/reliability_runner.dart';
|
|
import 'package:sharedinbox/data/db/database.dart'
|
|
hide Account, Email, EmailBody;
|
|
|
|
import 'db_test_helper.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Minimal fakes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const _kAccount = Account(
|
|
id: 'test-account',
|
|
displayName: 'Test',
|
|
email: 'test@example.com',
|
|
imapHost: 'localhost',
|
|
);
|
|
|
|
const _kMailbox = Mailbox(
|
|
id: 'test-account:INBOX',
|
|
accountId: 'test-account',
|
|
path: 'INBOX',
|
|
name: 'INBOX',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
);
|
|
|
|
class _FakeAccounts implements AccountRepository {
|
|
@override
|
|
Stream<List<Account>> observeAccounts() => Stream.value([_kAccount]);
|
|
@override
|
|
Future<Account?> getAccount(String id) async => _kAccount;
|
|
@override
|
|
Future<void> addAccount(Account account, String password) async {}
|
|
@override
|
|
Future<void> updateAccount(Account account, {String? password}) async {}
|
|
@override
|
|
Future<void> removeAccount(String id) async {}
|
|
@override
|
|
Future<String> getPassword(String id) async => 'secret';
|
|
}
|
|
|
|
class _FakeMailboxes implements MailboxRepository {
|
|
@override
|
|
Stream<List<Mailbox>> observeMailboxes(String? accountId) =>
|
|
Stream.value([_kMailbox]);
|
|
@override
|
|
Future<int> syncMailboxes(String accountId) async => 0;
|
|
@override
|
|
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
|
null;
|
|
@override
|
|
Future<void> clearForResync(String accountId) async {}
|
|
@override
|
|
Future<Mailbox> createMailboxWithRole(
|
|
String accountId,
|
|
String name,
|
|
String role,
|
|
) async =>
|
|
Mailbox(
|
|
id: '$accountId:$name',
|
|
accountId: accountId,
|
|
path: name,
|
|
name: name,
|
|
role: role,
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
);
|
|
@override
|
|
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
|
|
id: '$accountId:$name',
|
|
accountId: accountId,
|
|
path: name,
|
|
name: name,
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
);
|
|
}
|
|
|
|
class _FakeEmails implements EmailRepository {
|
|
int verifyCallCount = 0;
|
|
|
|
@override
|
|
Future<ReliabilityResult> verifySyncReliability(
|
|
String accountId,
|
|
String mailboxPath,
|
|
) async {
|
|
verifyCallCount++;
|
|
return ReliabilityResult.healthy;
|
|
}
|
|
|
|
// All remaining methods are unused by ReliabilityRunner.
|
|
@override
|
|
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
|
|
Stream.value([]);
|
|
@override
|
|
Stream<List<EmailThread>> observeThreads(
|
|
String a,
|
|
String m, {
|
|
int limit = 50,
|
|
}) =>
|
|
Stream.value([]);
|
|
@override
|
|
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
|
Stream.value([]);
|
|
@override
|
|
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
|
Stream.value([]);
|
|
@override
|
|
Future<Email?> getEmail(String id) async => null;
|
|
@override
|
|
Future<EmailBody> getEmailBody(String id) async =>
|
|
const EmailBody(emailId: '', attachments: []);
|
|
@override
|
|
Future<SyncEmailsResult> syncEmails(String a, String m) async =>
|
|
SyncEmailsResult.zero;
|
|
@override
|
|
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
|
@override
|
|
Future<void> markAllAsRead(String a, String m) async {}
|
|
@override
|
|
Future<void> moveEmail(String id, String dest) async {}
|
|
@override
|
|
Future<String?> deleteEmail(String id) async => null;
|
|
@override
|
|
Future<void> sendEmail(String a, EmailDraft d) async {}
|
|
@override
|
|
Future<String> downloadAttachment(String id, EmailAttachment att) async => '';
|
|
@override
|
|
Future<String> fetchRawRfc822(String id) async => '';
|
|
@override
|
|
Future<List<Email>> searchEmails(String a, String m, String q) async => [];
|
|
@override
|
|
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
|
@override
|
|
Future<List<Email>> searchEmailsStructured(
|
|
String? a,
|
|
FilterGroup f,
|
|
) async =>
|
|
[];
|
|
@override
|
|
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
|
@override
|
|
Future<List<EmailAddress>> searchAddresses(
|
|
String? a,
|
|
String q, {
|
|
int limit = 10,
|
|
}) async =>
|
|
[];
|
|
@override
|
|
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
|
|
Stream.value([]);
|
|
@override
|
|
Future<void> discardMutation(int id) async {}
|
|
@override
|
|
Future<void> retryMutation(int id) async {}
|
|
@override
|
|
Future<bool> cancelPendingChange(String id, String type) async => false;
|
|
@override
|
|
Future<void> snoozeEmail(String id, DateTime until) async {}
|
|
@override
|
|
Future<void> restoreEmails(List<Email> emails) async {}
|
|
@override
|
|
Future<Email?> findEmailByMessageId(String a, String messageId) async => null;
|
|
@override
|
|
Stream<String> get onChangesQueued => const Stream.empty();
|
|
@override
|
|
Stream<void> watchJmapPush(String a, String password) => const Stream.empty();
|
|
@override
|
|
Future<int> flushPendingChanges(String a, String password) async => 0;
|
|
@override
|
|
Future<int> wakeUpEmails(String accountId) async => 0;
|
|
@override
|
|
Future<void> clearForResync(String accountId) async {}
|
|
@override
|
|
Future<int> applySieveRules(String accountId) async => 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void main() {
|
|
configureSqliteForTests();
|
|
|
|
group('ReliabilityRunner.checkNow()', () {
|
|
late AppDatabase db;
|
|
late _FakeEmails emails;
|
|
late ReliabilityRunner runner;
|
|
|
|
setUp(() {
|
|
db = openTestDatabase();
|
|
emails = _FakeEmails();
|
|
runner = ReliabilityRunner(db, _FakeAccounts(), _FakeMailboxes(), emails);
|
|
});
|
|
|
|
tearDown(() => db.close());
|
|
|
|
test('writes sync-health row even when start() was never called', () async {
|
|
// Do NOT call runner.start() — this was the bug: checkNow() only ran
|
|
// when _running was true, which required start() to have been called.
|
|
await runner.checkNow();
|
|
|
|
final rows = await db.select(db.syncHealth).get();
|
|
expect(rows, hasLength(1), reason: 'checkNow() must write to the DB');
|
|
expect(rows.first.accountId, 'test-account');
|
|
expect(rows.first.isHealthy, isTrue);
|
|
});
|
|
|
|
test('calls verifySyncReliability for each mailbox', () async {
|
|
await runner.checkNow();
|
|
|
|
expect(
|
|
emails.verifyCallCount,
|
|
1,
|
|
reason: 'one mailbox → one verifySyncReliability call',
|
|
);
|
|
});
|
|
|
|
test('also works when start() was called beforehand', () async {
|
|
runner.start();
|
|
await runner.checkNow();
|
|
|
|
final rows = await db.select(db.syncHealth).get();
|
|
expect(rows, hasLength(1));
|
|
});
|
|
});
|
|
}
|