From 0c36cc383a83adca6cbd9a5e24746cf2b8b6cd3f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 18 Oct 2026 16:31:51 +0300 Subject: [PATCH] feat(sql-workspace): a failed query puts the caret at the driver's error position; the Mongo query error uses the same panel (#1557) --- lib/core/database/query_error_text.dart | 41 +++++ .../mongodb/mongo_query_workspace.dart | 12 +- .../workspace/generic_sql_workspace.dart | 15 ++ lib/features/workspace/results_tab.dart | 106 ++----------- lib/shared/widgets/query_error_view.dart | 130 ++++++++++++++++ test/core/database/query_error_text_test.dart | 49 ++++++ ...eneric_sql_workspace_error_caret_test.dart | 140 ++++++++++++++++++ .../shared/widgets/query_error_view_test.dart | 84 +++++++++++ 8 files changed, 473 insertions(+), 104 deletions(-) create mode 100644 lib/shared/widgets/query_error_view.dart create mode 100644 test/features/workspace/generic_sql_workspace_error_caret_test.dart create mode 100644 test/shared/widgets/query_error_view_test.dart diff --git a/lib/core/database/query_error_text.dart b/lib/core/database/query_error_text.dart index 91a3488a..1b8b1cac 100644 --- a/lib/core/database/query_error_text.dart +++ b/lib/core/database/query_error_text.dart @@ -1,3 +1,7 @@ +import 'dart:convert'; + +import 'package:postgres/postgres.dart' show ServerException; + /// A failed query as the results pane shows it (#1557): one readable line, /// and the driver's text without the statement the app wrapped around the /// user's SQL. @@ -69,4 +73,41 @@ class QueryErrorText { static String _cap(String s) => s.length <= _maxSummary ? s : '${s.substring(0, _maxSummary)}…'; + + /// `SELECT * FROM (\n` the wrapper puts before the user's SQL. + static const _wrapperPrefix = 'SELECT * FROM (\n'; + + // SQLite: `Causing statement (at position 29): ...`, a byte offset into the + // statement the driver prepared. + static final _sqlitePosition = RegExp(r'\(at position (\d+)\)'); + + /// Where in [sql], the text that was sent, the error is: a 0-based offset in + /// characters, or null when the driver does not say (MySQL) or the place is + /// not in [sql]. + /// + /// PostgreSQL gives a 1-based character position in the statement; SQLite a + /// byte offset in the statement it prepared, which is the user's SQL inside + /// the app's wrapper when [sql] is a query (#1144), so the wrapper's prefix + /// is taken off. The row limit the app appends comes after the text, so it + /// does not move anything. + static int? offsetIn(Object error, String sql) { + if (error is ServerException) return offsetOfPgPosition(error.position, sql); + final text = error.toString(); + final m = _sqlitePosition.firstMatch(text); + if (m == null) return null; + var bytes = int.parse(m[1]!); + if (text.contains(_wrapperPrefix)) bytes -= _wrapperPrefix.length; + if (bytes < 0) return null; + final encoded = utf8.encode(sql); + if (bytes > encoded.length) return null; + return utf8.decode(encoded.sublist(0, bytes), allowMalformed: true).length; + } + + /// The offset in [sql] of a PostgreSQL `position` (1-based, in characters), + /// or null when there is none or it lies outside [sql]. + static int? offsetOfPgPosition(int? position, String sql) { + if (position == null) return null; + final offset = position - 1; + return offset >= 0 && offset <= sql.length ? offset : null; + } } diff --git a/lib/features/mongodb/mongo_query_workspace.dart b/lib/features/mongodb/mongo_query_workspace.dart index 5fbeaf43..d17a4692 100644 --- a/lib/features/mongodb/mongo_query_workspace.dart +++ b/lib/features/mongodb/mongo_query_workspace.dart @@ -11,6 +11,8 @@ import 'package:querya_desktop/features/mongodb/mongo_documents_json_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_documents_table_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_query_executor.dart'; import 'package:querya_desktop/features/mongodb/mql_parser.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; +import 'package:querya_desktop/shared/widgets/query_error_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -499,15 +501,7 @@ class _MongoQueryWorkspaceState extends material.State { ], ), const Gap(8), - material.SelectableText( - tab.error!, - style: material.TextStyle( - color: cs.destructive, - fontFamily: QueryaTypography.mono, - fontFamilyFallback: QueryaTypography.monoFontFamilyFallback, - fontSize: 12.5, - ), - ), + QueryErrorView(error: QueryErrorText.parse(tab.error!), inline: true), ], ), ); diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 6c65b58c..6c473ed9 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -1,4 +1,5 @@ import 'package:querya_desktop/core/erd/erd_saved_layout.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; import 'package:querya_desktop/core/database/statement_queue.dart'; import 'package:querya_desktop/core/database/sql_statement_splitter.dart'; import 'dart:async'; @@ -810,6 +811,20 @@ class GenericSqlWorkspaceState extends material.State { } catch (e) { if (mounted) { invalidatePane(session); + // One statement: the caret goes where the driver says it failed. + if (sent.length == 1) { + final offset = QueryErrorText.offsetIn(e, sent.single); + if (offset != null) { + final runStart = statementAtCursor + ? scopeStart + spans.single.start + : scopeStart + (scope.length - scope.trimLeft().length); + // The text may have been edited while the query ran. + final at = (runStart + offset) + .clamp(0, session.controller.text.length); + session.controller.selection = + material.TextSelection.collapsed(offset: at); + } + } setState(() { session.error = e.toString(); session.running = false; diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index 1c4dc1e4..b782c987 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -5,7 +5,6 @@ import 'package:flutter/services.dart'; import 'package:querya_desktop/core/security/pii_masking_controller.dart'; import 'package:querya_desktop/core/actions/data_grid_command_bridge.dart'; import 'package:querya_desktop/core/database/query_error_text.dart'; -import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; import 'package:querya_desktop/features/results/charts/quick_chart_view.dart'; import 'package:querya_desktop/features/workspace/data_grid_calc_bar.dart'; import 'package:querya_desktop/features/workspace/data_grid_filter_bar.dart'; @@ -17,6 +16,7 @@ import 'package:querya_desktop/features/workspace/grid_filter_engine.dart'; import 'package:querya_desktop/features/workspace/grid_selection_calc_engine.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; +import 'package:querya_desktop/shared/widgets/query_error_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; @@ -86,8 +86,6 @@ class ResultsTab extends material.StatefulWidget { class _ResultsTabState extends material.State { /// The driver text of a query error is folded away until asked for (#1557). - bool _showErrorDetails = false; - bool _errorCopied = false; /// Selection / focus state lives in notifiers so a navigation step rebuilds /// only the widgets that show it (toolbar, value panel, calc bar), not the @@ -254,10 +252,6 @@ class _ResultsTabState extends material.State { @override void didUpdateWidget(covariant ResultsTab oldWidget) { - if (oldWidget.errorMessage != widget.errorMessage) { - _showErrorDetails = false; - _errorCopied = false; - } super.didUpdateWidget(oldWidget); _syncGridBridge(); if (!identical(oldWidget.rows, widget.rows) || @@ -388,93 +382,6 @@ class _ResultsTabState extends material.State { super.dispose(); } - /// The driver's message as the headline, the rest (without the app's own - /// statement wrapping) behind "Show details", and a Copy button (#1557). - material.Widget _buildError( - material.BuildContext context, - QueryErrorText error, - ) { - final cs = Theme.of(context).colorScheme; - return material.Center( - key: const material.ValueKey('results_mode_error'), - child: material.Padding( - padding: const material.EdgeInsets.all(32), - child: material.Column( - mainAxisAlignment: material.MainAxisAlignment.center, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Icon( - material.Icons.error_outline_rounded, - size: 48, - color: cs.destructive, - ), - const Gap(16), - const Text('Query Error').large().semiBold(), - const Gap(8), - material.SelectableText( - error.summary, - textAlign: material.TextAlign.center, - style: material.TextStyle(color: cs.foreground, fontSize: 14), - ), - if (error.hasDetail) ...[ - const Gap(12), - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - QueryaButton.ghost( - label: _showErrorDetails ? 'Hide details' : 'Show details', - icon: _showErrorDetails - ? material.Icons.expand_less_rounded - : material.Icons.expand_more_rounded, - size: QueryaControlSize.sm, - onPressed: () => setState( - () => _showErrorDetails = !_showErrorDetails, - ), - ), - const Gap(6), - QueryaButton.ghost( - label: _errorCopied ? 'Copied' : 'Copy error', - icon: _errorCopied - ? material.Icons.check_rounded - : material.Icons.copy_rounded, - size: QueryaControlSize.sm, - onPressed: () => unawaited(_copyError(error.detail)), - ), - ], - ), - ], - if (error.hasDetail && _showErrorDetails) ...[ - const Gap(8), - // A long driver message scrolls inside the space left, instead - // of pushing the column past the pane. - material.Flexible( - child: VirtualSelectableTextView( - text: error.detail, - style: material.TextStyle( - color: cs.mutedForeground, - fontSize: 13, - ), - ), - ), - ], - if (widget.errorAction != null) ...[ - const Gap(16), - widget.errorAction!, - ], - ], - ), - ), - ); - } - - Future _copyError(String text) async { - await Clipboard.setData(ClipboardData(text: text)); - if (!mounted) return; - setState(() => _errorCopied = true); - await Future.delayed(const Duration(seconds: 2)); - if (mounted) setState(() => _errorCopied = false); - } - @override material.Widget build(material.BuildContext context) { if (widget.isLoading) { @@ -488,7 +395,16 @@ class _ResultsTabState extends material.State { } if (widget.errorMessage != null) { - return _buildError(context, QueryErrorText.parse(widget.errorMessage!)); + return material.Center( + key: const material.ValueKey('results_mode_error'), + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: QueryErrorView( + error: QueryErrorText.parse(widget.errorMessage!), + action: widget.errorAction, + ), + ), + ); } if (widget.columns.isEmpty && widget.rows.isEmpty && widget.stagingBuffer == null) { diff --git a/lib/shared/widgets/query_error_view.dart b/lib/shared/widgets/query_error_view.dart new file mode 100644 index 00000000..deda373f --- /dev/null +++ b/lib/shared/widgets/query_error_view.dart @@ -0,0 +1,130 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; +import 'package:querya_desktop/core/ui/querya_control_tokens.dart'; +import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// A failed query: the driver's message as the headline, the rest (without the +/// statement the app wrapped around the user's SQL) behind "Show details", and +/// a Copy button (#1557). Shared by the SQL results pane and the Mongo query +/// workspace. +/// +/// [inline] is for a panel that has its own title row (a box above results): +/// left aligned and without the large icon and title. +class QueryErrorView extends material.StatefulWidget { + const QueryErrorView({ + super.key, + required this.error, + this.action, + this.inline = false, + }); + + final QueryErrorText error; + + /// Shown under the error, e.g. a way to fix the connection. + final material.Widget? action; + final bool inline; + + @override + material.State createState() => _QueryErrorViewState(); +} + +class _QueryErrorViewState extends material.State { + bool _showDetails = false; + bool _copied = false; + + @override + void didUpdateWidget(covariant QueryErrorView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.error.detail != widget.error.detail) { + _showDetails = false; + _copied = false; + } + } + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.error.detail)); + if (!mounted) return; + setState(() => _copied = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _copied = false); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final error = widget.error; + final inline = widget.inline; + final align = inline + ? material.CrossAxisAlignment.start + : material.CrossAxisAlignment.center; + return material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: align, + children: [ + if (!inline) ...[ + material.Icon( + material.Icons.error_outline_rounded, + size: 48, + color: cs.destructive, + ), + const Gap(16), + const Text('Query Error').large().semiBold(), + const Gap(8), + ], + material.SelectableText( + error.summary, + textAlign: inline ? material.TextAlign.start : material.TextAlign.center, + style: material.TextStyle(color: cs.foreground, fontSize: 14), + ), + if (error.hasDetail) ...[ + const Gap(12), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + QueryaButton.ghost( + label: _showDetails ? 'Hide details' : 'Show details', + icon: _showDetails + ? material.Icons.expand_less_rounded + : material.Icons.expand_more_rounded, + size: QueryaControlSize.sm, + onPressed: () => setState(() => _showDetails = !_showDetails), + ), + const Gap(6), + QueryaButton.ghost( + label: _copied ? 'Copied' : 'Copy error', + icon: _copied + ? material.Icons.check_rounded + : material.Icons.copy_rounded, + size: QueryaControlSize.sm, + onPressed: () => unawaited(_copy()), + ), + ], + ), + ], + if (error.hasDetail && _showDetails) ...[ + const Gap(8), + // A long driver message scrolls inside the space left, instead of + // pushing the column past the pane. + material.Flexible( + child: VirtualSelectableTextView( + text: error.detail, + style: material.TextStyle( + color: cs.mutedForeground, + fontSize: 13, + ), + ), + ), + ], + if (widget.action != null) ...[ + const Gap(16), + widget.action!, + ], + ], + ); + } +} diff --git a/test/core/database/query_error_text_test.dart b/test/core/database/query_error_text_test.dart index b289642e..9d732404 100644 --- a/test/core/database/query_error_text_test.dart +++ b/test/core/database/query_error_text_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/query_error_text.dart'; void main() { + _positionTests(); group('QueryErrorText (#1557)', () { // The example from the issue, as sqflite_common_ffi prints it. const sqlite = @@ -71,3 +72,51 @@ void main() { }); }); } + +void _positionTests() { + group('where the error is in the user\'s SQL (#1557)', () { + String sqliteError(String wrappedSql, int at) => + 'SqliteFfiException(sqlite_error: 1, , SqliteException(1): while ' + 'preparing statement, no such column: x, SQL logic error (code 1)\n' + ' Causing statement (at position $at): $wrappedSql})'; + + test('SQLite: the wrapper prefix is taken off', () { + const sql = 'SELECT a, nope FROM t'; + final error = sqliteError('SELECT * FROM (\n$sql\n)\nLIMIT 5000', 16 + 10); + expect(QueryErrorText.offsetIn(error, sql), 10); + expect(sql.substring(10, 14), 'nope'); + }); + + test('SQLite: without the wrapper the offset is the position', () { + const sql = 'UPDATE t SET nope = 1'; + final error = sqliteError(sql, 13); + expect(QueryErrorText.offsetIn(error, sql), 13); + }); + + test('SQLite counts bytes, the editor counts characters', () { + const sql = "SELECT 'héllo', nope FROM t"; + // `é` is two bytes: `nope` starts at character 16, byte 17. + final error = sqliteError('SELECT * FROM (\n$sql\n)', 16 + 17); + expect(QueryErrorText.offsetIn(error, sql), 16); + expect(sql.substring(16, 20), 'nope'); + }); + + test('a position outside the text, or none, gives no offset', () { + expect(QueryErrorText.offsetIn(sqliteError('SELECT 1', 500), 'SELECT 1'), + isNull); + expect( + QueryErrorText.offsetIn( + Exception('MySQLServerException [1064]: syntax error'), 'SELECT'), + isNull); + }); + + test('PostgreSQL positions are 1-based characters', () { + const sql = 'SELECT nope FROM t'; + expect(QueryErrorText.offsetOfPgPosition(8, sql), 7); + expect(sql.substring(7, 11), 'nope'); + expect(QueryErrorText.offsetOfPgPosition(null, sql), isNull); + expect(QueryErrorText.offsetOfPgPosition(0, sql), isNull); + expect(QueryErrorText.offsetOfPgPosition(99, sql), isNull); + }); + }); +} diff --git a/test/features/workspace/generic_sql_workspace_error_caret_test.dart b/test/features/workspace/generic_sql_workspace_error_caret_test.dart new file mode 100644 index 00000000..5cf307b6 --- /dev/null +++ b/test/features/workspace/generic_sql_workspace_error_caret_test.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/workspace/generic_sql_workspace.dart'; + +import '../../support/fake_sql_execution_delegate.dart'; +import '../../support/local_db_test_support.dart'; +import '../../support/querya_theme_test_shell.dart'; + + +/// A hung test must fail in a minute instead of blocking CI for ten. +const _timeout = Timeout(Duration(seconds: 60)); + + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late ConnectionRow connection; + + setUpAll(() async { + tempDir = await initTestLocalDb('generic_ws_error_caret_'); + // History is written with a foreign key to `connections`, so the workspace + // needs a connection that really exists. + const row = ConnectionRow( + type: 'sqlite', + name: 'Synthetic', + host: '/tmp/synthetic.db', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + connection = row.copyWith(id: id); + }); + tearDownAll(() => disposeTestLocalDb(tempDir)); + + tearDown(() { + SqlEditorCommandBridge.instance.unregister(connectionId: connection.id); + }); + + Future pumpWorkspace( + WidgetTester tester, + FakeSqlExecutionDelegate delegate, { + String? initialSql, + }) async { + await tester.binding.setSurfaceSize(const material.Size(1200, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox.expand( + child: GenericSqlWorkspace( + connectionRow: connection, + delegate: delegate, + dialect: SqlDialect.sqlite, + initialSql: initialSql, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return tester.state( + find.byType(GenericSqlWorkspace), + ); + } + + /// What SQLite throws for a bad column in a query the app wrapped (#1144), + /// with [at] counting bytes from the start of the wrapper. + Object sqliteError(String sql, int at) => Exception( + 'SqliteFfiException(sqlite_error: 1, , SqliteException(1): while ' + 'preparing statement, no such column: nope, SQL logic error (code 1)\n' + ' Causing statement (at position $at): SELECT * FROM (\n$sql\n)\n' + 'LIMIT 5000})', + ); + + Future run( + WidgetTester tester, + GenericSqlWorkspaceState state, + ) async { + // Settings are read from the real SQLite file: start the query in the test + // zone, then let real time pass. + unawaited(state.execute()); + for (var i = 0; i < 4; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 150)), + ); + await tester.pump(); + } + } + + testWidgets('a failed query puts the caret where the driver says', + timeout: _timeout, (tester) async { + const sql = 'SELECT a,\n nope\nFROM t'; + final delegate = FakeSqlExecutionDelegate(onExecute: (s) { + throw sqliteError(s, 16 + sql.indexOf('nope')); + }); + final state = await pumpWorkspace(tester, delegate, initialSql: sql); + + await run(tester, state); + + final session = state.activeSession; + expect(session.error, contains('no such column: nope')); + expect(session.controller.selection.isCollapsed, isTrue); + expect(session.controller.selection.baseOffset, sql.indexOf('nope')); + }); + + testWidgets('leading blank lines in the editor are counted', + timeout: _timeout, (tester) async { + const sql = 'SELECT nope FROM t'; + const padded = '\n\n $sql'; + final delegate = FakeSqlExecutionDelegate(onExecute: (s) { + throw sqliteError(s, 16 + sql.indexOf('nope')); + }); + final state = await pumpWorkspace(tester, delegate, initialSql: padded); + + await run(tester, state); + + expect(state.activeSession.controller.selection.baseOffset, + padded.indexOf('nope')); + }); + + testWidgets('an error without a position leaves the caret alone', + timeout: _timeout, (tester) async { + final delegate = FakeSqlExecutionDelegate( + onExecute: (s) => + throw Exception('MySQLServerException [1064]: You have an error'), + ); + final state = await pumpWorkspace(tester, delegate, initialSql: 'SELEC 1'); + state.activeSession.controller.selection = + const material.TextSelection.collapsed(offset: 2); + + await run(tester, state); + + expect(state.activeSession.error, contains('1064')); + expect(state.activeSession.controller.selection.baseOffset, 2); + }); +} diff --git a/test/shared/widgets/query_error_view_test.dart b/test/shared/widgets/query_error_view_test.dart new file mode 100644 index 00000000..1be1bfc2 --- /dev/null +++ b/test/shared/widgets/query_error_view_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/query_error_text.dart'; +import 'package:querya_desktop/shared/widgets/query_error_view.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + Future pump(WidgetTester t, String raw, {bool inline = false}) async { + await t.binding.setSurfaceSize(const material.Size(800, 600)); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(queryaThemeTestShell( + child: material.SizedBox.expand( + child: material.Center( + child: QueryErrorView(error: QueryErrorText.parse(raw), inline: inline), + ), + ), + )); + } + + const raw = 'MongoDartError: Could not run the command\n' + 'at db.users.find()\nserver said: unauthorized'; + + testWidgets('the headline first, the rest behind Show details', (t) async { + await pump(t, raw); + expect(find.text('Query Error'), findsOneWidget); + expect(find.text('MongoDartError: Could not run the command'), + findsOneWidget); + expect(find.textContaining('unauthorized'), findsNothing); + + await t.tap(find.text('Show details')); + await t.pump(); + expect(find.textContaining('unauthorized'), findsWidgets); + await t.tap(find.text('Hide details')); + await t.pump(); + expect(find.textContaining('unauthorized'), findsNothing); + }); + + testWidgets('Copy error puts the whole text on the clipboard', (t) async { + String? copied; + t.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + addTearDown(() => t.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null)); + await pump(t, raw); + + await t.tap(find.text('Copy error')); + await t.pump(); + expect(copied, raw); + expect(find.text('Copied'), findsOneWidget); + await t.pump(const Duration(seconds: 3)); + expect(find.text('Copy error'), findsOneWidget); + }); + + testWidgets('inline has no title of its own: the box around it has one', + (t) async { + await pump(t, raw, inline: true); + expect(find.text('Query Error'), findsNothing); + expect(find.text('MongoDartError: Could not run the command'), + findsOneWidget); + }); + + testWidgets('a one-line error has no details or copy', (t) async { + await pump(t, 'collection not found'); + expect(find.text('collection not found'), findsOneWidget); + expect(find.text('Show details'), findsNothing); + }); + + testWidgets('a new error closes the details', (t) async { + await pump(t, raw); + await t.tap(find.text('Show details')); + await t.pump(); + await pump(t, '$raw\nsecond'); + expect(find.text('Show details'), findsOneWidget); + }); +}