Skip to content

Commit 7d3d1ec

Browse files
Increase test coverage for search.dart (#9795)
1 parent 19a1dff commit 7d3d1ec

2 files changed

Lines changed: 306 additions & 10 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,5 @@ DEPENDENCIES.md
5252
# macOS generated Flutter files
5353
**/macos/Flutter/ephemeral/
5454
**/macos/Flutter/GeneratedPluginRegistrant.swift
55+
56+
build/

packages/devtools_app/test/shared/ui/search_test.dart

Lines changed: 304 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
import 'package:devtools_app/devtools_app.dart';
66
import 'package:devtools_app_shared/utils.dart';
7+
import 'package:fake_async/fake_async.dart';
8+
import 'package:flutter/material.dart';
79
import 'package:flutter_test/flutter_test.dart';
810

9-
// TODO(https://github.com/flutter/devtools/issues/3514): increase test coverage
10-
1111
void main() {
1212
late TestSearchController searchController;
1313

@@ -43,6 +43,59 @@ void main() {
4343
}
4444
});
4545

46+
test('nextMatch and previousMatch', () {
47+
searchController.search = 'foo';
48+
expect(searchController.matchIndex.value, equals(1));
49+
expect(searchController.activeSearchMatch.value!.name, equals('Foo'));
50+
51+
searchController.nextMatch();
52+
expect(searchController.matchIndex.value, equals(2));
53+
expect(searchController.activeSearchMatch.value!.name, equals('FooBar'));
54+
55+
searchController.nextMatch();
56+
expect(searchController.matchIndex.value, equals(3));
57+
expect(searchController.activeSearchMatch.value!.name, equals('FooBaz'));
58+
59+
searchController.nextMatch();
60+
expect(searchController.matchIndex.value, equals(1));
61+
expect(searchController.activeSearchMatch.value!.name, equals('Foo'));
62+
63+
searchController.previousMatch();
64+
expect(searchController.matchIndex.value, equals(3));
65+
expect(searchController.activeSearchMatch.value!.name, equals('FooBaz'));
66+
67+
searchController.previousMatch();
68+
expect(searchController.matchIndex.value, equals(2));
69+
expect(searchController.activeSearchMatch.value!.name, equals('FooBar'));
70+
});
71+
72+
test('resetSearch', () {
73+
searchController.search = 'foo';
74+
expect(searchController.search, equals('foo'));
75+
expect(searchController.searchMatches.value.length, equals(3));
76+
77+
searchController.resetSearch();
78+
expect(searchController.search, isEmpty);
79+
expect(searchController.searchMatches.value, isEmpty);
80+
});
81+
82+
test('searchPreviousMatches', () {
83+
searchController.search = 'foo';
84+
expect(searchController.searchMatches.value.length, equals(3));
85+
86+
// Add a new item that matches 'foob' but was not in the previous matches.
87+
searchController.data.add(TestSearchData('FooBarBaz'));
88+
89+
// Since 'foob' contains 'foo', it will search previous matches.
90+
// 'FooBarBaz' was not in the previous matches, so it should not be found.
91+
searchController.search = 'foob';
92+
expect(searchController.searchMatches.value.length, equals(2));
93+
expect(
94+
searchController.searchMatches.value.map((e) => e.name),
95+
equals(['FooBar', 'FooBaz']),
96+
);
97+
});
98+
4699
test('updates values for empty query', () {
47100
searchController.search = 'foo';
48101
expect(searchController.search, equals('foo'));
@@ -67,6 +120,225 @@ void main() {
67120
expect(data.isSearchMatch, isFalse);
68121
}
69122
});
123+
124+
test('debounce', () {
125+
fakeAsync((async) {
126+
final debounceController = TestDebounceSearchController()
127+
..data.addAll(testData);
128+
expect(debounceController.search, isEmpty);
129+
expect(debounceController.searchMatches.value, isEmpty);
130+
131+
debounceController.search = 'foo';
132+
expect(debounceController.search, equals('foo'));
133+
expect(
134+
debounceController.searchMatches.value,
135+
isEmpty,
136+
); // Has not updated yet
137+
expect(debounceController.isSearchInProgress, isTrue);
138+
139+
async.elapse(debounceController.debounceDelay! * 1.5);
140+
141+
expect(debounceController.isSearchInProgress, isFalse);
142+
expect(debounceController.searchMatches.value.length, equals(3));
143+
});
144+
});
145+
});
146+
147+
group('AutoCompleteMatch', () {
148+
test('transformAutoCompleteMatch without matched segments', () {
149+
final match = AutoCompleteMatch('test');
150+
final result = match.transformAutoCompleteMatch<String>(
151+
transformMatchedSegment: (s) => '[$s]',
152+
transformUnmatchedSegment: (s) => '<$s>',
153+
combineSegments: (segments) => segments.join(),
154+
);
155+
expect(result, equals('<test>'));
156+
});
157+
158+
test('transformAutoCompleteMatch with matched segments', () {
159+
final match = AutoCompleteMatch(
160+
'testSuggestion',
161+
matchedSegments: [
162+
const Range(0, 4), // 'test'
163+
const Range(10, 14), // 'tion'
164+
],
165+
);
166+
final result = match.transformAutoCompleteMatch<String>(
167+
transformMatchedSegment: (s) => '[$s]',
168+
transformUnmatchedSegment: (s) => '<$s>',
169+
combineSegments: (segments) => segments.join(),
170+
);
171+
expect(result, equals('[test]<Sugges>[tion]'));
172+
});
173+
});
174+
175+
group('AutoCompleteSearchControllerMixin', () {
176+
late TestAutoCompleteSearchController autoCompleteController;
177+
178+
setUp(() {
179+
autoCompleteController = TestAutoCompleteSearchController();
180+
});
181+
182+
tearDown(() {
183+
autoCompleteController.dispose();
184+
});
185+
186+
test('clearSearchAutoComplete', () {
187+
autoCompleteController.searchAutoComplete.value = [
188+
AutoCompleteMatch('test'),
189+
];
190+
autoCompleteController.setCurrentHoveredIndexValue(1);
191+
192+
autoCompleteController.clearSearchAutoComplete();
193+
194+
expect(autoCompleteController.searchAutoComplete.value, isEmpty);
195+
expect(autoCompleteController.currentHoveredIndex.value, equals(0));
196+
});
197+
198+
test('updateCurrentSuggestion / clearCurrentSuggestion', () {
199+
autoCompleteController.searchAutoComplete.value = [
200+
AutoCompleteMatch('testSuggestion'),
201+
];
202+
autoCompleteController.setCurrentHoveredIndexValue(0);
203+
204+
autoCompleteController.updateCurrentSuggestion('test');
205+
expect(
206+
autoCompleteController.currentSuggestion.value,
207+
equals('Suggestion'),
208+
);
209+
210+
autoCompleteController.updateCurrentSuggestion('testSuggest');
211+
expect(autoCompleteController.currentSuggestion.value, equals('ion'));
212+
213+
// Active word is longer than hovered text (should not happen in practice but handled)
214+
autoCompleteController.updateCurrentSuggestion('testSuggestionWithMore');
215+
expect(autoCompleteController.currentSuggestion.value, isNull);
216+
217+
autoCompleteController.clearCurrentSuggestion();
218+
expect(autoCompleteController.currentSuggestion.value, isNull);
219+
});
220+
221+
test('activeEditingParts', () {
222+
final parts1 = AutoCompleteSearchControllerMixin.activeEditingParts(
223+
'addOne.yName + 1000 + myChart.tra',
224+
const TextSelection.collapsed(offset: 33),
225+
);
226+
expect(parts1.activeWord, equals('tra'));
227+
expect(parts1.leftSide, equals('addOne.yName + 1000 + myChart.'));
228+
expect(parts1.rightSide, equals(''));
229+
expect(parts1.isField, isTrue);
230+
231+
final parts2 = AutoCompleteSearchControllerMixin.activeEditingParts(
232+
'controller.cl + 1000 + myChart.tra',
233+
const TextSelection.collapsed(offset: 13),
234+
);
235+
expect(parts2.activeWord, equals('cl'));
236+
expect(parts2.leftSide, equals('controller.'));
237+
expect(parts2.rightSide, equals(' + 1000 + myChart.tra'));
238+
expect(parts2.isField, isTrue);
239+
240+
final parts3 = AutoCompleteSearchControllerMixin.activeEditingParts(
241+
'foo',
242+
const TextSelection.collapsed(offset: 3),
243+
);
244+
expect(parts3.activeWord, equals('foo'));
245+
expect(parts3.leftSide, equals(''));
246+
expect(parts3.rightSide, equals(''));
247+
expect(parts3.isField, isFalse);
248+
});
249+
250+
test('clearSearchField', () {
251+
autoCompleteController.search = 'foo';
252+
autoCompleteController.clearSearchField();
253+
expect(autoCompleteController.search, isEmpty);
254+
255+
autoCompleteController.clearSearchField(force: true);
256+
expect(autoCompleteController.search, isEmpty);
257+
});
258+
259+
test('updateSearchField', () {
260+
autoCompleteController.updateSearchField(
261+
newValue: 'foo bar',
262+
caretPosition: 3,
263+
);
264+
expect(
265+
autoCompleteController.searchTextFieldController.text,
266+
equals('foo bar'),
267+
);
268+
expect(
269+
autoCompleteController.searchTextFieldController.selection.baseOffset,
270+
equals(3),
271+
);
272+
});
273+
});
274+
275+
group('StatelessSearchField', () {
276+
testWidgets('calls onChanged and onClose', (WidgetTester tester) async {
277+
final searchController = TestSearchController()..init();
278+
bool closeCalled = false;
279+
String lastChangedValue = '';
280+
281+
await tester.pumpWidget(
282+
MaterialApp(
283+
home: Scaffold(
284+
body: StatelessSearchField(
285+
controller: searchController,
286+
searchFieldEnabled: true,
287+
shouldRequestFocus: false,
288+
onClose: () {
289+
closeCalled = true;
290+
},
291+
onChanged: (value) {
292+
lastChangedValue = value;
293+
},
294+
),
295+
),
296+
),
297+
);
298+
299+
final textField = find.byType(TextField);
300+
expect(textField, findsOneWidget);
301+
302+
await tester.enterText(textField, 'test input');
303+
await tester.pumpAndSettle();
304+
305+
expect(lastChangedValue, equals('test input'));
306+
307+
final closeButton = find.byIcon(Icons.close);
308+
expect(closeButton, findsOneWidget);
309+
310+
await tester.tap(closeButton);
311+
expect(closeCalled, isTrue);
312+
});
313+
});
314+
315+
group('SearchField', () {
316+
testWidgets('calls onClose', (WidgetTester tester) async {
317+
final searchController = TestSearchController()..init();
318+
bool closeCalled = false;
319+
320+
await tester.pumpWidget(
321+
MaterialApp(
322+
home: Scaffold(
323+
body: SearchField(
324+
searchController: searchController,
325+
onClose: () {
326+
closeCalled = true;
327+
},
328+
),
329+
),
330+
),
331+
);
332+
333+
await tester.enterText(find.byType(TextField), 'foo');
334+
await tester.pumpAndSettle();
335+
// find the close button
336+
final closeButton = find.byIcon(Icons.close);
337+
expect(closeButton, findsOneWidget);
338+
339+
await tester.tap(closeButton);
340+
expect(closeCalled, isTrue);
341+
});
70342
});
71343
}
72344

@@ -75,18 +347,40 @@ class TestSearchController extends DisposableController
75347
final data = <TestSearchData>[];
76348

77349
@override
78-
List<TestSearchData> matchesForSearch(
79-
String search, {
80-
bool searchPreviousMatches = false,
81-
}) {
82-
return data
83-
.where((element) => element.name.caseInsensitiveContains(search))
84-
.toList();
85-
}
350+
Iterable<TestSearchData> get currentDataToSearchThrough => data;
351+
}
352+
353+
class TestDebounceSearchController extends DisposableController
354+
with SearchControllerMixin<TestSearchData> {
355+
final data = <TestSearchData>[];
356+
357+
@override
358+
Iterable<TestSearchData> get currentDataToSearchThrough => data;
359+
360+
@override
361+
Duration? get debounceDelay => const Duration(milliseconds: 100);
86362
}
87363

88364
class TestSearchData with SearchableDataMixin {
89365
TestSearchData(this.name);
90366

91367
final String name;
368+
369+
@override
370+
bool matchesSearchToken(RegExp regExpSearch) {
371+
return name.caseInsensitiveContains(regExpSearch);
372+
}
373+
}
374+
375+
class TestAutoCompleteSearchController extends DisposableController
376+
with SearchControllerMixin, AutoCompleteSearchControllerMixin {
377+
TestAutoCompleteSearchController() {
378+
init();
379+
}
380+
381+
@override
382+
final searchFieldKey = GlobalKey();
383+
384+
@override
385+
Iterable<SearchableDataMixin> get currentDataToSearchThrough => [];
92386
}

0 commit comments

Comments
 (0)