diff --git a/.gitignore b/.gitignore index fc6c1605c17..ff6678e7488 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ DEPENDENCIES.md # 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 57c85993829..e98e83e67b0 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 @@ 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 +120,225 @@ void main() { 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( + 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 +347,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); + } +} + +class TestAutoCompleteSearchController extends DisposableController + with SearchControllerMixin, AutoCompleteSearchControllerMixin { + TestAutoCompleteSearchController() { + init(); + } + + @override + final searchFieldKey = GlobalKey(); + + @override + Iterable get currentDataToSearchThrough => []; } 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();