From 75dfdef7efc7b6438947e9b9391334b7fb758e58 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Tue, 21 Apr 2026 13:38:11 -0700 Subject: [PATCH 1/7] Increase test coverage for `search.dart`. --- .../test/shared/ui/search_test.dart | 311 +++++++++++++++++- 1 file changed, 301 insertions(+), 10 deletions(-) diff --git a/packages/devtools_app/test/shared/ui/search_test.dart b/packages/devtools_app/test/shared/ui/search_test.dart index 57c85993829..50bf38ddacd 100644 --- a/packages/devtools_app/test/shared/ui/search_test.dart +++ b/packages/devtools_app/test/shared/ui/search_test.dart @@ -4,10 +4,9 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app_shared/utils.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 +42,59 @@ void main() { } }); + 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 +119,223 @@ void main() { expect(data.isSearchMatch, isFalse); } }); + + test('debounce', () 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); + + await Future.delayed(const Duration(milliseconds: 150)); + + 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( + transformMatchedSegment: (s) => '[$s]', + transformUnmatchedSegment: (s) => '<$s>', + combineSegments: (segments) => segments.join(), + ); + expect(result, equals('')); + }); + + test('transformAutoCompleteMatch with matched segments', () { + final match = AutoCompleteMatch( + 'testSuggestion', + matchedSegments: [ + const Range(0, 4), // 'test' + const Range(10, 14), // 'tion' + ], + ); + final result = match.transformAutoCompleteMatch( + transformMatchedSegment: (s) => '[$s]', + transformUnmatchedSegment: (s) => '<$s>', + combineSegments: (segments) => segments.join(), + ); + expect(result, equals('[test][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 +344,40 @@ class TestSearchController extends DisposableController final data = []; @override - List matchesForSearch( - String search, { - bool searchPreviousMatches = false, - }) { - return data - .where((element) => element.name.caseInsensitiveContains(search)) - .toList(); - } + Iterable get currentDataToSearchThrough => data; +} + +class TestDebounceSearchController extends DisposableController + with SearchControllerMixin { + final data = []; + + @override + Iterable 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.pattern); + } +} + +class TestAutoCompleteSearchController extends DisposableController + with SearchControllerMixin, AutoCompleteSearchControllerMixin { + TestAutoCompleteSearchController() { + init(); + } + + @override + GlobalKey> get searchFieldKey => GlobalKey(); + + @override + Iterable get currentDataToSearchThrough => []; } From b802a432a6698847ece75484b9dba340694bd396 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Tue, 21 Apr 2026 13:39:15 -0700 Subject: [PATCH 2/7] formatting and gitignore --- .gitignore | 2 ++ tool/lib/commands/presubmit.dart | 15 ++++++++++----- tool/test/validate_skills_test.dart | 15 ++++++++++----- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index fc6c1605c17..5179e36e96c 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ DEPENDENCIES.md # macOS generated Flutter files **/macos/Flutter/ephemeral/ **/macos/Flutter/GeneratedPluginRegistrant.swift + +build/ \ No newline at end of file diff --git a/tool/lib/commands/presubmit.dart b/tool/lib/commands/presubmit.dart index c0518035d4f..66648807486 100644 --- a/tool/lib/commands/presubmit.dart +++ b/tool/lib/commands/presubmit.dart @@ -65,7 +65,10 @@ class PresubmitCommand extends Command { final pathsToFormat = _getPathsToFormat(p); final formatProcess = await pm.runProcess( - CliCommand.dart(['format', ...pathsToFormat], throwOnException: false), + CliCommand.dart([ + 'format', + ...pathsToFormat, + ], throwOnException: false), workingDirectory: p.packagePath, ); @@ -108,10 +111,12 @@ class PresubmitCommand extends Command { final pathsToFormat = _getPathsToFormat(p); final formatProcess = await pm.runProcess( - CliCommand.dart( - ['format', '--output=none', '--set-exit-if-changed', ...pathsToFormat], - throwOnException: false, - ), + CliCommand.dart([ + 'format', + '--output=none', + '--set-exit-if-changed', + ...pathsToFormat, + ], throwOnException: false), workingDirectory: p.packagePath, ); diff --git a/tool/test/validate_skills_test.dart b/tool/test/validate_skills_test.dart index 7c50d63ebeb..1c44f8b2050 100644 --- a/tool/test/validate_skills_test.dart +++ b/tool/test/validate_skills_test.dart @@ -11,12 +11,13 @@ void main() { test('Validate DevTools Skills', () async { final Level oldLevel = Logger.root.level; Logger.root.level = Level.ALL; - final StreamSubscription subscription = Logger.root.onRecord.listen((record) { - print(record.message); - }); + final StreamSubscription subscription = Logger.root.onRecord + .listen((record) { + print(record.message); + }); try { - // TODO(https://github.com/flutter/skills/issues/85): Update test + // TODO(https://github.com/flutter/skills/issues/85): Update test // to use dart_skills_lint.yaml for config when available. final bool isValid = await validateSkills( skillDirPaths: ['../.agents/skills'], @@ -26,7 +27,11 @@ void main() { 'check-trailing-whitespace': AnalysisSeverity.error, }, ); - expect(isValid, isTrue, reason: 'Skills validation failed. See above for details.'); + expect( + isValid, + isTrue, + reason: 'Skills validation failed. See above for details.', + ); } finally { Logger.root.level = oldLevel; await subscription.cancel(); From c5a593a6b581fefb2aea5ee89663fbe99318bf46 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Tue, 21 Apr 2026 13:40:01 -0700 Subject: [PATCH 3/7] new line --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5179e36e96c..ff6678e7488 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,4 @@ DEPENDENCIES.md **/macos/Flutter/ephemeral/ **/macos/Flutter/GeneratedPluginRegistrant.swift -build/ \ No newline at end of file +build/ From 790743f8880c1c1fc2c15089636b87986bb35028 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Tue, 21 Apr 2026 13:52:42 -0700 Subject: [PATCH 4/7] review comments --- .../test/shared/ui/search_test.dart | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/devtools_app/test/shared/ui/search_test.dart b/packages/devtools_app/test/shared/ui/search_test.dart index 50bf38ddacd..5f013baada1 100644 --- a/packages/devtools_app/test/shared/ui/search_test.dart +++ b/packages/devtools_app/test/shared/ui/search_test.dart @@ -5,6 +5,7 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/material.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -120,24 +121,26 @@ void main() { } }); - test('debounce', () 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); - - await Future.delayed(const Duration(milliseconds: 150)); - - expect(debounceController.isSearchInProgress, isFalse); - expect(debounceController.searchMatches.value.length, equals(3)); + 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(const Duration(milliseconds: 150)); + + expect(debounceController.isSearchInProgress, isFalse); + expect(debounceController.searchMatches.value.length, equals(3)); + }); }); }); @@ -365,7 +368,7 @@ class TestSearchData with SearchableDataMixin { @override bool matchesSearchToken(RegExp regExpSearch) { - return name.caseInsensitiveContains(regExpSearch.pattern); + return regExpSearch.hasMatch(name); } } @@ -376,7 +379,7 @@ class TestAutoCompleteSearchController extends DisposableController } @override - GlobalKey> get searchFieldKey => GlobalKey(); + final searchFieldKey = GlobalKey(); @override Iterable get currentDataToSearchThrough => []; From b0e3200d12e9d868bd18dcd6d308d94e9c962cf5 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Tue, 21 Apr 2026 13:53:13 -0700 Subject: [PATCH 5/7] fix --- packages/devtools_app/test/shared/ui/search_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools_app/test/shared/ui/search_test.dart b/packages/devtools_app/test/shared/ui/search_test.dart index 5f013baada1..f024d1f80a5 100644 --- a/packages/devtools_app/test/shared/ui/search_test.dart +++ b/packages/devtools_app/test/shared/ui/search_test.dart @@ -368,7 +368,7 @@ class TestSearchData with SearchableDataMixin { @override bool matchesSearchToken(RegExp regExpSearch) { - return regExpSearch.hasMatch(name); + return name.caseInsensitiveContains(regExpSearch); } } From e1989e96b46338f2363445d91fb15b8bd6e77f5a Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Tue, 21 Apr 2026 14:02:30 -0700 Subject: [PATCH 6/7] fix --- packages/devtools_app/test/shared/ui/search_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools_app/test/shared/ui/search_test.dart b/packages/devtools_app/test/shared/ui/search_test.dart index f024d1f80a5..484db68e7e3 100644 --- a/packages/devtools_app/test/shared/ui/search_test.dart +++ b/packages/devtools_app/test/shared/ui/search_test.dart @@ -4,8 +4,8 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:fake_async/fake_async.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { From 9184836f04c62a5913724dfdc9d8f5ed0cbe3991 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Tue, 21 Apr 2026 14:54:26 -0700 Subject: [PATCH 7/7] review comments --- packages/devtools_app/test/shared/ui/search_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools_app/test/shared/ui/search_test.dart b/packages/devtools_app/test/shared/ui/search_test.dart index 484db68e7e3..e98e83e67b0 100644 --- a/packages/devtools_app/test/shared/ui/search_test.dart +++ b/packages/devtools_app/test/shared/ui/search_test.dart @@ -136,7 +136,7 @@ void main() { ); // Has not updated yet expect(debounceController.isSearchInProgress, isTrue); - async.elapse(const Duration(milliseconds: 150)); + async.elapse(debounceController.debounceDelay! * 1.5); expect(debounceController.isSearchInProgress, isFalse); expect(debounceController.searchMatches.value.length, equals(3));