1046 lines
36 KiB
Dart
1046 lines
36 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:sharedinbox/core/models/email.dart';
|
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
|
import 'package:sharedinbox/di.dart';
|
|
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
|
|
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
|
|
|
|
import 'helpers.dart';
|
|
|
|
// A fake email repository whose search results can be changed mid-test.
|
|
class _MutableFakeEmailRepository extends FakeEmailRepository {
|
|
List<Email> _results;
|
|
|
|
_MutableFakeEmailRepository(
|
|
List<Email> initial, {
|
|
super.emailDetail,
|
|
super.emailBody,
|
|
}) : _results = List.of(initial);
|
|
|
|
void setSearchResults(List<Email> results) => _results = results;
|
|
|
|
@override
|
|
Future<List<Email>> searchEmails(
|
|
String accountId,
|
|
String mailboxPath,
|
|
String query,
|
|
) async =>
|
|
_results;
|
|
}
|
|
|
|
final _kDate = DateTime(2024, 6);
|
|
|
|
void main() {
|
|
group('EmailListScreen', () {
|
|
testWidgets('shows "No emails" when list is empty', (tester) async {
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('No emails'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('shows email sender and subject', (tester) async {
|
|
final email = testEmail(subject: 'Meeting agenda');
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(emails: [email]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Bob'), findsOneWidget);
|
|
expect(find.text('Meeting agenda'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('shows flag icon for flagged email', (tester) async {
|
|
final email = testEmail(isFlagged: true);
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(emails: [email]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byIcon(Icons.star), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('submitting a search query shows "No results" when empty', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byIcon(Icons.search));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.enterText(find.byType(TextField), 'hello');
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('No results'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('submitting a search query shows matching emails', (
|
|
tester,
|
|
) async {
|
|
final email = testEmail(subject: 'Found it');
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(searchResults: [email]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byIcon(Icons.search));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.enterText(find.byType(TextField), 'Found');
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Found it'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('tapping sync button triggers syncEmails', (tester) async {
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byIcon(Icons.sync));
|
|
await tester.pumpAndSettle();
|
|
|
|
// No assertion needed — we just verify the tap doesn't throw.
|
|
});
|
|
|
|
testWidgets('tapping edit button navigates to compose screen', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byIcon(Icons.edit));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('To'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('SearchBar is always visible in the AppBar', (tester) async {
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(SearchBar), findsOneWidget);
|
|
expect(find.text('Search…'), findsOneWidget);
|
|
expect(find.text('INBOX'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('long-press enters selection mode with selection bar', (
|
|
tester,
|
|
) async {
|
|
final email = testEmail(subject: 'Select me');
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(emails: [email]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.longPress(find.text('Select me'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('1 selected'), findsOneWidget);
|
|
expect(find.byType(BottomAppBar), findsOneWidget);
|
|
expect(find.byIcon(Icons.close), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('selection bar close button exits selection mode', (
|
|
tester,
|
|
) async {
|
|
final email = testEmail(subject: 'Select me');
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(emails: [email]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.longPress(find.text('Select me'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byIcon(Icons.close));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('INBOX'), findsOneWidget);
|
|
expect(find.byIcon(Icons.close), findsNothing);
|
|
});
|
|
|
|
testWidgets('tapping clear icon in search bar clears results', (
|
|
tester,
|
|
) async {
|
|
final email = testEmail(subject: 'Found it');
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(emails: [], searchResults: [email]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byIcon(Icons.search));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.enterText(find.byType(TextField), 'hello');
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Found it'), findsOneWidget);
|
|
|
|
await tester.tap(find.byIcon(Icons.clear));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Found it'), findsNothing);
|
|
});
|
|
|
|
testWidgets('tapping selected-email checkbox deselects it', (tester) async {
|
|
final email = testEmail(subject: 'Toggle me');
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(emails: [email]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.longPress(find.text('Toggle me'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('1 selected'), findsOneWidget);
|
|
|
|
await tester.tap(find.byType(Checkbox));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Deselecting the only email exits selection mode automatically.
|
|
expect(find.text('INBOX'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('tapping a search result navigates to email detail', (
|
|
tester,
|
|
) async {
|
|
final email = testEmail(subject: 'Result email');
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(
|
|
searchResults: [email],
|
|
emailDetail: email,
|
|
emailBody: const EmailBody(
|
|
emailId: 'acc-1:42',
|
|
attachments: [],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byIcon(Icons.search));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.enterText(find.byType(TextField), 'Result');
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('Result email'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Navigated to email detail (subject appears in the detail body)
|
|
expect(find.text('Result email'), findsWidgets);
|
|
});
|
|
|
|
testWidgets(
|
|
'tapping first of multiple search results opens the first email',
|
|
(tester) async {
|
|
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
|
|
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(
|
|
searchResults: [email1, email2],
|
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.enterText(find.byType(TextField), 'Match');
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Alpha Match'), findsOneWidget);
|
|
expect(find.text('Beta Match'), findsOneWidget);
|
|
|
|
// Tap the first result.
|
|
await tester.tap(find.text('Alpha Match'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(EmailDetailScreen), findsOneWidget);
|
|
// The detail AppBar title shows the first email's subject.
|
|
expect(
|
|
find.descendant(
|
|
of: find.byType(AppBar),
|
|
matching: find.text('Alpha Match'),
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
// The second email's subject must not appear in the detail view.
|
|
expect(
|
|
find.descendant(
|
|
of: find.byType(EmailDetailScreen),
|
|
matching: find.text('Beta Match'),
|
|
),
|
|
findsNothing,
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'stale search results from a slower concurrent search are discarded',
|
|
(tester) async {
|
|
// Reproduces: user types quickly, triggering multiple concurrent IMAP
|
|
// searches. An older, slower search must not overwrite the results for
|
|
// the user's current query (issue #467).
|
|
final staleEmail = testEmail(id: 'acc-1:1', subject: 'Stale Result');
|
|
final freshEmail = testEmail(id: 'acc-1:2', subject: 'Fresh Result');
|
|
|
|
// The first search call is held open by a Completer; all subsequent
|
|
// calls resolve immediately with freshEmail.
|
|
final staleCompleter = Completer<List<Email>>();
|
|
var firstCall = true;
|
|
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(
|
|
onSearch: (_) {
|
|
if (firstCall) {
|
|
firstCall = false;
|
|
return staleCompleter.future;
|
|
}
|
|
return Future.value([freshEmail]);
|
|
},
|
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Trigger the first (slow) search.
|
|
await tester.enterText(find.byType(TextField), 'slow');
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
// Do not pumpAndSettle yet — the slow search is still in flight.
|
|
|
|
// Trigger the second (fast) search by changing the query.
|
|
await tester.enterText(find.byType(TextField), 'fast');
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle(); // fast searches settle immediately
|
|
|
|
// The fresh results must be shown.
|
|
expect(find.text('Fresh Result'), findsOneWidget);
|
|
expect(find.text('Stale Result'), findsNothing);
|
|
|
|
// Now let the stale search complete.
|
|
staleCompleter.complete([staleEmail]);
|
|
await tester.pumpAndSettle();
|
|
|
|
// The stale results must NOT replace the fresh ones.
|
|
expect(find.text('Fresh Result'), findsOneWidget);
|
|
expect(find.text('Stale Result'), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'pressing Enter on already-settled search does not re-run search (issue #473)',
|
|
(tester) async {
|
|
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
|
|
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
|
|
|
|
var searchCallCount = 0;
|
|
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(
|
|
onSearch: (_) async {
|
|
searchCallCount++;
|
|
return [email1, email2];
|
|
},
|
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Run the initial search.
|
|
await tester.enterText(find.byType(TextField), 'Match');
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Alpha Match'), findsOneWidget);
|
|
expect(find.text('Beta Match'), findsOneWidget);
|
|
|
|
final countAfterFirstSearch = searchCallCount;
|
|
|
|
// Re-focus the search bar (simulates user tapping back into the field
|
|
// with the keyboard still visible) and press Enter again on the same,
|
|
// already-settled query.
|
|
await tester.tap(find.byType(TextField));
|
|
await tester.pump();
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle();
|
|
|
|
// The search must NOT re-run; call count must not increase.
|
|
expect(
|
|
searchCallCount,
|
|
countAfterFirstSearch,
|
|
reason:
|
|
'Enter on settled results must not re-run the search (issue #473)',
|
|
);
|
|
// Results must still be visible — no loading spinner.
|
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
|
expect(find.text('Alpha Match'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'folder search returns results from local cache without any network call',
|
|
(tester) async {
|
|
// Verifies that searchEmails is backed by local SQLite (not IMAP).
|
|
// The repository throws if a network call is attempted, yet search
|
|
// must still return results.
|
|
final email = testEmail(subject: 'Cached subject');
|
|
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(
|
|
onSearch: (_) async {
|
|
// Local DB: return cached results immediately.
|
|
return [email];
|
|
},
|
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.enterText(find.byType(TextField), 'Cached');
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Cached subject'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets('deleting all search results pops back to previous screen', (
|
|
tester,
|
|
) async {
|
|
final email = testEmail(subject: 'Needle');
|
|
|
|
// Start at the mailbox list so the email list is pushed on top of it,
|
|
// making context.canPop() == true inside EmailListScreen.
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository([kTestMailbox]),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(searchResults: [email]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(MailboxListScreen), findsOneWidget);
|
|
|
|
// Navigate into INBOX (pushes EmailListScreen onto the stack).
|
|
await tester.tap(find.text('INBOX'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(EmailListScreen), findsOneWidget);
|
|
|
|
// Search for the email.
|
|
await tester.enterText(find.byType(TextField), 'Needle');
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle();
|
|
|
|
// 'Needle' also appears in the SearchBar input, so match at least one.
|
|
expect(find.text('Needle'), findsAtLeastNWidgets(1));
|
|
|
|
// Long-press the sender name (unique to the email tile) to enter
|
|
// selection mode.
|
|
await tester.longPress(find.text('Bob'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byIcon(Icons.select_all));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byIcon(Icons.delete));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Should have popped back to the mailbox list.
|
|
expect(find.byType(EmailListScreen), findsNothing);
|
|
expect(find.byType(MailboxListScreen), findsOneWidget);
|
|
});
|
|
|
|
testWidgets(
|
|
'deleting some search results updates the list without popping',
|
|
(tester) async {
|
|
final email1 = testEmail(subject: 'Needle One');
|
|
final email2 = testEmail(subject: 'Needle Two', id: 'acc-1:2');
|
|
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository([kTestMailbox]),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(searchResults: [email1, email2]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('INBOX'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Search returns both emails.
|
|
await tester.enterText(find.byType(TextField), 'Needle');
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Needle One'), findsOneWidget);
|
|
expect(find.text('Needle Two'), findsOneWidget);
|
|
|
|
// Select only the first email and delete it.
|
|
await tester.longPress(find.text('Needle One'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.byIcon(Icons.delete));
|
|
await tester.pumpAndSettle();
|
|
|
|
// EmailListScreen stays open but the deleted email is gone.
|
|
expect(find.byType(EmailListScreen), findsOneWidget);
|
|
expect(find.text('Needle One'), findsNothing);
|
|
expect(find.text('Needle Two'), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'opening and deleting a single search result pops back to previous screen',
|
|
(tester) async {
|
|
final email = testEmail(subject: 'Needle');
|
|
final repo = _MutableFakeEmailRepository(
|
|
[email],
|
|
emailDetail: email,
|
|
emailBody: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
|
);
|
|
|
|
// Start at the mailbox list so context.canPop() is true in EmailListScreen.
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository([kTestMailbox]),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(repo),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Navigate into INBOX.
|
|
await tester.tap(find.text('INBOX'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(EmailListScreen), findsOneWidget);
|
|
|
|
// Search for the email.
|
|
await tester.enterText(find.byType(TextField), 'Needle');
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Tap the email to open it in EmailDetailScreen (not long-press / selection).
|
|
await tester.tap(find.text('Bob'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(EmailDetailScreen), findsOneWidget);
|
|
|
|
// The search will return empty results after the email is deleted.
|
|
repo.setSearchResults([]);
|
|
|
|
// Delete the email from the detail screen.
|
|
await tester.tap(find.byIcon(Icons.delete));
|
|
await tester.pumpAndSettle();
|
|
await tester.pump();
|
|
await tester.pumpAndSettle();
|
|
|
|
// Should have popped all the way back to the mailbox list.
|
|
expect(find.byType(EmailDetailScreen), findsNothing);
|
|
expect(find.byType(EmailListScreen), findsNothing);
|
|
expect(find.byType(MailboxListScreen), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'pressing Enter after search settles does not reorder results',
|
|
(tester) async {
|
|
// Reproduces: user types a query → onChanged fires → results settle.
|
|
// Then user presses Enter → onSubmitted fires a second search → the
|
|
// second IMAP response may return results in a different order, so the
|
|
// tile the user is about to tap is no longer the email they expect.
|
|
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Foo');
|
|
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Foo');
|
|
var callCount = 0;
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(
|
|
onSearch: (_) async {
|
|
callCount++;
|
|
// First call: [Alpha, Beta]. Second call: reversed.
|
|
return callCount == 1 ? [email1, email2] : [email2, email1];
|
|
},
|
|
emailBody: const EmailBody(emailId: '', attachments: []),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Typing triggers onChanged → first search → results settle.
|
|
await tester.enterText(find.byType(TextField), 'foo');
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Alpha Foo'), findsOneWidget);
|
|
expect(find.text('Beta Foo'), findsOneWidget);
|
|
// Alpha must appear above Beta (it is first in the list).
|
|
expect(
|
|
tester.getTopLeft(find.text('Alpha Foo')).dy,
|
|
lessThan(tester.getTopLeft(find.text('Beta Foo')).dy),
|
|
);
|
|
|
|
// Pressing Enter triggers onSubmitted — must NOT re-run the search.
|
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Order must be unchanged: pressing Enter must not reorder results.
|
|
expect(find.text('Alpha Foo'), findsOneWidget);
|
|
expect(find.text('Beta Foo'), findsOneWidget);
|
|
expect(
|
|
tester.getTopLeft(find.text('Alpha Foo')).dy,
|
|
lessThan(tester.getTopLeft(find.text('Beta Foo')).dy),
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets('shows preview snippet when email has preview', (tester) async {
|
|
final email = Email(
|
|
id: 'acc-1:99',
|
|
accountId: 'acc-1',
|
|
mailboxPath: 'INBOX',
|
|
uid: 99,
|
|
subject: 'Hello',
|
|
receivedAt: _kDate,
|
|
sentAt: _kDate,
|
|
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
|
to: const [EmailAddress(email: 'alice@example.com')],
|
|
cc: [],
|
|
preview: 'This is the preview text',
|
|
isSeen: false,
|
|
isFlagged: false,
|
|
hasAttachment: false,
|
|
);
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(emails: [email]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('This is the preview text'), findsOneWidget);
|
|
});
|
|
|
|
group('archive with missing folder', () {
|
|
testWidgets('shows dialog when archive folder is not found', (
|
|
tester,
|
|
) async {
|
|
final email = testEmail(subject: 'To archive');
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
// No archive folder in the repo.
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(
|
|
FakeEmailRepository(emails: [email]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Enter selection mode and tap archive.
|
|
await tester.longPress(find.text('To archive'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byIcon(Icons.archive));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('No archive folder found'), findsOneWidget);
|
|
expect(find.text('Choose existing folder'), findsOneWidget);
|
|
expect(find.text('Create "Archive"'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('tapping Create creates the folder and moves emails', (
|
|
tester,
|
|
) async {
|
|
final email = testEmail(subject: 'To archive');
|
|
final movedTo = <String>[];
|
|
|
|
final fakeEmailRepo = _SpyEmailRepository(
|
|
emails: [email],
|
|
onMove: (id, path) => movedTo.add(path),
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository(),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.longPress(find.text('To archive'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byIcon(Icons.archive));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Tap "Create Archive".
|
|
await tester.tap(find.text('Create "Archive"'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(movedTo, contains('Archive'));
|
|
});
|
|
|
|
testWidgets(
|
|
'tapping Choose existing opens folder picker and moves emails',
|
|
(tester) async {
|
|
final email = testEmail(subject: 'To archive');
|
|
final movedTo = <String>[];
|
|
|
|
final fakeEmailRepo = _SpyEmailRepository(
|
|
emails: [email],
|
|
onMove: (id, path) => movedTo.add(path),
|
|
);
|
|
const archiveFolder = Mailbox(
|
|
id: 'acc-1:OldArchive',
|
|
accountId: 'acc-1',
|
|
path: 'OldArchive',
|
|
name: 'OldArchive',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
buildApp(
|
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
|
overrides: [
|
|
accountRepositoryProvider.overrideWithValue(
|
|
FakeAccountRepository([kTestAccount]),
|
|
),
|
|
// Repo has a folder but it has no 'archive' role.
|
|
mailboxRepositoryProvider.overrideWithValue(
|
|
FakeMailboxRepository([archiveFolder]),
|
|
),
|
|
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.longPress(find.text('To archive'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.byIcon(Icons.archive));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Tap "Choose existing folder".
|
|
await tester.tap(find.text('Choose existing folder'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Bottom sheet with folder list appears.
|
|
expect(find.text('OldArchive'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('OldArchive'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(movedTo, contains('OldArchive'));
|
|
},
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Email repository spy that records [moveEmail] calls.
|
|
class _SpyEmailRepository extends FakeEmailRepository {
|
|
_SpyEmailRepository({
|
|
super.emails,
|
|
required void Function(String emailId, String path) onMove,
|
|
}) : _onMove = onMove;
|
|
|
|
final void Function(String emailId, String path) _onMove;
|
|
|
|
@override
|
|
Future<void> moveEmail(String emailId, String destMailboxPath) async {
|
|
_onMove(emailId, destMailboxPath);
|
|
}
|
|
}
|