| // Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'dart:collection'; |
| |
| import 'package:analyzer_plugin/protocol/protocol_common.dart'; |
| import 'package:test/test.dart'; |
| |
| import 'domain_completion_util.dart'; |
| |
| /// A base class for classes containing completion tests. |
| class CompletionTestCase extends AbstractCompletionDomainTest { |
| static const String CURSOR_MARKER = '!'; |
| |
| List get suggestedCompletions => suggestions |
| .map((CompletionSuggestion suggestion) => suggestion.completion) |
| .toList(); |
| |
| void assertHasCompletion(String completion, |
| {ElementKind? elementKind, bool? isDeprecated}) { |
| var expectedOffset = completion.indexOf(CURSOR_MARKER); |
| if (expectedOffset >= 0) { |
| if (completion.contains(CURSOR_MARKER, expectedOffset + 1)) { |
| fail( |
| "Invalid completion, contains multiple cursor positions: '$completion'"); |
| } |
| completion = completion.replaceFirst(CURSOR_MARKER, ''); |
| } else { |
| expectedOffset = completion.length; |
| } |
| CompletionSuggestion? matchingSuggestion; |
| for (var suggestion in suggestions) { |
| if (suggestion.completion == completion) { |
| if (matchingSuggestion == null) { |
| matchingSuggestion = suggestion; |
| } else { |
| // It is OK to have a class and its default constructor suggestions. |
| if (matchingSuggestion.element?.kind == ElementKind.CLASS && |
| suggestion.element?.kind == ElementKind.CONSTRUCTOR || |
| matchingSuggestion.element?.kind == ElementKind.CONSTRUCTOR && |
| suggestion.element?.kind == ElementKind.CLASS) { |
| return; |
| } |
| fail( |
| "Expected exactly one '$completion' but found multiple:\n $suggestedCompletions"); |
| } |
| } |
| } |
| if (matchingSuggestion == null) { |
| fail("Expected '$completion' but found none:\n $suggestedCompletions"); |
| } |
| expect(matchingSuggestion.selectionOffset, equals(expectedOffset)); |
| expect(matchingSuggestion.selectionLength, equals(0)); |
| if (elementKind != null) { |
| expect(matchingSuggestion.element!.kind, elementKind); |
| } |
| if (isDeprecated != null) { |
| expect(matchingSuggestion.isDeprecated, isDeprecated); |
| } |
| } |
| |
| void assertHasNoCompletion(String completion) { |
| if (suggestions.any((CompletionSuggestion suggestion) => |
| suggestion.completion == completion)) { |
| fail( |
| "Did not expect completion '$completion' but found:\n $suggestedCompletions"); |
| } |
| } |
| |
| /// Discard any results that do not start with the characters the user has |
| /// "already typed". |
| void filterResults(String content) { |
| var charsAlreadyTyped = |
| content.substring(replacementOffset!, completionOffset).toLowerCase(); |
| suggestions = suggestions |
| .where((CompletionSuggestion suggestion) => |
| suggestion.completion.toLowerCase().startsWith(charsAlreadyTyped)) |
| .toList(); |
| } |
| |
| Future runTest(LocationSpec spec, [Map<String, String>? extraFiles]) { |
| super.setUp(); |
| return Future(() { |
| var content = spec.source; |
| newFile(testFile, content: content); |
| testCode = content; |
| completionOffset = spec.testLocation; |
| if (extraFiles != null) { |
| extraFiles.forEach((String fileName, String content) { |
| newFile(fileName, content: content); |
| }); |
| } |
| }).then((_) => getSuggestions()).then((_) { |
| filterResults(spec.source); |
| for (var result in spec.positiveResults) { |
| assertHasCompletion(result); |
| } |
| for (var result in spec.negativeResults) { |
| assertHasNoCompletion(result); |
| } |
| }).whenComplete(() { |
| super.tearDown(); |
| }); |
| } |
| } |
| |
| /// A specification of the completion results expected at a given location. |
| class LocationSpec { |
| String id; |
| int testLocation = -1; |
| List<String> positiveResults = <String>[]; |
| List<String> negativeResults = <String>[]; |
| late String source; |
| |
| LocationSpec(this.id); |
| |
| /// Parse a set of tests from the given `originalSource`. Return a list of the |
| /// specifications that were parsed. |
| /// |
| /// The source string has test locations embedded in it, which are identified |
| /// by '!X' where X is a single character. Each X is matched to positive or |
| /// negative results in the array of [validationStrings]. Validation strings |
| /// contain the name of a prediction with a two character prefix. The first |
| /// character of the prefix corresponds to an X in the [originalSource]. The |
| /// second character is either a '+' or a '-' indicating whether the string is |
| /// a positive or negative result. If logical not is needed in the source it |
| /// can be represented by '!!'. |
| /// |
| /// The [originalSource] is the source for a test that contains test |
| /// locations. The [validationStrings] are the positive and negative |
| /// predictions. |
| static List<LocationSpec> from( |
| String originalSource, List<String> validationStrings) { |
| Map<String, LocationSpec> tests = HashMap<String, LocationSpec>(); |
| var modifiedSource = originalSource; |
| var modifiedPosition = 0; |
| while (true) { |
| var index = modifiedSource.indexOf('!', modifiedPosition); |
| if (index < 0) { |
| break; |
| } |
| var n = 1; // only delete one char for double-bangs |
| var id = modifiedSource.substring(index + 1, index + 2); |
| if (id != '!') { |
| n = 2; |
| var test = LocationSpec(id); |
| tests[id] = test; |
| test.testLocation = index; |
| } else { |
| modifiedPosition = index + 1; |
| } |
| modifiedSource = modifiedSource.substring(0, index) + |
| modifiedSource.substring(index + n); |
| } |
| if (modifiedSource == originalSource) { |
| throw StateError('No tests in source: ' + originalSource); |
| } |
| for (var result in validationStrings) { |
| if (result.length < 3) { |
| throw StateError('Invalid location result: ' + result); |
| } |
| var id = result.substring(0, 1); |
| var sign = result.substring(1, 2); |
| var value = result.substring(2); |
| var test = tests[id]; |
| if (test == null) { |
| throw StateError('Invalid location result id: $id for: $result'); |
| } |
| test.source = modifiedSource; |
| if (sign == '+') { |
| test.positiveResults.add(value); |
| } else if (sign == '-') { |
| test.negativeResults.add(value); |
| } else { |
| var err = 'Invalid location result sign: $sign for: $result'; |
| throw StateError(err); |
| } |
| } |
| var badPoints = <String>[]; |
| var badResults = <String>[]; |
| for (var test in tests.values) { |
| if (test.testLocation == -1) { |
| badPoints.add(test.id); |
| } |
| if (test.positiveResults.isEmpty && test.negativeResults.isEmpty) { |
| badResults.add(test.id); |
| } |
| } |
| if (!(badPoints.isEmpty && badResults.isEmpty)) { |
| var err = StringBuffer(); |
| if (badPoints.isNotEmpty) { |
| err.write('No test location for tests:'); |
| for (var ch in badPoints) { |
| err |
| ..write(' ') |
| ..write(ch); |
| } |
| err.write(' '); |
| } |
| if (badResults.isNotEmpty) { |
| err.write('No results for tests:'); |
| for (var ch in badResults) { |
| err |
| ..write(' ') |
| ..write(ch); |
| } |
| } |
| throw StateError(err.toString()); |
| } |
| return tests.values.toList(); |
| } |
| } |