Increase test coverage for `search.dart` (#9795)
diff --git a/.gitignore b/.gitignore
index fc6c160..ff6678e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,5 @@
# macOS generated Flutter files
**/macos/Flutter/ephemeral/
**/macos/Flutter/GeneratedPluginRegistrant.swift
+
+build/
diff --git a/packages/devtools_app/test/shared/ui/search_test.dart b/packages/devtools_app/test/shared/ui/search_test.dart
index 57c8599..e98e83e 100644
--- a/packages/devtools_app/test/shared/ui/search_test.dart
+++ b/packages/devtools_app/test/shared/ui/search_test.dart
@@ -4,10 +4,10 @@
import 'package:devtools_app/devtools_app.dart';
import 'package:devtools_app_shared/utils.dart';
+import 'package:fake_async/fake_async.dart';
+import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
-// TODO(https://github.com/flutter/devtools/issues/3514): increase test coverage
-
void main() {
late TestSearchController searchController;
@@ -43,6 +43,59 @@
}
});
+ test('nextMatch and previousMatch', () {
+ searchController.search = 'foo';
+ expect(searchController.matchIndex.value, equals(1));
+ expect(searchController.activeSearchMatch.value!.name, equals('Foo'));
+
+ searchController.nextMatch();
+ expect(searchController.matchIndex.value, equals(2));
+ expect(searchController.activeSearchMatch.value!.name, equals('FooBar'));
+
+ searchController.nextMatch();
+ expect(searchController.matchIndex.value, equals(3));
+ expect(searchController.activeSearchMatch.value!.name, equals('FooBaz'));
+
+ searchController.nextMatch();
+ expect(searchController.matchIndex.value, equals(1));
+ expect(searchController.activeSearchMatch.value!.name, equals('Foo'));
+
+ searchController.previousMatch();
+ expect(searchController.matchIndex.value, equals(3));
+ expect(searchController.activeSearchMatch.value!.name, equals('FooBaz'));
+
+ searchController.previousMatch();
+ expect(searchController.matchIndex.value, equals(2));
+ expect(searchController.activeSearchMatch.value!.name, equals('FooBar'));
+ });
+
+ test('resetSearch', () {
+ searchController.search = 'foo';
+ expect(searchController.search, equals('foo'));
+ expect(searchController.searchMatches.value.length, equals(3));
+
+ searchController.resetSearch();
+ expect(searchController.search, isEmpty);
+ expect(searchController.searchMatches.value, isEmpty);
+ });
+
+ test('searchPreviousMatches', () {
+ searchController.search = 'foo';
+ expect(searchController.searchMatches.value.length, equals(3));
+
+ // Add a new item that matches 'foob' but was not in the previous matches.
+ searchController.data.add(TestSearchData('FooBarBaz'));
+
+ // Since 'foob' contains 'foo', it will search previous matches.
+ // 'FooBarBaz' was not in the previous matches, so it should not be found.
+ searchController.search = 'foob';
+ expect(searchController.searchMatches.value.length, equals(2));
+ expect(
+ searchController.searchMatches.value.map((e) => e.name),
+ equals(['FooBar', 'FooBaz']),
+ );
+ });
+
test('updates values for empty query', () {
searchController.search = 'foo';
expect(searchController.search, equals('foo'));
@@ -67,6 +120,225 @@
expect(data.isSearchMatch, isFalse);
}
});
+
+ test('debounce', () {
+ fakeAsync((async) {
+ final debounceController = TestDebounceSearchController()
+ ..data.addAll(testData);
+ expect(debounceController.search, isEmpty);
+ expect(debounceController.searchMatches.value, isEmpty);
+
+ debounceController.search = 'foo';
+ expect(debounceController.search, equals('foo'));
+ expect(
+ debounceController.searchMatches.value,
+ isEmpty,
+ ); // Has not updated yet
+ expect(debounceController.isSearchInProgress, isTrue);
+
+ async.elapse(debounceController.debounceDelay! * 1.5);
+
+ expect(debounceController.isSearchInProgress, isFalse);
+ expect(debounceController.searchMatches.value.length, equals(3));
+ });
+ });
+ });
+
+ group('AutoCompleteMatch', () {
+ test('transformAutoCompleteMatch without matched segments', () {
+ final match = AutoCompleteMatch('test');
+ final result = match.transformAutoCompleteMatch<String>(
+ transformMatchedSegment: (s) => '[$s]',
+ transformUnmatchedSegment: (s) => '<$s>',
+ combineSegments: (segments) => segments.join(),
+ );
+ expect(result, equals('<test>'));
+ });
+
+ test('transformAutoCompleteMatch with matched segments', () {
+ final match = AutoCompleteMatch(
+ 'testSuggestion',
+ matchedSegments: [
+ const Range(0, 4), // 'test'
+ const Range(10, 14), // 'tion'
+ ],
+ );
+ final result = match.transformAutoCompleteMatch<String>(
+ transformMatchedSegment: (s) => '[$s]',
+ transformUnmatchedSegment: (s) => '<$s>',
+ combineSegments: (segments) => segments.join(),
+ );
+ expect(result, equals('[test]<Sugges>[tion]'));
+ });
+ });
+
+ group('AutoCompleteSearchControllerMixin', () {
+ late TestAutoCompleteSearchController autoCompleteController;
+
+ setUp(() {
+ autoCompleteController = TestAutoCompleteSearchController();
+ });
+
+ tearDown(() {
+ autoCompleteController.dispose();
+ });
+
+ test('clearSearchAutoComplete', () {
+ autoCompleteController.searchAutoComplete.value = [
+ AutoCompleteMatch('test'),
+ ];
+ autoCompleteController.setCurrentHoveredIndexValue(1);
+
+ autoCompleteController.clearSearchAutoComplete();
+
+ expect(autoCompleteController.searchAutoComplete.value, isEmpty);
+ expect(autoCompleteController.currentHoveredIndex.value, equals(0));
+ });
+
+ test('updateCurrentSuggestion / clearCurrentSuggestion', () {
+ autoCompleteController.searchAutoComplete.value = [
+ AutoCompleteMatch('testSuggestion'),
+ ];
+ autoCompleteController.setCurrentHoveredIndexValue(0);
+
+ autoCompleteController.updateCurrentSuggestion('test');
+ expect(
+ autoCompleteController.currentSuggestion.value,
+ equals('Suggestion'),
+ );
+
+ autoCompleteController.updateCurrentSuggestion('testSuggest');
+ expect(autoCompleteController.currentSuggestion.value, equals('ion'));
+
+ // Active word is longer than hovered text (should not happen in practice but handled)
+ autoCompleteController.updateCurrentSuggestion('testSuggestionWithMore');
+ expect(autoCompleteController.currentSuggestion.value, isNull);
+
+ autoCompleteController.clearCurrentSuggestion();
+ expect(autoCompleteController.currentSuggestion.value, isNull);
+ });
+
+ test('activeEditingParts', () {
+ final parts1 = AutoCompleteSearchControllerMixin.activeEditingParts(
+ 'addOne.yName + 1000 + myChart.tra',
+ const TextSelection.collapsed(offset: 33),
+ );
+ expect(parts1.activeWord, equals('tra'));
+ expect(parts1.leftSide, equals('addOne.yName + 1000 + myChart.'));
+ expect(parts1.rightSide, equals(''));
+ expect(parts1.isField, isTrue);
+
+ final parts2 = AutoCompleteSearchControllerMixin.activeEditingParts(
+ 'controller.cl + 1000 + myChart.tra',
+ const TextSelection.collapsed(offset: 13),
+ );
+ expect(parts2.activeWord, equals('cl'));
+ expect(parts2.leftSide, equals('controller.'));
+ expect(parts2.rightSide, equals(' + 1000 + myChart.tra'));
+ expect(parts2.isField, isTrue);
+
+ final parts3 = AutoCompleteSearchControllerMixin.activeEditingParts(
+ 'foo',
+ const TextSelection.collapsed(offset: 3),
+ );
+ expect(parts3.activeWord, equals('foo'));
+ expect(parts3.leftSide, equals(''));
+ expect(parts3.rightSide, equals(''));
+ expect(parts3.isField, isFalse);
+ });
+
+ test('clearSearchField', () {
+ autoCompleteController.search = 'foo';
+ autoCompleteController.clearSearchField();
+ expect(autoCompleteController.search, isEmpty);
+
+ autoCompleteController.clearSearchField(force: true);
+ expect(autoCompleteController.search, isEmpty);
+ });
+
+ test('updateSearchField', () {
+ autoCompleteController.updateSearchField(
+ newValue: 'foo bar',
+ caretPosition: 3,
+ );
+ expect(
+ autoCompleteController.searchTextFieldController.text,
+ equals('foo bar'),
+ );
+ expect(
+ autoCompleteController.searchTextFieldController.selection.baseOffset,
+ equals(3),
+ );
+ });
+ });
+
+ group('StatelessSearchField', () {
+ testWidgets('calls onChanged and onClose', (WidgetTester tester) async {
+ final searchController = TestSearchController()..init();
+ bool closeCalled = false;
+ String lastChangedValue = '';
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: StatelessSearchField(
+ controller: searchController,
+ searchFieldEnabled: true,
+ shouldRequestFocus: false,
+ onClose: () {
+ closeCalled = true;
+ },
+ onChanged: (value) {
+ lastChangedValue = value;
+ },
+ ),
+ ),
+ ),
+ );
+
+ final textField = find.byType(TextField);
+ expect(textField, findsOneWidget);
+
+ await tester.enterText(textField, 'test input');
+ await tester.pumpAndSettle();
+
+ expect(lastChangedValue, equals('test input'));
+
+ final closeButton = find.byIcon(Icons.close);
+ expect(closeButton, findsOneWidget);
+
+ await tester.tap(closeButton);
+ expect(closeCalled, isTrue);
+ });
+ });
+
+ group('SearchField', () {
+ testWidgets('calls onClose', (WidgetTester tester) async {
+ final searchController = TestSearchController()..init();
+ bool closeCalled = false;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: SearchField(
+ searchController: searchController,
+ onClose: () {
+ closeCalled = true;
+ },
+ ),
+ ),
+ ),
+ );
+
+ await tester.enterText(find.byType(TextField), 'foo');
+ await tester.pumpAndSettle();
+ // find the close button
+ final closeButton = find.byIcon(Icons.close);
+ expect(closeButton, findsOneWidget);
+
+ await tester.tap(closeButton);
+ expect(closeCalled, isTrue);
+ });
});
}
@@ -75,18 +347,40 @@
final data = <TestSearchData>[];
@override
- List<TestSearchData> matchesForSearch(
- String search, {
- bool searchPreviousMatches = false,
- }) {
- return data
- .where((element) => element.name.caseInsensitiveContains(search))
- .toList();
- }
+ Iterable<TestSearchData> get currentDataToSearchThrough => data;
+}
+
+class TestDebounceSearchController extends DisposableController
+ with SearchControllerMixin<TestSearchData> {
+ final data = <TestSearchData>[];
+
+ @override
+ Iterable<TestSearchData> get currentDataToSearchThrough => data;
+
+ @override
+ Duration? get debounceDelay => const Duration(milliseconds: 100);
}
class TestSearchData with SearchableDataMixin {
TestSearchData(this.name);
final String name;
+
+ @override
+ bool matchesSearchToken(RegExp regExpSearch) {
+ return name.caseInsensitiveContains(regExpSearch);
+ }
+}
+
+class TestAutoCompleteSearchController extends DisposableController
+ with SearchControllerMixin, AutoCompleteSearchControllerMixin {
+ TestAutoCompleteSearchController() {
+ init();
+ }
+
+ @override
+ final searchFieldKey = GlobalKey();
+
+ @override
+ Iterable<SearchableDataMixin> get currentDataToSearchThrough => [];
}