diff --git a/build.yaml b/build.yaml new file mode 100644 index 00000000..5aaf9be8 --- /dev/null +++ b/build.yaml @@ -0,0 +1,11 @@ +targets: + $default: + sources: + - $package$ + - lib/$lib$ + - lib/**.dart + - test/**.dart + builders: + mockito|mockBuilder: + generate_for: + - test/**.dart diff --git a/lib/src/analyzer/dartpad_analyzer.dart b/lib/src/analyzer/dartpad_analyzer.dart index e33ad882..8c64e70c 100644 --- a/lib/src/analyzer/dartpad_analyzer.dart +++ b/lib/src/analyzer/dartpad_analyzer.dart @@ -1,6 +1,6 @@ -// ignore_for_file: avoid_dynamic_calls import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../code/code.dart'; @@ -11,41 +11,45 @@ import 'models/issue_type.dart'; // Example for implementation of Analyzer for Dart. class DartPadAnalyzer extends AbstractAnalyzer { - static const _url = + @visibleForTesting + static const String url = 'https://stable.api.dartpad.dev/api/dartservices/v2/analyze'; + final http.Client client; + + DartPadAnalyzer({http.Client? client}) : client = client ?? http.Client(); + @override Future analyze(Code code) async { - final client = http.Client(); - - final response = await client.post( - Uri.parse(_url), - body: json.encode({ - 'source': code.text, - }), + final http.Response response = await client.post( + Uri.parse(url), + body: json.encode({'source': code.text}), encoding: utf8, ); - final decodedResponse = jsonDecode(utf8.decode(response.bodyBytes)) as Map; - final issueMaps = decodedResponse['issues']; + final Map decodedResponse = + jsonDecode(utf8.decode(response.bodyBytes)) as Map; + final dynamic issueMaps = decodedResponse['issues']; - if (issueMaps is! Iterable || (issueMaps.isEmpty)) { - return const AnalysisResult(issues: []); + if (issueMaps is! Iterable || (issueMaps.isEmpty)) { + return const AnalysisResult(issues: []); } - final issues = issueMaps + final List issues = issueMaps .cast>() - .map(issueFromJson) + .map(issueFromJson) .toList(growable: false); + return AnalysisResult(issues: issues); } } // Converts json to Issue object for the DartAnalyzer. Issue issueFromJson(Map json) { - final type = mapIssueType(json['kind']); + final IssueType type = mapIssueType(json['kind']); + final int line = json['line']; return Issue( - line: json['line'] - 1, + line: line - 1, message: json['message'], suggestion: json['correction'], type: type, diff --git a/lib/src/analyzer/models/issue.dart b/lib/src/analyzer/models/issue.dart index 8d90e6ad..60a103ae 100644 --- a/lib/src/analyzer/models/issue.dart +++ b/lib/src/analyzer/models/issue.dart @@ -18,6 +18,6 @@ class Issue { }); } -Comparator issueLineComparator = (issue1, issue2) { +Comparator issueLineComparator = (Issue issue1, Issue issue2) { return issue1.line - issue2.line; }; diff --git a/lib/src/code_field/code_field.dart b/lib/src/code_field/code_field.dart index ab2a38fc..e0281789 100644 --- a/lib/src/code_field/code_field.dart +++ b/lib/src/code_field/code_field.dart @@ -214,11 +214,15 @@ class CodeField extends StatefulWidget { class _CodeFieldState extends State { // Add a controller - LinkedScrollControllerGroup? _controllers; - ScrollController? _numberScroll; - ScrollController? _codeScroll; - ScrollController? _horizontalCodeScroll; - final _codeFieldKey = GlobalKey(); + final LinkedScrollControllerGroup _controllers = + LinkedScrollControllerGroup(); + + late final ScrollController _numberScroll; + late final ScrollController _codeScroll; + late final ScrollController _horizontalCodeScroll; + + final GlobalKey> _codeFieldKey = + GlobalKey>(); OverlayEntry? _suggestionsPopup; OverlayEntry? _searchPopup; @@ -234,15 +238,16 @@ class _CodeFieldState extends State { late TextStyle textStyle; Color? _backgroundCol; - final _editorKey = GlobalKey(); + final GlobalKey> _editorKey = + GlobalKey>(); Offset? _editorOffset; @override void initState() { super.initState(); - _controllers = LinkedScrollControllerGroup(); - _numberScroll = _controllers?.addAndGet(); - _codeScroll = _controllers?.addAndGet(); + + _numberScroll = _controllers.addAndGet(); + _codeScroll = _controllers.addAndGet(); widget.controller.addListener(_onTextChanged); widget.controller.addListener(_updatePopupOffset); @@ -261,11 +266,8 @@ class _CodeFieldState extends State { disableSpellCheckIfWeb(); WidgetsBinding.instance.addPostFrameCallback((_) { - final double width = _codeFieldKey.currentContext!.size!.width; - final double height = _codeFieldKey.currentContext!.size!.height; - windowSize = Size(width, height); + _onTextChanged(); }); - _onTextChanged(); } KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { @@ -284,9 +286,9 @@ class _CodeFieldState extends State { ); _searchPopup?.remove(); _searchPopup = null; - _numberScroll?.dispose(); - _codeScroll?.dispose(); - _horizontalCodeScroll?.dispose(); + _numberScroll.dispose(); + _codeScroll.dispose(); + _horizontalCodeScroll.dispose(); super.dispose(); } @@ -310,18 +312,20 @@ class _CodeFieldState extends State { } void rebuild() { - setState(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - // For some reason _codeFieldKey.currentContext is null in tests - // so check first. - final context = _codeFieldKey.currentContext; - if (context != null) { + if (mounted) { + setState(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + // For some reason _codeFieldKey.currentContext is null in tests + // so check first. + final BuildContext? context = _codeFieldKey.currentContext; + if (context == null || context.size == null) return; + final double width = context.size!.width; final double height = context.size!.height; windowSize = Size(width, height); - } + }); }); - }); + } } void _onTextChanged() { @@ -339,12 +343,15 @@ class _CodeFieldState extends State { if (line.length > longestLine.length) longestLine = line; }); - if (_codeScroll != null && _editorKey.currentContext != null) { - final box = _editorKey.currentContext!.findRenderObject() as RenderBox?; + if (_editorKey.currentContext != null) { + final RenderBox? box = + _editorKey.currentContext!.findRenderObject() as RenderBox?; _editorOffset = box?.localToGlobal(Offset.zero); if (_editorOffset != null) { - var fixedOffset = _editorOffset!; - fixedOffset += Offset(0, _codeScroll!.offset); + Offset fixedOffset = _editorOffset!; + if (_codeScroll.hasClients) { + fixedOffset += Offset(0, _codeScroll.offset); + } _editorOffset = fixedOffset; } } @@ -362,7 +369,7 @@ class _CodeFieldState extends State { child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + children: [ ConstrainedBox( constraints: BoxConstraints( maxHeight: 0, @@ -391,10 +398,10 @@ class _CodeFieldState extends State { @override Widget build(BuildContext context) { // Default color scheme - const rootKey = 'root'; + const String rootKey = 'root'; - final themeData = Theme.of(context); - final styles = CodeTheme.of(context)?.styles; + final ThemeData themeData = Theme.of(context); + final Map? styles = CodeTheme.of(context)?.styles; _backgroundCol = widget.background ?? styles?[rootKey]?.backgroundColor ?? DefaultStyles.backgroundColor; @@ -410,7 +417,7 @@ class _CodeFieldState extends State { textStyle = defaultTextStyle.merge(widget.textStyle); - final codeField = TextField( + final TextField codeField = TextField( focusNode: _focusNode, scrollPadding: widget.padding, style: textStyle, @@ -505,10 +512,12 @@ class _CodeFieldState extends State { final flippedTopOffset = normalTopOffset - (Sizes.autocompletePopupMaxHeight + caretHeight + Sizes.caretPadding); - setState(() { - _normalPopupOffset = Offset(leftOffset, normalTopOffset); - _flippedPopupOffset = Offset(leftOffset, flippedTopOffset); - }); + if (mounted) { + setState(() { + _normalPopupOffset = Offset(leftOffset, normalTopOffset); + _flippedPopupOffset = Offset(leftOffset, flippedTopOffset); + }); + } } TextPainter _getTextPainter(String text) { @@ -534,22 +543,27 @@ class _CodeFieldState extends State { } double _getPopupLeftOffset(TextPainter textPainter) { + final double scrollOffset = + _horizontalCodeScroll.hasClients ? _horizontalCodeScroll.offset : 0; + return max( _getCaretOffset(textPainter).dx + widget.padding.left - - _horizontalCodeScroll!.offset + + scrollOffset + (_editorOffset?.dx ?? 0), 0, ); } double _getPopupTopOffset(TextPainter textPainter, double caretHeight) { + final codeScrollOffset = _codeScroll.hasClients ? _codeScroll.offset : 0; + return max( _getCaretOffset(textPainter).dy + caretHeight + 16 + widget.padding.top - - _codeScroll!.offset + + codeScrollOffset + (_editorOffset?.dy ?? 0), 0, ); @@ -589,7 +603,7 @@ class _CodeFieldState extends State { OverlayEntry _buildSearchOverlay() { final colorScheme = Theme.of(context).colorScheme; - final borderColor = _getTextColorFromTheme() ?? colorScheme.onBackground; + final borderColor = _getTextColorFromTheme() ?? colorScheme.onSurface; return OverlayEntry( builder: (context) { return Positioned( diff --git a/lib/src/gutter/error.dart b/lib/src/gutter/error.dart index 04abf601..0a9b529f 100644 --- a/lib/src/gutter/error.dart +++ b/lib/src/gutter/error.dart @@ -33,15 +33,17 @@ class _GutterErrorWidgetState extends State { Widget build(BuildContext context) { return MouseRegion( onEnter: (event) { - setState(() { - _mouseEnteredPopup = false; - if (_entry != null) { - return; - } - _entry = _getErrorPopup(); - final overlay = Overlay.of(context); - overlay.insert(_entry!); - }); + if (mounted) { + setState(() { + _mouseEnteredPopup = false; + if (_entry != null) { + return; + } + _entry = _getErrorPopup(); + final overlay = Overlay.of(context); + overlay.insert(_entry!); + }); + } }, onExit: (event) { // Delay event here to keep overlay @@ -49,12 +51,14 @@ class _GutterErrorWidgetState extends State { Future.delayed( const Duration(milliseconds: 50), () { - setState(() { - if (!_mouseEnteredPopup) { - _entry?.remove(); - _entry = null; - } - }); + if (mounted) { + setState(() { + if (!_mouseEnteredPopup) { + _entry?.remove(); + _entry = null; + } + }); + } }, ); }, diff --git a/pubspec.yaml b/pubspec.yaml index 0cd5720b..f1dde93e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,8 +25,10 @@ dependencies: url_launcher: ^6.1.8 dev_dependencies: + build_runner: ^2.4.9 fake_async: ^1.3.1 flutter_test: { sdk: flutter } + mockito: ^5.4.4 total_lints: ^3.1.1 flutter: diff --git a/test/mocks/general_mocks.dart b/test/mocks/general_mocks.dart new file mode 100644 index 00000000..b4474175 --- /dev/null +++ b/test/mocks/general_mocks.dart @@ -0,0 +1,14 @@ +import 'package:http/http.dart' as http; +import 'package:mockito/annotations.dart' show GenerateNiceMocks, MockSpec; +/* +Run `dart run build_runner watch` in the +command line to generate the mocks +*/ + +// Create mock classes +@GenerateNiceMocks( + >[ + MockSpec(), + ], +) +void main() {} diff --git a/test/mocks/general_mocks.mocks.dart b/test/mocks/general_mocks.mocks.dart new file mode 100644 index 00000000..800811d0 --- /dev/null +++ b/test/mocks/general_mocks.mocks.dart @@ -0,0 +1,367 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in flutter_code_editor/test/mocks/general_mocks.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; +import 'dart:convert' as _i4; +import 'dart:typed_data' as _i6; + +import 'package:http/http.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i5; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { + _FakeResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeStreamedResponse_1 extends _i1.SmartFake + implements _i2.StreamedResponse { + _FakeStreamedResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [Client]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockClient extends _i1.Mock implements _i2.Client { + @override + _i3.Future<_i2.Response> head( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), + returnValueForMissingStub: + _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> get( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), + returnValueForMissingStub: + _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> post( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + returnValueForMissingStub: + _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> put( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + returnValueForMissingStub: + _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> patch( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + returnValueForMissingStub: + _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> delete( + Uri? url, { + Map? headers, + Object? body, + _i4.Encoding? encoding, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + returnValueForMissingStub: + _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, + ), + )), + ) as _i3.Future<_i2.Response>); + + @override + _i3.Future read( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future.value(_i5.dummyValue( + this, + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + )), + returnValueForMissingStub: + _i3.Future.value(_i5.dummyValue( + this, + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + )), + ) as _i3.Future); + + @override + _i3.Future<_i6.Uint8List> readBytes( + Uri? url, { + Map? headers, + }) => + (super.noSuchMethod( + Invocation.method( + #readBytes, + [url], + {#headers: headers}, + ), + returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + returnValueForMissingStub: + _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), + ) as _i3.Future<_i6.Uint8List>); + + @override + _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => + (super.noSuchMethod( + Invocation.method( + #send, + [request], + ), + returnValue: + _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( + this, + Invocation.method( + #send, + [request], + ), + )), + returnValueForMissingStub: + _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( + this, + Invocation.method( + #send, + [request], + ), + )), + ) as _i3.Future<_i2.StreamedResponse>); + + @override + void close() => super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/src/analyzer/abstract_analyzer_test.dart b/test/src/analyzer/abstract_analyzer_test.dart new file mode 100644 index 00000000..fd5be6d9 --- /dev/null +++ b/test/src/analyzer/abstract_analyzer_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_code_editor/flutter_code_editor.dart' + show AbstractAnalyzer; +import 'package:flutter_code_editor/src/analyzer/models/analysis_result.dart' + show AnalysisResult; +import 'package:flutter_code_editor/src/analyzer/models/issue.dart' show Issue; +import 'package:flutter_code_editor/src/code/code.dart' show Code; +import 'package:flutter_test/flutter_test.dart'; + +class DummyAnalyzer extends AbstractAnalyzer { + const DummyAnalyzer(); + + @override + Future analyze(Code code) async { + return const AnalysisResult(issues: []); + } +} + +void main() { + group('AbstractAnalyzer', () { + test('dispose() can be called safely', () { + const DummyAnalyzer analyzer = DummyAnalyzer(); + analyzer.dispose(); + }); + + test('analyze() should return AnalysisResult with no issues', () async { + const DummyAnalyzer analyzer = DummyAnalyzer(); + final AnalysisResult result = await analyzer.analyze(Code(text: '')); + expect(result, isA()); + expect(result.issues, isEmpty); + }); + }); +} diff --git a/test/src/analyzer/dartpad_analyzer_test.dart b/test/src/analyzer/dartpad_analyzer_test.dart new file mode 100644 index 00000000..c720b488 --- /dev/null +++ b/test/src/analyzer/dartpad_analyzer_test.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +import 'package:flutter_code_editor/flutter_code_editor.dart' + show DartPadAnalyzer; +import 'package:flutter_code_editor/src/analyzer/models/analysis_result.dart' + show AnalysisResult; +import 'package:flutter_code_editor/src/analyzer/models/issue.dart' show Issue; +import 'package:flutter_code_editor/src/analyzer/models/issue_type.dart' + show IssueType; +import 'package:flutter_code_editor/src/code/code.dart' show Code; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mockito/mockito.dart'; + +import '../../mocks/general_mocks.mocks.dart' show MockClient; + +void main() { + group('DartPadAnalyzer', () { + late MockClient mockClient; + late DartPadAnalyzer analyzer; + + setUp(() { + mockClient = MockClient(); + analyzer = DartPadAnalyzer(client: mockClient); + }); + + test('returns empty issues when response is empty or invalid', () async { + when>( + mockClient.post( + Uri.parse(DartPadAnalyzer.url), + body: anyNamed('body'), + encoding: anyNamed('encoding'), + ), + ).thenAnswer( + (Invocation _) async => http.Response('{}', 200), + ); + + final AnalysisResult result = await analyzer.analyze(Code(text: '')); + + expect(result.issues, isEmpty); + }); + + test('parses issues correctly from response', () async { + final Map mockResponse = { + 'issues': >[ + { + 'line': 2, + 'message': 'Avoid using print', + 'kind': 'warning', + 'correction': 'Remove the print statement', + 'url': 'https://example.com/print', + }, + ], + }; + + when>( + mockClient.post( + Uri.parse(DartPadAnalyzer.url), + body: anyNamed('body'), + encoding: anyNamed('encoding'), + ), + ).thenAnswer( + (Invocation _) async => http.Response(jsonEncode(mockResponse), 200), + ); + + final AnalysisResult result = + await analyzer.analyze(Code(text: 'print("hi");')); + + expect(result.issues.length, 1); + final Issue issue = result.issues.first; + expect(issue.message, 'Avoid using print'); + expect(issue.type, IssueType.warning); + expect(issue.line, 1); // 2 - 1 + }); + }); +} diff --git a/test/src/analyzer/default_local_analyzer_test.dart b/test/src/analyzer/default_local_analyzer_test.dart new file mode 100644 index 00000000..8081732b --- /dev/null +++ b/test/src/analyzer/default_local_analyzer_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class TestCode implements Code { + final Code _inner; + final List _mockedInvalidBlocks; + + TestCode({ + required String text, + required List mockedInvalidBlocks, + }) : _mockedInvalidBlocks = mockedInvalidBlocks, + _inner = Code(text: text); // use the factory constructor + + @override + List get invalidBlocks => _mockedInvalidBlocks; + + // Forward everything else to the inner Code instance. + @override + dynamic noSuchMethod(Invocation invocation) => Function.apply( + _inner.noSuchMethod, + [invocation], + ); +} + +void main() { + group('DefaultLocalAnalyzer', () { + test('returns issues from overridden invalidBlocks', () async { + final InvalidFoldableBlock block1 = InvalidFoldableBlock( + type: FoldableBlockType.braces, + startLine: 1, + ); + + final InvalidFoldableBlock block2 = InvalidFoldableBlock( + type: FoldableBlockType.brackets, + endLine: 3, + ); + + final TestCode code = TestCode( + text: 'fake', + mockedInvalidBlocks: [block1, block2], + ); + + const DefaultLocalAnalyzer analyzer = DefaultLocalAnalyzer(); + final AnalysisResult result = await analyzer.analyze(code); + + expect(result.issues.length, 2); + expect(result.issues[0], equals(block1.issue)); + expect(result.issues[1].line, equals(3)); + }); + + test('returns empty issues when invalidBlocks is empty', () async { + final TestCode code = TestCode( + text: 'clean', + mockedInvalidBlocks: [], + ); + + const DefaultLocalAnalyzer analyzer = DefaultLocalAnalyzer(); + final AnalysisResult result = await analyzer.analyze(code); + + expect(result.issues, isEmpty); + }); + }); +} diff --git a/test/src/analyzer/models/analysis_result_test.dart b/test/src/analyzer/models/analysis_result_test.dart new file mode 100644 index 00000000..a78f992a --- /dev/null +++ b/test/src/analyzer/models/analysis_result_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_code_editor/flutter_code_editor.dart' show Issue; +import 'package:flutter_code_editor/src/analyzer/models/analysis_result.dart' + show AnalysisResult; +import 'package:flutter_code_editor/src/analyzer/models/issue_type.dart' + show IssueType; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AnalysisResult', () { + test('should create an AnalysisResult with a list of issues', () { + final List issues = [ + const Issue(line: 1, message: 'Test', type: IssueType.info), + ]; + + final AnalysisResult result = AnalysisResult(issues: issues); + + expect(result.issues, hasLength(1)); + expect(result.issues.first.message, 'Test'); + }); + }); +} diff --git a/test/src/analyzer/models/issue_test.dart b/test/src/analyzer/models/issue_test.dart new file mode 100644 index 00000000..727b45ae --- /dev/null +++ b/test/src/analyzer/models/issue_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_code_editor/flutter_code_editor.dart' show Issue; +import 'package:flutter_code_editor/src/analyzer/models/issue.dart' + show issueLineComparator; +import 'package:flutter_code_editor/src/analyzer/models/issue_type.dart' + show IssueType; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group( + 'Issue Model Tests', + () { + test( + 'should create an Issue with required fields', + () { + const Issue issue = Issue( + line: 5, + message: 'Unexpected semicolon', + type: IssueType.warning, + ); + + expect(issue.line, 5); + expect(issue.message, 'Unexpected semicolon'); + expect(issue.type, IssueType.warning); + expect(issue.suggestion, isNull); + expect(issue.url, isNull); + }, + ); + + test( + 'should create an Issue with all fields', + () { + const Issue issue = Issue( + line: 10, + message: 'Missing return type', + type: IssueType.error, + suggestion: 'Add a return type', + url: 'https://example.com/return-type', + ); + + expect(issue.line, 10); + expect(issue.message, 'Missing return type'); + expect(issue.type, IssueType.error); + expect(issue.suggestion, 'Add a return type'); + expect(issue.url, 'https://example.com/return-type'); + }, + ); + }, + ); + + group( + 'issueLineComparator', + () { + test( + 'should compare issues by line number', + () { + const Issue issue1 = + Issue(line: 3, message: '', type: IssueType.info); + const Issue issue2 = + Issue(line: 5, message: '', type: IssueType.info); + const Issue issue3 = + Issue(line: 1, message: '', type: IssueType.info); + + final List issues = [issue1, issue2, issue3]; + issues.sort(issueLineComparator); + + expect(issues[0].line, 1); + expect(issues[1].line, 3); + expect(issues[2].line, 5); + }, + ); + }, + ); +}