Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
727f10462c |
@@ -50,6 +50,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
// Pagination: number of threads currently requested from the DB.
|
// Pagination: number of threads currently requested from the DB.
|
||||||
static const _pageSize = 50;
|
static const _pageSize = 50;
|
||||||
int _limit = _pageSize;
|
int _limit = _pageSize;
|
||||||
|
|
||||||
|
// Incremented on every search start; stale completions are ignored when the
|
||||||
|
// generation has advanced (prevents out-of-order IMAP responses from
|
||||||
|
// overwriting fresh results with results for an older query).
|
||||||
|
int _searchGeneration = 0;
|
||||||
bool get _selecting =>
|
bool get _selecting =>
|
||||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||||
|
|
||||||
@@ -121,14 +126,19 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
setState(() => _searchResults = null);
|
setState(() => _searchResults = null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final generation = ++_searchGeneration;
|
||||||
setState(() => _searchLoading = true);
|
setState(() => _searchLoading = true);
|
||||||
try {
|
try {
|
||||||
final results = await ref
|
final results = await ref
|
||||||
.read(emailRepositoryProvider)
|
.read(emailRepositoryProvider)
|
||||||
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
||||||
if (mounted) setState(() => _searchResults = results);
|
if (mounted && generation == _searchGeneration) {
|
||||||
|
setState(() => _searchResults = results);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _searchLoading = false);
|
if (mounted && generation == _searchGeneration) {
|
||||||
|
setState(() => _searchLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -371,6 +371,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_launcher_icons:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_launcher_icons
|
||||||
|
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.4"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -562,6 +570,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.8.0"
|
||||||
integration_test:
|
integration_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@@ -430,6 +432,128 @@ void main() {
|
|||||||
expect(find.text('Result email'), findsWidgets);
|
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('deleting all search results pops back to previous screen', (
|
testWidgets('deleting all search results pops back to previous screen', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -216,12 +216,17 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
|
|
||||||
final List<Email> _searchResults;
|
final List<Email> _searchResults;
|
||||||
|
|
||||||
|
/// Optional override: when set, [searchEmails] calls this instead of
|
||||||
|
/// returning [_searchResults]. Useful for testing race-condition fixes.
|
||||||
|
final Future<List<Email>> Function(String query)? onSearch;
|
||||||
|
|
||||||
FakeEmailRepository({
|
FakeEmailRepository({
|
||||||
List<Email>? emails,
|
List<Email>? emails,
|
||||||
Email? emailDetail,
|
Email? emailDetail,
|
||||||
EmailBody? emailBody,
|
EmailBody? emailBody,
|
||||||
List<Email>? searchResults,
|
List<Email>? searchResults,
|
||||||
String rawRfc822 = '',
|
String rawRfc822 = '',
|
||||||
|
this.onSearch,
|
||||||
}) : _emails = emails ?? [],
|
}) : _emails = emails ?? [],
|
||||||
_emailDetail = emailDetail,
|
_emailDetail = emailDetail,
|
||||||
_searchResults = searchResults ?? [],
|
_searchResults = searchResults ?? [],
|
||||||
@@ -274,7 +279,15 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
Future<Email?> getEmail(String emailId) async {
|
||||||
|
for (final e in _searchResults) {
|
||||||
|
if (e.id == emailId) return e;
|
||||||
|
}
|
||||||
|
for (final e in _emails) {
|
||||||
|
if (e.id == emailId) return e;
|
||||||
|
}
|
||||||
|
return _emailDetail;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
|
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
|
||||||
@@ -340,8 +353,10 @@ class FakeEmailRepository implements EmailRepository {
|
|||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath,
|
||||||
String query,
|
String query,
|
||||||
) async =>
|
) async {
|
||||||
_searchResults;
|
if (onSearch != null) return onSearch!(query);
|
||||||
|
return _searchResults;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Email>> searchEmailsGlobal(
|
Future<List<Email>> searchEmailsGlobal(
|
||||||
|
|||||||
Reference in New Issue
Block a user