From 495a57d5f2dced8ea104215a4fef84422c28d4ce Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Tue, 16 Jul 2024 22:02:17 -0700 Subject: [PATCH 1/3] compose box test [nfc]: Pull prepareComposeBox out to be reused --- test/widgets/compose_box_test.dart | 53 +++++++++++++++++------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 564682b164..7908bb15b4 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -13,6 +13,24 @@ import '../model/binding.dart'; void main() { TestZulipBinding.ensureInitialized(); + Future> prepareComposeBox(WidgetTester tester, Narrow narrow) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + + final controllerKey = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, + home: GlobalStoreWidget( + child: PerAccountStoreWidget( + accountId: eg.selfAccount.id, + child: ComposeBox(controllerKey: controllerKey, narrow: narrow))))); + await tester.pumpAndSettle(); + + return controllerKey; + } + group('ComposeContentController', () { group('insertPadded', () { // Like `parseMarkedText` in test/model/autocomplete_test.dart, @@ -116,25 +134,10 @@ void main() { }); group('ComposeBox textCapitalization', () { - late GlobalKey controllerKey; - - Future prepareComposeBox(WidgetTester tester, Narrow narrow) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - - controllerKey = GlobalKey(); - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: ZulipLocalizations.localizationsDelegates, - supportedLocales: ZulipLocalizations.supportedLocales, - home: GlobalStoreWidget( - child: PerAccountStoreWidget( - accountId: eg.selfAccount.id, - child: ComposeBox(controllerKey: controllerKey, narrow: narrow))))); - await tester.pumpAndSettle(); - } - - void checkComposeBoxTextFields(WidgetTester tester, {required bool expectTopicTextField}) { + void checkComposeBoxTextFields(WidgetTester tester, { + required GlobalKey controllerKey, + required bool expectTopicTextField, + }) { final composeBoxController = controllerKey.currentState!; final topicTextField = tester.widgetList(find.byWidgetPredicate( @@ -155,13 +158,17 @@ void main() { } testWidgets('_StreamComposeBox', (tester) async { - await prepareComposeBox(tester, StreamNarrow(eg.stream().streamId)); - checkComposeBoxTextFields(tester, expectTopicTextField: true); + final key = await prepareComposeBox(tester, + StreamNarrow(eg.stream().streamId)); + checkComposeBoxTextFields(tester, controllerKey: key, + expectTopicTextField: true); }); testWidgets('_FixedDestinationComposeBox', (tester) async { - await prepareComposeBox(tester, TopicNarrow.ofMessage(eg.streamMessage())); - checkComposeBoxTextFields(tester, expectTopicTextField: false); + final key = await prepareComposeBox(tester, + TopicNarrow.ofMessage(eg.streamMessage())); + checkComposeBoxTextFields(tester, controllerKey: key, + expectTopicTextField: false); }); }); } From 81e2c9db69f3a1d153412e62244ea67172e48d4f Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 17 Jul 2024 12:17:52 -0700 Subject: [PATCH 2/3] compose box test: Test that message-send request is made on send-button tap --- test/widgets/compose_box_test.dart | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 7908bb15b4..6de0d21266 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -1,22 +1,34 @@ import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/store.dart'; +import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; +import '../stdlib_checks.dart'; void main() { TestZulipBinding.ensureInitialized(); + late PerAccountStore store; + late FakeApiConnection connection; + Future> prepareComposeBox(WidgetTester tester, Narrow narrow) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + final controllerKey = GlobalKey(); await tester.pumpWidget( MaterialApp( @@ -171,4 +183,40 @@ void main() { expectTopicTextField: false); }); }); + + group('message-send request response', () { + Future setupAndTapSend(WidgetTester tester, { + required void Function(int messageId) prepareResponse, + }) async { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + await prepareComposeBox(tester, const TopicNarrow(123, 'some topic')); + + final contentInputFinder = find.byWidgetPredicate( + (widget) => widget is TextField && widget.controller is ComposeContentController); + await tester.enterText(contentInputFinder, 'hello world'); + + prepareResponse(456); + await tester.tap(find.byTooltip(zulipLocalizations.composeBoxSendTooltip)); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages') + ..bodyFields.deepEquals({ + 'type': 'stream', + 'to': '123', + 'topic': 'some topic', + 'content': 'hello world', + 'read_by_sender': 'true', + }); + } + + testWidgets('success', (tester) async { + await setupAndTapSend(tester, prepareResponse: (int messageId) { + connection.prepare(json: SendMessageResult(id: messageId).toJson()); + }); + final errorDialogs = tester.widgetList(find.byType(AlertDialog)); + check(errorDialogs).isEmpty(); + }); + }); } From 3256d1ac8e1d19896e9c41f9c4b050068abf691a Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 17 Jul 2024 12:17:04 -0700 Subject: [PATCH 3/3] compose box: On failed message send, show error dialog Fixes: #815 --- lib/widgets/compose_box.dart | 22 ++++++++++++++++++++-- test/widgets/compose_box_test.dart | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index c80c5dd26a..5bb365cd81 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:image_picker/image_picker.dart'; +import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../model/compose.dart'; @@ -716,7 +717,7 @@ class _SendButtonState extends State<_SendButton> { || widget.contentController.hasValidationErrors.value; } - void _send() { + void _send() async { if (_hasValidationErrors) { final zulipLocalizations = ZulipLocalizations.of(context); List validationErrorMessages = [ @@ -735,9 +736,26 @@ class _SendButtonState extends State<_SendButton> { final store = PerAccountStoreWidget.of(context); final content = widget.contentController.textNormalized; - store.sendMessage(destination: widget.getDestination(), content: content); widget.contentController.clear(); + + try { + // TODO(#720) clear content input only on success response; + // while waiting, put input(s) and send button into a disabled + // "working on it" state (letting input text be selected for copying). + await store.sendMessage(destination: widget.getDestination(), content: content); + } on ApiRequestException catch (e) { + if (!mounted) return; + final zulipLocalizations = ZulipLocalizations.of(context); + final message = switch (e) { + ZulipApiException() => zulipLocalizations.errorServerMessage(e.message), + _ => e.message, + }; + showErrorDialog(context: context, + title: zulipLocalizations.errorMessageNotSent, + message: message); + return; + } } @override diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index 6de0d21266..8288239ab4 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -15,6 +15,7 @@ import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../stdlib_checks.dart'; +import 'dialog_checks.dart'; void main() { TestZulipBinding.ensureInitialized(); @@ -218,5 +219,23 @@ void main() { final errorDialogs = tester.widgetList(find.byType(AlertDialog)); check(errorDialogs).isEmpty(); }); + + testWidgets('ZulipApiException', (tester) async { + await setupAndTapSend(tester, prepareResponse: (message) { + connection.prepare( + httpStatus: 400, + json: { + 'result': 'error', + 'code': 'BAD_REQUEST', + 'msg': 'You do not have permission to initiate direct message conversations.', + }); + }); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorMessageNotSent, + expectedMessage: zulipLocalizations.errorServerMessage( + 'You do not have permission to initiate direct message conversations.'), + ))); + }); }); }