Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 43fb2a594e test(T2): add widget tests for ThreadDetailScreen and SearchScreen
Six tests for ThreadDetailScreen:
- empty thread shows placeholder message
- sender name rendered in card
- last email is expanded by default (reply/delete buttons visible)
- tapping expanded card collapses it
- flagged email shows star icon
- expanded card renders plain text body

Six tests for SearchScreen:
- placeholder shown when field is empty
- typing fewer than 3 characters keeps placeholder (no search fires)
- search returning empty shows "No results"
- email results shown under "Messages" section
- folder results shown under "Folders" section
- clear button resets results to placeholder

Also adds ThreadDetailScreen route to the shared test router in
helpers.dart so tests can navigate to it by URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 04:54:32 +02:00
Bot of Thomas Güttler dff2b5e2ca test(T1): add edge-case coverage for EmailRepositoryImpl (#30) 2026-05-14 04:43:11 +02:00
Bot of Thomas Güttler 7096c27ede feat(U6): show sync status indicator in email list app bar (#29) 2026-05-14 04:23:07 +02:00
7 changed files with 643 additions and 24 deletions
+40 -4
View File
@@ -44,6 +44,16 @@ class AccountSyncManager {
StreamSubscription<List<Account>>? _accountsSub; StreamSubscription<List<Account>>? _accountsSub;
StreamSubscription<String>? _onChangesSub; StreamSubscription<String>? _onChangesSub;
final _syncPhaseCtrl = StreamController<(String, bool)>.broadcast();
/// Emits `true` when [accountId] starts syncing, `false` when it stops.
Stream<bool> watchSyncing(String accountId) =>
_syncPhaseCtrl.stream.where((e) => e.$1 == accountId).map((e) => e.$2);
void _emitSyncing(String accountId, {required bool syncing}) {
if (!_syncPhaseCtrl.isClosed) _syncPhaseCtrl.add((accountId, syncing));
}
void start() { void start() {
_onChangesSub = _emails.onChangesQueued.listen((accountId) { _onChangesSub = _emails.onChangesQueued.listen((accountId) {
_active[accountId]?.kick(); _active[accountId]?.kick();
@@ -54,6 +64,7 @@ class AccountSyncManager {
for (final account in accounts) { for (final account in accounts) {
if (_active.containsKey(account.id)) continue; if (_active.containsKey(account.id)) continue;
final id = account.id;
final loop = switch (account.type) { final loop = switch (account.type) {
AccountType.imap => _AccountSync( AccountType.imap => _AccountSync(
account, account,
@@ -64,6 +75,8 @@ class AccountSyncManager {
_syncLog, _syncLog,
_drafts, _drafts,
_onNewMail, _onNewMail,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
@@ -71,6 +84,8 @@ class AccountSyncManager {
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(id, syncing: true),
onSyncEnd: () => _emitSyncing(id, syncing: false),
), ),
}; };
_active[account.id] = loop; _active[account.id] = loop;
@@ -92,6 +107,7 @@ class AccountSyncManager {
s.stop(); s.stop();
} }
_active.clear(); _active.clear();
unawaited(_syncPhaseCtrl.close());
} }
/// Wakes the idle/wait phase of the given account's sync loop so a new /// Wakes the idle/wait phase of the given account's sync loop so a new
@@ -126,6 +142,8 @@ class AccountSyncManager {
_syncLog, _syncLog,
_drafts, _drafts,
_onNewMail, _onNewMail,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
AccountType.jmap => _JmapAccountSync( AccountType.jmap => _JmapAccountSync(
account, account,
@@ -133,6 +151,8 @@ class AccountSyncManager {
_emails, _emails,
_accounts, _accounts,
_syncLog, _syncLog,
onSyncStart: () => _emitSyncing(accountId, syncing: true),
onSyncEnd: () => _emitSyncing(accountId, syncing: false),
), ),
}; };
_active[accountId] = loop; _active[accountId] = loop;
@@ -159,8 +179,11 @@ class _AccountSync implements _SyncLoop {
this._imapConnect, this._imapConnect,
this._syncLog, this._syncLog,
this._drafts, this._drafts,
this._onNewMail, this._onNewMail, {
); void Function()? onSyncStart,
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account; final Account account;
final AccountRepository _accounts; final AccountRepository _accounts;
@@ -170,6 +193,8 @@ class _AccountSync implements _SyncLoop {
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final DraftRepository? _drafts; final DraftRepository? _drafts;
final OnNewMailCallback? _onNewMail; final OnNewMailCallback? _onNewMail;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
imap.ImapClient? _idleClient; imap.ImapClient? _idleClient;
bool _running = false; bool _running = false;
@@ -202,6 +227,7 @@ class _AccountSync implements _SyncLoop {
Future<void> _loop() async { Future<void> _loop() async {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
_onSyncStart?.call();
try { try {
final (_SyncStats stats, String? capturedLog) = await _runSync( final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose, account.verbose,
@@ -221,8 +247,10 @@ class _AccountSync implements _SyncLoop {
protocolLog: capturedLog, protocolLog: capturedLog,
); );
_backoffSeconds = 5; _backoffSeconds = 5;
_onSyncEnd?.call();
await _idle(); await _idle();
} catch (e, st) { } catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e); final isPermanent = _isPermanentError(e);
try { try {
await _syncLog.log( await _syncLog.log(
@@ -392,14 +420,19 @@ class _JmapAccountSync implements _SyncLoop {
this._mailboxes, this._mailboxes,
this._emails, this._emails,
this._accounts, this._accounts,
this._syncLog, this._syncLog, {
); void Function()? onSyncStart,
void Function()? onSyncEnd,
}) : _onSyncStart = onSyncStart,
_onSyncEnd = onSyncEnd;
final Account account; final Account account;
final MailboxRepository _mailboxes; final MailboxRepository _mailboxes;
final EmailRepository _emails; final EmailRepository _emails;
final AccountRepository _accounts; final AccountRepository _accounts;
final SyncLogRepository _syncLog; final SyncLogRepository _syncLog;
final void Function()? _onSyncStart;
final void Function()? _onSyncEnd;
bool _running = false; bool _running = false;
int _backoffSeconds = 5; int _backoffSeconds = 5;
@@ -431,6 +464,7 @@ class _JmapAccountSync implements _SyncLoop {
Future<void> _loop() async { Future<void> _loop() async {
while (_running) { while (_running) {
final startedAt = DateTime.now(); final startedAt = DateTime.now();
_onSyncStart?.call();
try { try {
final (_SyncStats stats, String? capturedLog) = await _runSync( final (_SyncStats stats, String? capturedLog) = await _runSync(
account.verbose, account.verbose,
@@ -450,8 +484,10 @@ class _JmapAccountSync implements _SyncLoop {
protocolLog: capturedLog, protocolLog: capturedLog,
); );
_backoffSeconds = 5; _backoffSeconds = 5;
_onSyncEnd?.call();
await _wait(); await _wait();
} catch (e, st) { } catch (e, st) {
_onSyncEnd?.call();
final isPermanent = _isPermanentError(e); final isPermanent = _isPermanentError(e);
try { try {
await _syncLog.log( await _syncLog.log(
+5
View File
@@ -115,6 +115,11 @@ final syncHealthProvider =
.watchSingleOrNull(); .watchSingleOrNull();
}); });
final isSyncingProvider =
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
return ref.watch(syncManagerProvider).watchSyncing(accountId);
});
final syncManagerProvider = Provider<AccountSyncManager>((ref) { final syncManagerProvider = Provider<AccountSyncManager>((ref) {
final manager = AccountSyncManager( final manager = AccountSyncManager(
ref.watch(accountRepositoryProvider), ref.watch(accountRepositoryProvider),
+39 -16
View File
@@ -180,22 +180,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
), ),
), ),
), ),
IconButton( _buildSyncButton(emailRepo),
icon: const Icon(Icons.sync),
onPressed: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Sync failed: $e')));
}
},
),
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () => context.push( onPressed: () => context.push(
@@ -229,6 +214,44 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
: hasError
? 'Sync error'
: 'Sync',
icon: isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: hasError
? const Icon(Icons.sync_problem, color: Colors.red)
: const Icon(Icons.sync),
onPressed: isSyncing
? null
: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sync failed: $e')),
);
}
},
);
}
Widget _selectionBottomBar() { Widget _selectionBottomBar() {
return BottomAppBar( return BottomAppBar(
child: Row( child: Row(
+166 -4
View File
@@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:enough_mail/enough_mail.dart' as imap; import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -16,6 +16,7 @@ import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage; import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart'; import 'db_test_helper.dart';
import 'fake_imap.dart' show FakeImapClient;
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account( const _account = Account(
@@ -162,15 +163,19 @@ Future<imap.SmtpClient> _noSmtpConnect(Account a, String u, String p) =>
Future.error(UnsupportedError('SMTP unavailable in unit tests')); Future.error(UnsupportedError('SMTP unavailable in unit tests'));
({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails}) ({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails})
_makeRepos({http.Client? httpClient}) { _makeRepos({
http.Client? httpClient,
Future<imap.ImapClient> Function(Account, String, String)? imapConnect,
Future<imap.SmtpClient> Function(Account, String, String)? smtpConnect,
}) {
final db = openTestDatabase(); final db = openTestDatabase();
final storage = MapSecureStorage(); final storage = MapSecureStorage();
final accounts = AccountRepositoryImpl(db, storage); final accounts = AccountRepositoryImpl(db, storage);
final emails = EmailRepositoryImpl( final emails = EmailRepositoryImpl(
db, db,
accounts, accounts,
imapConnect: _noImapConnect, imapConnect: imapConnect ?? _noImapConnect,
smtpConnect: _noSmtpConnect, smtpConnect: smtpConnect ?? _noSmtpConnect,
httpClient: httpClient, httpClient: httpClient,
); );
return (db: db, accounts: accounts, emails: emails); return (db: db, accounts: accounts, emails: emails);
@@ -1935,6 +1940,163 @@ void main() {
expect(row.lastError, isNull); expect(row.lastError, isNull);
}); });
}); });
group('concurrent moves', () {
test(
'two simultaneous moves enqueue two changes and leave email in last destination',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:5',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 5,
receivedAt: DateTime(2024),
),
);
// Fire both moves without awaiting to exercise concurrent enqueue logic.
final f1 = r.emails.moveEmail('acc-1:5', 'Archive');
final f2 = r.emails.moveEmail('acc-1:5', 'Trash');
await Future.wait([f1, f2]);
final changes = await r.db.select(r.db.pendingChanges).get();
expect(changes, hasLength(2));
expect(changes.map((c) => c.changeType), everyElement('move'));
final destinations =
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
expect(destinations, containsAll(['Archive', 'Trash']));
final email = await r.emails.getEmail('acc-1:5');
expect(
email!.mailboxPath,
anyOf('Archive', 'Trash'),
reason:
'email must be optimistically moved to one of the two destinations',
);
});
});
group('IMAP SMTP auth failure', () {
test('sendEmail propagates SMTP authentication error', () async {
final r = _makeRepos(
smtpConnect: (Account _, String __, String ___) => Future.error(
Exception('535 5.7.8 Authentication credentials invalid'),
),
);
await r.accounts.addAccount(_account, 'pw');
const draft = EmailDraft(
from: EmailAddress(name: 'Alice', email: 'alice@example.com'),
to: [EmailAddress(name: 'Bob', email: 'bob@example.com')],
cc: [],
subject: 'Test',
body: 'Body',
);
await expectLater(
r.emails.sendEmail('acc-1', draft),
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('535'),
),
),
);
});
});
group('IMAP UID validity change', () {
test('full re-sync wipes stale emails when uidValidity changes', () async {
final r = _makeRepos(
imapConnect: (Account _, String __, String ___) async =>
_FakeImapClientUidValidity(456),
);
await r.accounts.addAccount(_account, 'pw');
// Pre-seed two emails from the old server epoch (uidValidity=123).
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
receivedAt: DateTime(2024),
),
);
// Seed an IMAP checkpoint with the old uidValidity so the code detects
// a mismatch and triggers a full re-sync.
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: 'acc-1',
resourceType: 'IMAP:INBOX',
state: '{"uidValidity":123,"lastUid":2,"highestModSeq":null}',
syncedAt: DateTime(2024),
),
);
await r.emails.syncEmails('acc-1', 'INBOX');
// Old emails must be wiped; the fake server returns zero messages.
final remaining = await r.db.select(r.db.emails).get();
expect(remaining, isEmpty);
// Checkpoint must be updated to the new uidValidity.
final stateRow = await (r.db.select(r.db.syncStates)
..where(
(t) =>
t.accountId.equals('acc-1') &
t.resourceType.equals('IMAP:INBOX'),
))
.getSingleOrNull();
expect(stateRow, isNotNull);
final state = jsonDecode(stateRow!.state) as Map<String, dynamic>;
expect(state['uidValidity'], 456);
});
});
}
// ── Additional fake IMAP client for UID-validity tests ───────────────────────
class _FakeImapClientUidValidity extends FakeImapClient {
_FakeImapClientUidValidity(this._uidValidity);
final int _uidValidity;
@override
Future<imap.Mailbox> selectMailboxByPath(
String path, {
bool enableCondStore = false,
imap.QResyncParameters? qresync,
}) async =>
imap.Mailbox(
encodedName: path,
encodedPath: path,
flags: [],
pathSeparator: '/',
uidValidity: _uidValidity,
);
@override
Future<imap.SearchImapResult> uidSearchMessages({
String searchCriteria = 'ALL',
List<imap.ReturnOption>? returnOptions,
Duration? responseTimeout,
}) async =>
imap.SearchImapResult();
} }
// ── SSE test helper ────────────────────────────────────────────────────────── // ── SSE test helper ──────────────────────────────────────────────────────────
+13
View File
@@ -30,6 +30,7 @@ import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart'; import 'package:sharedinbox/ui/screens/email_list_screen.dart';
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart'; import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
import 'package:sharedinbox/ui/screens/search_screen.dart'; import 'package:sharedinbox/ui/screens/search_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fake repositories // Fake repositories
@@ -381,6 +382,18 @@ Widget buildApp({
), ),
], ],
), ),
GoRoute(
path: ':mailboxPath/threads/:threadId',
builder: (ctx, state) => ThreadDetailScreen(
accountId: state.pathParameters['accountId']!,
mailboxPath: Uri.decodeComponent(
state.pathParameters['mailboxPath']!,
),
threadId: Uri.decodeComponent(
state.pathParameters['threadId']!,
),
),
),
], ],
), ),
], ],
+182
View File
@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
void main() {
group('SearchScreen', () {
testWidgets('shows placeholder hint text when empty', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Type 3+ characters to search'), findsOneWidget);
});
testWidgets('typing fewer than 3 characters does not trigger search', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'hi');
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('Type 3+ characters to search'), findsOneWidget);
expect(find.text('No results'), findsNothing);
});
testWidgets('shows "No results" when search returns nothing', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'xyz');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('No results'), findsOneWidget);
});
testWidgets('shows email results under "Messages" section', (
tester,
) async {
final email = testEmail(subject: 'Invoice Q3');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'inv');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Messages'), findsOneWidget);
expect(find.text('Invoice Q3'), findsOneWidget);
});
testWidgets('shows folder results under "Folders" section', (
tester,
) async {
const archiveMailbox = Mailbox(
id: 'acc-1:Archive',
accountId: 'acc-1',
path: 'Archive',
name: 'Archive',
unreadCount: 0,
totalCount: 5,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([archiveMailbox]),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'arc');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Folders'), findsOneWidget);
expect(find.text('Archive'), findsOneWidget);
});
testWidgets('tapping clear button resets results to placeholder', (
tester,
) async {
final email = testEmail(subject: 'Found email');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/search',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'found');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('Found email'), findsOneWidget);
await tester.tap(find.byIcon(Icons.clear));
await tester.pumpAndSettle();
expect(find.text('Found email'), findsNothing);
expect(find.text('Type 3+ characters to search'), findsOneWidget);
});
});
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
Email _threadEmail({
String id = 'acc-1:10',
bool isFlagged = false,
bool isSeen = true,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 10,
threadId: 'thread-1',
subject: 'Project update',
receivedAt: DateTime(2024, 6),
sentAt: DateTime(2024, 6, 1, 9),
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: false,
);
void main() {
group('ThreadDetailScreen', () {
testWidgets('shows "Thread not found or empty" when thread is empty', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Thread not found or empty'), findsOneWidget);
});
testWidgets('shows sender name for email in thread', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Bob'), findsOneWidget);
});
testWidgets('last email in thread is expanded by default', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: [email],
emailBody: const EmailBody(
emailId: 'acc-1:10',
textBody: 'Hello body text',
attachments: [],
),
),
),
],
),
);
await tester.pumpAndSettle();
// Reply and delete buttons are visible for the expanded card.
expect(find.byIcon(Icons.reply), findsOneWidget);
expect(find.byIcon(Icons.delete_outline), findsOneWidget);
});
testWidgets('tapping an expanded card collapses it', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: [email],
emailBody: const EmailBody(
emailId: 'acc-1:10',
textBody: 'Hello body text',
attachments: [],
),
),
),
],
),
);
await tester.pumpAndSettle();
// Tap the expand_less icon to collapse.
await tester.tap(find.byIcon(Icons.expand_less));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.reply), findsNothing);
expect(find.byIcon(Icons.expand_more), findsOneWidget);
});
testWidgets('flagged email shows star icon', (tester) async {
final email = _threadEmail(isFlagged: true);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.star), findsOneWidget);
});
testWidgets('expanded card shows plain text body', (tester) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: [email],
emailBody: const EmailBody(
emailId: 'acc-1:10',
textBody: 'Body content here',
attachments: [],
),
),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Body content here'), findsOneWidget);
});
});
}