feat(search): match word prefix, not arbitrary substring (#96)
Searching for "foo" now finds "foobar" (prefix of a word) but not "blafoo" (suffix). The FTS5 query already used the foo* prefix form; this commit extends the same semantics to folder-name and address matching in the search screen, replacing contains() with a word-boundary regex check. Tests added for all three paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
4a25d831fb
commit
9d19bdb81b
@@ -15,6 +15,11 @@ final _searchHistoryProvider =
|
||||
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||
});
|
||||
|
||||
/// Returns true if [text] contains a word that starts with [query].
|
||||
/// "foo" matches "foobar" or "My Foobar" but NOT "blafoo".
|
||||
bool _hasWordPrefix(String text, String query) =>
|
||||
RegExp(r'\b' + RegExp.escape(query), caseSensitive: false).hasMatch(text);
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
const SearchScreen({super.key, this.accountId});
|
||||
final String? accountId;
|
||||
@@ -79,7 +84,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
).wait;
|
||||
|
||||
final matchedMailboxes = allMailboxes
|
||||
.where((m) => m.name.toLowerCase().contains(ql))
|
||||
.where((m) => _hasWordPrefix(m.name, ql))
|
||||
.toList()
|
||||
..sort(compareMailboxes);
|
||||
|
||||
@@ -91,8 +96,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
for (final addr in [...email.from, ...email.to, ...email.cc]) {
|
||||
final key = '${email.accountId}:${addr.email}';
|
||||
if (seen.contains(key)) continue;
|
||||
final matchesEmail = addr.email.toLowerCase().contains(ql);
|
||||
final matchesName = addr.name?.toLowerCase().contains(ql) ?? false;
|
||||
final matchesEmail = _hasWordPrefix(addr.email, ql);
|
||||
final matchesName =
|
||||
addr.name != null && _hasWordPrefix(addr.name!, ql);
|
||||
if (!matchesEmail && !matchesName) continue;
|
||||
seen.add(key);
|
||||
final addrEmail = addr.email;
|
||||
|
||||
@@ -421,6 +421,38 @@ void main() {
|
||||
expect(results3, isEmpty);
|
||||
});
|
||||
|
||||
test('searchEmailsGlobal matches word prefix but not suffix', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
subject: const Value('foobar baz'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 2,
|
||||
subject: const Value('blafoo baz'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
|
||||
// 'foo' is a prefix of 'foobar' — should match; 'blafoo' is not a
|
||||
// prefix match so only one result expected.
|
||||
final results = await r.emails.searchEmailsGlobal(null, 'foo');
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.subject, 'foobar baz');
|
||||
});
|
||||
|
||||
test('searchAddresses returns results sorted by most recently used',
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
|
||||
@@ -160,6 +160,80 @@ void main() {
|
||||
expect(find.text('Archive'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('folder with query as word prefix is matched', (tester) async {
|
||||
const foobarMailbox = Mailbox(
|
||||
id: 'acc-1:Foobar',
|
||||
accountId: 'acc-1',
|
||||
path: 'Foobar',
|
||||
name: 'Foobar',
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/search',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository([foobarMailbox]),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
searchHistoryRepositoryProvider.overrideWithValue(
|
||||
FakeSearchHistoryRepository(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'foo');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Folders'), findsOneWidget);
|
||||
expect(find.text('Foobar'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('folder whose name ends with query is not matched', (
|
||||
tester,
|
||||
) async {
|
||||
const blafooMailbox = Mailbox(
|
||||
id: 'acc-1:Blafoo',
|
||||
accountId: 'acc-1',
|
||||
path: 'Blafoo',
|
||||
name: 'Blafoo',
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/search',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository([blafooMailbox]),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
searchHistoryRepositoryProvider.overrideWithValue(
|
||||
FakeSearchHistoryRepository(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'foo');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Blafoo'), findsNothing);
|
||||
expect(find.text('No results'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping clear button resets results to placeholder', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user