diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml index 91808df2..aa621854 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -59,7 +59,7 @@ jobs: test: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 steps: - uses: actions/checkout@v2 with: @@ -98,7 +98,7 @@ jobs: - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_flutter_core/coverage/lcov.info - min_coverage: 4.5 + min_coverage: 90 - uses: VeryGoodOpenSource/very_good_coverage@v1.1.1 with: path: packages/stream_chat_flutter/coverage/lcov.info diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index dcb11316..96fb70db 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -146,7 +146,7 @@ class Channel { /// Channel extra data Map get extraData => - state?._channelState?.channel?.extraData; + state?._channelState?.channel?.extraData ?? _extraData; /// Channel extra data as a stream Stream> get extraDataStream => diff --git a/packages/stream_chat/lib/src/api/requests.dart b/packages/stream_chat/lib/src/api/requests.dart index 764d249a..51151fca 100644 --- a/packages/stream_chat/lib/src/api/requests.dart +++ b/packages/stream_chat/lib/src/api/requests.dart @@ -104,4 +104,25 @@ class PaginationParams { lessThan: lessThan ?? this.lessThan, lessThanOrEqual: lessThanOrEqual ?? this.lessThanOrEqual, ); + + @override + int get hashCode => + runtimeType.hashCode ^ + limit.hashCode ^ + offset.hashCode ^ + greaterThan.hashCode ^ + greaterThanOrEqual.hashCode ^ + lessThan.hashCode ^ + lessThanOrEqual.hashCode; + + @override + bool operator ==(covariant PaginationParams other) => + identical(this, other) || + runtimeType == other.runtimeType && + limit == other.limit && + offset == other.offset && + greaterThan == other.greaterThan && + greaterThanOrEqual == other.greaterThanOrEqual && + lessThan == other.lessThan && + lessThanOrEqual == other.lessThanOrEqual; } diff --git a/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart b/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart index 2813f300..083b238b 100644 --- a/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart +++ b/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart @@ -143,7 +143,7 @@ class _ChannelFileDisplayScreenState extends State { } return LazyLoadScrollView( - onEndOfPage: () => messageSearchBloc.loadMore( + onEndOfPage: () => messageSearchBloc.search( filter: { 'cid': { r'$in': [StreamChannel.of(context).channel.cid] diff --git a/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart b/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart index 251143ed..7b34e449 100644 --- a/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart +++ b/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart @@ -166,7 +166,7 @@ class _ChannelMediaDisplayScreenState extends State { } return LazyLoadScrollView( - onEndOfPage: () => messageSearchBloc.loadMore( + onEndOfPage: () => messageSearchBloc.search( filter: { 'cid': { r'$in': [StreamChannel.of(context).channel.cid] diff --git a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart index 316e27b3..50131b87 100644 --- a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart @@ -262,9 +262,7 @@ class _MessageSearchListViewState extends State { ); if (widget.pullToRefresh) { child = RefreshIndicator( - onRefresh: () async { - _messageSearchListController.loadData(); - }, + onRefresh: () => _messageSearchListController.loadData(), child: child, ); } diff --git a/packages/stream_chat_flutter/lib/src/user_list_view.dart b/packages/stream_chat_flutter/lib/src/user_list_view.dart index 77537e59..5559f67e 100644 --- a/packages/stream_chat_flutter/lib/src/user_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/user_list_view.dart @@ -193,9 +193,7 @@ class _UserListViewState extends State return child; } else { return RefreshIndicator( - onRefresh: () async { - _userListController.loadData(); - }, + onRefresh: () => _userListController.loadData(), child: child, ); } diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 93c6e4d1..37898bcf 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -44,6 +44,11 @@ dependencies: path_provider: ^1.6.27 video_thumbnail: ^0.2.5+1 + +dependency_overrides: + stream_chat_flutter_core: + path: ../stream_chat_flutter_core + flutter: assets: - images/ diff --git a/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart index 8a41332d..3adea312 100644 --- a/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart @@ -130,10 +130,11 @@ class ChannelListCore extends StatefulWidget { final PaginationParams pagination; @override - _ChannelListCoreState createState() => _ChannelListCoreState(); + ChannelListCoreState createState() => ChannelListCoreState(); } -class _ChannelListCoreState extends State { +/// The current state of the [ChannelListCore]. +class ChannelListCoreState extends State { @override Widget build(BuildContext context) { final channelsBloc = ChannelsBloc.of(context); @@ -148,7 +149,7 @@ class _ChannelListCoreState extends State { stream: channelsBlocState.channelsStream, builder: (context, snapshot) { if (snapshot.hasError) { - return _buildErrorWidget(snapshot, context, channelsBlocState); + return widget.errorBuilder(context, snapshot.error); } if (!snapshot.hasData) { return widget.loadingBuilder(context); @@ -161,18 +162,7 @@ class _ChannelListCoreState extends State { }, ); - Widget _buildErrorWidget( - AsyncSnapshot> snapshot, - BuildContext context, - ChannelsBlocState channelsBlocState, - ) { - if (snapshot.error is Error) { - print((snapshot.error as Error).stackTrace); - } - - return widget.errorBuilder(context, snapshot.error); - } - + /// Fetches initial channels and updates the widget Future loadData() { final channelsBloc = ChannelsBloc.of(context); return channelsBloc.queryChannels( @@ -183,6 +173,7 @@ class _ChannelListCoreState extends State { ); } + /// Fetches more channels with updated pagination and updates the widget Future paginateData() { final channelsBloc = ChannelsBloc.of(context); return channelsBloc.queryChannels( @@ -223,9 +214,9 @@ class _ChannelListCoreState extends State { if (widget.filter?.toString() != oldWidget.filter?.toString() || jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || + widget.options?.toString() != oldWidget.options?.toString() || widget.pagination?.toJson()?.toString() != - oldWidget.pagination?.toJson()?.toString() || - widget.options?.toString() != oldWidget.options?.toString()) { + oldWidget.pagination?.toJson()?.toString()) { loadData(); } } diff --git a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart index 66c2b8ea..71f0d508 100644 --- a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -74,10 +74,9 @@ class ChannelsBlocState extends State /// The current channel list as a stream Stream> get channelsStream => _channelsController.stream; - final BehaviorSubject _queryChannelsLoadingController = - BehaviorSubject.seeded(false); + final _queryChannelsLoadingController = BehaviorSubject.seeded(false); - final BehaviorSubject> _channelsController = BehaviorSubject(); + final _channelsController = BehaviorSubject>(); /// The stream notifying the state of queryChannel call Stream get queryChannelsLoading => @@ -91,15 +90,14 @@ class ChannelsBlocState extends State List> sortOptions, PaginationParams paginationParams, Map options, - bool onlyOffline = false, }) async { final client = StreamChatCore.of(context).client; - if (client.state?.user == null || - _queryChannelsLoadingController.value == true) { - return; + if (_queryChannelsLoadingController.value == true) return; + + if (_channelsController.hasValue) { + _queryChannelsLoadingController.add(true); } - _queryChannelsLoadingController.sink.add(true); try { final clear = paginationParams == null || @@ -115,15 +113,20 @@ class ChannelsBlocState extends State if (clear) { _channelsController.add(channels); } else { - final l = oldChannels + channels; - _channelsController.add(l); + final temp = oldChannels + channels; + _channelsController.add(temp); + } + if (_channelsController.hasValue && + _queryChannelsLoadingController.value) { + _queryChannelsLoadingController.sink.add(false); } - _queryChannelsLoadingController.sink.add(false); } - } catch (err, stackTrace) { - print(err); - print(stackTrace); - _queryChannelsLoadingController.addError(err, stackTrace); + } catch (e, stk) { + if (_channelsController.hasValue) { + _queryChannelsLoadingController.addError(e, stk); + } else { + _channelsController.addError(e, stk); + } } } @@ -139,15 +142,14 @@ class ChannelsBlocState extends State _subscriptions.add(client.on(EventType.messageNew).listen((e) { final newChannels = List.from(channels ?? []); final index = newChannels.indexWhere((c) => c.cid == e.cid); - if (index > -1) { + if (index != -1) { if (index > 0) { final channel = newChannels.removeAt(index); newChannels.insert(0, channel); } - } else if (widget.shouldAddChannel != null && - widget.shouldAddChannel(e)) { + } else if (widget.shouldAddChannel?.call(e) == true) { final hiddenIndex = _hiddenChannels.indexWhere((c) => c.cid == e.cid); - if (hiddenIndex > -1) { + if (hiddenIndex != -1) { newChannels.insert(0, _hiddenChannels[hiddenIndex]); _hiddenChannels.removeAt(hiddenIndex); } else { diff --git a/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart b/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart index 725e19f3..80afb2ea 100644 --- a/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart +++ b/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart @@ -1,8 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -// ignore: constant_identifier_names -enum _LoadingStatus { LOADING, STABLE } +enum _LoadingStatus { loading, stable } /// Wrapper around a [Scrollable] which triggers [onEndOfPage]/[onStartOfPage] the Scrollable /// reaches to the start or end of the view extent. @@ -47,16 +46,16 @@ class LazyLoadScrollView extends StatefulWidget { } class _LazyLoadScrollViewState extends State { - _LoadingStatus _loadMoreStatus = _LoadingStatus.STABLE; - double _scrollPosition = 0; + _LoadingStatus _loadMoreStatus = _LoadingStatus.stable; @override - Widget build(BuildContext context) => NotificationListener( + Widget build(BuildContext context) => + NotificationListener( onNotification: _onNotification, child: widget.child, ); - bool _onNotification(Notification notification) { + bool _onNotification(ScrollNotification notification) { if (notification is ScrollStartNotification) { if (widget.onPageScrollStart != null) { widget.onPageScrollStart(); @@ -73,7 +72,7 @@ class _LazyLoadScrollViewState extends State { final pixels = notification.metrics.pixels; final maxScrollExtent = notification.metrics.maxScrollExtent; final minScrollExtent = notification.metrics.minScrollExtent; - final scrollOffset = widget.scrollOffset; + final scrollOffset = widget.scrollOffset ?? 0; if (pixels > (minScrollExtent + scrollOffset) && pixels < (maxScrollExtent - scrollOffset)) { @@ -85,28 +84,15 @@ class _LazyLoadScrollViewState extends State { final extentBefore = notification.metrics.extentBefore; final extentAfter = notification.metrics.extentAfter; - final scrollingDown = _scrollPosition < pixels; - if (scrollOffset == null || scrollOffset == 0) { - if (extentAfter == 0) { - _onEndOfPage(); - } - if (extentBefore == 0) { - _onStartOfPage(); - } - } else { - if (scrollingDown) { - if (extentAfter <= scrollOffset) { - _onEndOfPage(); - } - } else { - if (extentBefore <= scrollOffset) { - _onStartOfPage(); - } - } + if (extentAfter <= scrollOffset) { + _onEndOfPage(); + return true; + } + if (extentBefore <= scrollOffset) { + _onStartOfPage(); + return true; } - _scrollPosition = pixels; - return true; } if (notification is OverscrollNotification) { if (notification.overscroll > 0) { @@ -121,22 +107,22 @@ class _LazyLoadScrollViewState extends State { } void _onEndOfPage() { - if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) { - _loadMoreStatus = _LoadingStatus.LOADING; + if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.stable) { + _loadMoreStatus = _LoadingStatus.loading; if (widget.onEndOfPage != null) { widget.onEndOfPage().whenComplete(() { - _loadMoreStatus = _LoadingStatus.STABLE; + _loadMoreStatus = _LoadingStatus.stable; }); } } } void _onStartOfPage() { - if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) { - _loadMoreStatus = _LoadingStatus.LOADING; + if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.stable) { + _loadMoreStatus = _LoadingStatus.loading; if (widget.onStartOfPage != null) { widget.onStartOfPage().whenComplete(() { - _loadMoreStatus = _LoadingStatus.STABLE; + _loadMoreStatus = _LoadingStatus.stable; }); } } diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart index a5e9587e..53844447 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -114,28 +114,28 @@ class MessageListCore extends StatefulWidget { final bool Function(Message) messageFilter; @override - _MessageListCoreState createState() => _MessageListCoreState(); + MessageListCoreState createState() => MessageListCoreState(); } -class _MessageListCoreState extends State { - StreamChannelState streamChannel; +/// The current state of the [MessageListCore]. +class MessageListCoreState extends State { + StreamChannelState _streamChannel; - bool get _upToDate => streamChannel.channel.state.isUpToDate; + bool get _upToDate => _streamChannel.channel.state.isUpToDate; bool get _isThreadConversation => widget.parentMessage != null; - OwnUser get _currentUser => streamChannel.channel.client.state.user; + OwnUser get _currentUser => _streamChannel.channel.client.state.user; - List messages = []; - bool initialMessageHighlightComplete = false; + var _messages = []; @override Widget build(BuildContext context) { final messagesStream = _isThreadConversation - ? streamChannel.channel.state.threadsStream + ? _streamChannel.channel.state.threadsStream .where((threads) => threads.containsKey(widget.parentMessage.id)) .map((threads) => threads[widget.parentMessage.id]) - : streamChannel.channel.state?.messagesStream; + : _streamChannel.channel.state?.messagesStream; bool defaultFilter(Message m) { final isMyMessage = m.user.id == _currentUser.id; @@ -148,10 +148,10 @@ class _MessageListCoreState extends State { stream: messagesStream?.map((messages) => messages?.where(widget.messageFilter ?? defaultFilter)?.toList()), builder: (context, snapshot) { - if (!snapshot.hasData) { - return widget.loadingBuilder(context); - } else if (snapshot.hasError) { + if (snapshot.hasError) { return widget.errorWidgetBuilder(context, snapshot.error); + } else if (!snapshot.hasData) { + return widget.loadingBuilder(context); } else { final messageList = snapshot.data?.reversed?.toList() ?? []; if (messageList.isEmpty && !_isThreadConversation) { @@ -159,29 +159,32 @@ class _MessageListCoreState extends State { return widget.emptyBuilder(context); } } else { - messages = messageList; + _messages = messageList; } - return widget.messageListBuilder(context, messages); + return widget.messageListBuilder(context, _messages); } }, ); } + /// Fetches more messages with updated pagination and updates the widget. + /// + /// Optionally pass the fetch direction, defaults to [QueryDirection.bottom] Future paginateData( {QueryDirection direction = QueryDirection.bottom}) { if (!_isThreadConversation) { - return streamChannel.queryMessages(direction: direction); + return _streamChannel.queryMessages(direction: direction); } else { - return streamChannel.getReplies(widget.parentMessage.id); + return _streamChannel.getReplies(widget.parentMessage.id); } } @override void initState() { - streamChannel = StreamChannel.of(context); + _streamChannel = StreamChannel.of(context); if (_isThreadConversation) { - streamChannel.getReplies(widget.parentMessage.id); + _streamChannel.getReplies(widget.parentMessage.id); } if (widget.messageListController != null) { @@ -194,7 +197,7 @@ class _MessageListCoreState extends State { @override void dispose() { if (!_upToDate) { - streamChannel.reloadChannel(); + _streamChannel.reloadChannel(); } super.dispose(); } @@ -203,5 +206,5 @@ class _MessageListCoreState extends State { /// Controller used for paginating data in [ChannelListView] class MessageListController { /// Call this function to load further data - Function({QueryDirection direction}) paginateData; + Future Function({QueryDirection direction}) paginateData; } diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart index cf1e8d19..1c5c78de 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart @@ -48,17 +48,16 @@ class MessageSearchBlocState extends State Stream> get messagesStream => _messageResponses.stream; - final BehaviorSubject> _messageResponses = - BehaviorSubject(); + final _messageResponses = BehaviorSubject>(); - final BehaviorSubject _queryMessagesLoadingController = - BehaviorSubject.seeded(false); + final _queryMessagesLoadingController = BehaviorSubject.seeded(false); /// The stream notifying the state of queryUsers call Stream get queryMessagesLoading => _queryMessagesLoadingController.stream; - /// Calls [StreamChatClient.search] updating [messageResponses] stream + /// Calls [StreamChatClient.search] updating + /// [messagesStream] and [queryMessagesLoading] stream Future search({ Map filter, Map messageFilter, @@ -66,33 +65,13 @@ class MessageSearchBlocState extends State String query, PaginationParams pagination, }) async { - _messageResponses.add(null); - try { - final messages = await _search( - filter: filter, - messageFilter: messageFilter, - sort: sort, - query: query, - pagination: pagination, - ); - _messageResponses.add(messages.results); - } catch (err, stk) { - _messageResponses.addError(err, stk); - } - } + final client = StreamChatCore.of(context).client; - /// Calls [StreamChatClient.search] updating [queryMessagesLoading] stream - Future loadMore({ - Map filter, - Map messageFilter, - List sort, - String query, - PaginationParams pagination, - }) async { - if (_queryMessagesLoadingController.value == true) { - return; + if (_queryMessagesLoadingController.value == true) return; + + if (_messageResponses.hasValue) { + _queryMessagesLoadingController.add(true); } - _queryMessagesLoadingController.add(true); try { final clear = pagination == null || pagination.offset == null || @@ -100,12 +79,12 @@ class MessageSearchBlocState extends State final oldMessages = List.from(messageResponses ?? []); - final messages = await _search( - filter: filter, - messageFilter: messageFilter, + final messages = await client.search( + filter, sort: sort, query: query, - pagination: pagination, + paginationParams: pagination, + messageFilters: messageFilter, ); if (clear) { @@ -114,30 +93,18 @@ class MessageSearchBlocState extends State final temp = oldMessages + messages.results; _messageResponses.add(temp); } - - _queryMessagesLoadingController.add(false); - } catch (err, stackTrace) { - _queryMessagesLoadingController.addError(err, stackTrace); + if (_messageResponses.hasValue && _queryMessagesLoadingController.value) { + _queryMessagesLoadingController.add(false); + } + } catch (e, stk) { + if (_messageResponses.hasValue) { + _queryMessagesLoadingController.addError(e, stk); + } else { + _messageResponses.addError(e, stk); + } } } - Future _search({ - Map filter, - Map messageFilter, - List sort, - String query, - PaginationParams pagination, - }) { - final client = StreamChatCore.of(context).client; - return client.search( - filter, - sort: sort, - query: query, - paginationParams: pagination, - messageFilters: messageFilter, - ); - } - @override Widget build(BuildContext context) { super.build(context); diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart index 5c4e74fe..72c5ea86 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart @@ -104,21 +104,15 @@ class MessageSearchListCore extends StatefulWidget { final WidgetBuilder loadingBuilder; @override - _MessageSearchListCoreState createState() => _MessageSearchListCoreState(); + MessageSearchListCoreState createState() => MessageSearchListCoreState(); } -class _MessageSearchListCoreState extends State { +/// The current state of the [MessageSearchListCore]. +class MessageSearchListCoreState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - MessageSearchBloc.of(context).search( - filter: widget.filters, - sort: widget.sortOptions, - query: widget.messageQuery, - pagination: widget.paginationParams, - messageFilter: widget.messageFilters, - ); - + loadData(); if (widget.messageSearchListController != null) { widget.messageSearchListController.loadData = loadData; widget.messageSearchListController.paginateData = paginateData; @@ -136,29 +130,23 @@ class _MessageSearchListCoreState extends State { stream: messageSearchBloc.messagesStream, builder: (context, snapshot) { if (snapshot.hasError) { - if (snapshot.error is Error) { - print((snapshot.error as Error).stackTrace); - } - return widget.errorBuilder(context, snapshot.error); } - if (!snapshot.hasData) { return widget.loadingBuilder(context); } - final items = snapshot.data; - if (items.isEmpty) { return widget.emptyBuilder(context); } - return widget.childBuilder(snapshot.data); }, ); - void loadData() { - MessageSearchBloc.of(context).search( + /// Fetches initial messages and updates the widget + Future loadData() { + final messageSearchBloc = MessageSearchBloc.of(context); + return messageSearchBloc.search( filter: widget.filters, sort: widget.sortOptions, query: widget.messageQuery, @@ -167,10 +155,10 @@ class _MessageSearchListCoreState extends State { ); } - void paginateData() { + /// Fetches more messages with updated pagination and updates the widget + Future paginateData() { final messageSearchBloc = MessageSearchBloc.of(context); - - messageSearchBloc.loadMore( + return messageSearchBloc.search( filter: widget.filters, sort: widget.sortOptions, pagination: widget.paginationParams.copyWith( @@ -186,18 +174,12 @@ class _MessageSearchListCoreState extends State { super.didUpdateWidget(oldWidget); if (widget.filters?.toString() != oldWidget.filters?.toString() || jsonEncode(widget.sortOptions) != jsonEncode(oldWidget.sortOptions) || - widget.paginationParams?.toJson()?.toString() != - oldWidget.paginationParams?.toJson()?.toString() || widget.messageQuery?.toString() != oldWidget.messageQuery?.toString() || widget.messageFilters?.toString() != - oldWidget.messageFilters?.toString()) { - MessageSearchBloc.of(context).search( - filter: widget.filters, - sort: widget.sortOptions, - query: widget.messageQuery, - pagination: widget.paginationParams, - messageFilter: widget.messageFilters, - ); + oldWidget.messageFilters?.toString() || + widget.paginationParams?.toJson()?.toString() != + oldWidget.paginationParams?.toJson()?.toString()) { + loadData(); } } } @@ -205,8 +187,8 @@ class _MessageSearchListCoreState extends State { /// Controller used for paginating data in [ChannelListView] class MessageSearchListController { /// Call this function to reload data - VoidCallback loadData; + AsyncCallback loadData; /// Call this function to load further data - VoidCallback paginateData; + AsyncCallback paginateData; } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index 05158c94..8f86fca4 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -207,15 +207,14 @@ class StreamChannelState extends State { int after = 20, bool preferOffline = false, }) => - queryAtMessage( + _queryAtMessage( messageId: messageId, before: before, after: after, preferOffline: preferOffline, ); - /// - Future queryAtMessage({ + Future _queryAtMessage({ String messageId, int before = 20, int after = 20, @@ -297,7 +296,7 @@ class StreamChannelState extends State { } /// Reloads the channel with latest message - Future reloadChannel() => queryAtMessage(before: 30); + Future reloadChannel() => _queryAtMessage(before: 30); List> _futures; @@ -305,8 +304,7 @@ class StreamChannelState extends State { try { await loadChannelAtMessage(initialMessageId); return true; - } catch (e, stk) { - print('Error: $e\nStack: $stk'); + } catch (_) { rethrow; } } @@ -349,9 +347,6 @@ class StreamChannelState extends State { ], builder: (context, snapshot) { if (snapshot.hasError) { - if (snapshot.error is Error) { - print((snapshot.error as Error).stackTrace); - } var message = snapshot.error.toString(); if (snapshot.error is DioError) { final dioError = snapshot.error as DioError; @@ -361,9 +356,7 @@ class StreamChannelState extends State { message = 'Check your connection and retry'; } } - return Center( - child: Text(message), - ); + return Center(child: Text(message)); } final initialized = snapshot.data[0]; // ignore: avoid_bool_literals_in_conditional_expressions diff --git a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart index 83699cc1..15c2ddf3 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart @@ -93,10 +93,10 @@ class StreamChatCoreState extends State Widget build(BuildContext context) => widget.child; /// The current user - User get user => widget.client.state.user; + User get user => client.state?.user; /// The current user as a stream - Stream get userStream => widget.client.state.userStream; + Stream get userStream => client.state?.userStream; @override void initState() { @@ -108,21 +108,25 @@ class StreamChatCoreState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (client.state?.user != null) { + if (user != null) { if (state == AppLifecycleState.paused) { - if (widget.onBackgroundEventReceived != null) { - _eventSubscription = - client.on().listen(widget.onBackgroundEventReceived); - _disconnectTimer = Timer( - widget.backgroundKeepAlive, - client.disconnect, - ); - } else { + if (widget.onBackgroundEventReceived == null) { + client.disconnect(); + return; + } + _eventSubscription = client.on().listen( + widget.onBackgroundEventReceived, + ); + + void onTimerComplete() { + _eventSubscription.cancel(); client.disconnect(); } + + _disconnectTimer = Timer(widget.backgroundKeepAlive, onTimerComplete); } else if (state == AppLifecycleState.resumed) { - _eventSubscription?.cancel(); if (_disconnectTimer?.isActive == true) { + _eventSubscription.cancel(); _disconnectTimer.cancel(); } else { if (client.wsConnectionStatus == ConnectionStatus.disconnected) { diff --git a/packages/stream_chat_flutter_core/lib/src/user_list_core.dart b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart index 61169483..f1bd68ee 100644 --- a/packages/stream_chat_flutter_core/lib/src/user_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart @@ -80,7 +80,7 @@ class UserListCore extends StatefulWidget { final UserListController userListController; /// The builder that will be used in case of error - final Widget Function(Error error) errorBuilder; + final Widget Function(Object error) errorBuilder; /// The builder that will be used to build the list final Widget Function(BuildContext context, List users) listBuilder; @@ -120,21 +120,16 @@ class UserListCore extends StatefulWidget { final bool groupAlphabetically; @override - _UserListCoreState createState() => _UserListCoreState(); + UserListCoreState createState() => UserListCoreState(); } -class _UserListCoreState extends State +/// The current state of the [UserListCore]. +class UserListCoreState extends State with WidgetsBindingObserver { @override void didChangeDependencies() { super.didChangeDependencies(); - UsersBloc.of(context).queryUsers( - filter: widget.filter, - sort: widget.sort, - pagination: widget.pagination, - options: widget.options, - ); - + loadData(); if (widget.userListController != null) { widget.userListController.loadData = loadData; widget.userListController.paginateData = paginateData; @@ -144,11 +139,10 @@ class _UserListCoreState extends State @override Widget build(BuildContext context) { final _usersBloc = UsersBloc.of(context); - return _buildListView(_usersBloc); } - bool get isListAlreadySorted => + bool get _isListAlreadySorted => widget.sort?.any((e) => e.field == 'name' && e.direction == 1) ?? false; Stream> _buildUserStream( @@ -158,7 +152,7 @@ class _UserListCoreState extends State (users) { if (widget.groupAlphabetically) { var temp = users; - if (!isListAlreadySorted) { + if (!_isListAlreadySorted) { temp = users ..sort((curr, next) => curr.name.compareTo(next.name)); } @@ -186,33 +180,23 @@ class _UserListCoreState extends State stream: _buildUserStream(usersBlocState), builder: (context, snapshot) { if (snapshot.hasError) { - if (snapshot.error is Error) { - print((snapshot.error as Error).stackTrace); - } - return widget.errorBuilder(snapshot.error); } - if (!snapshot.hasData) { return widget.loadingBuilder(context); } - final items = snapshot.data; - if (items.isEmpty) { return widget.emptyBuilder(context); } - - if (items.isEmpty) { - return widget.emptyBuilder(context); - } - return widget.listBuilder(context, items); }, ); - void loadData() { - UsersBloc.of(context).queryUsers( + // ignore: public_member_api_docs + Future loadData() { + final _usersBloc = UsersBloc.of(context); + return _usersBloc.queryUsers( filter: widget.filter, sort: widget.sort, pagination: widget.pagination, @@ -220,10 +204,10 @@ class _UserListCoreState extends State ); } - void paginateData() { + // ignore: public_member_api_docs + Future paginateData() { final _usersBloc = UsersBloc.of(context); - - _usersBloc.queryUsers( + return _usersBloc.queryUsers( filter: widget.filter, sort: widget.sort, pagination: widget.pagination.copyWith( @@ -238,15 +222,10 @@ class _UserListCoreState extends State super.didUpdateWidget(oldWidget); if (widget.filter?.toString() != oldWidget.filter?.toString() || jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || + widget.options?.toString() != oldWidget.options?.toString() || widget.pagination?.toJson()?.toString() != - oldWidget.pagination?.toJson()?.toString() || - widget.options?.toString() != oldWidget.options?.toString()) { - UsersBloc.of(context).queryUsers( - filter: widget.filter, - sort: widget.sort, - pagination: widget.pagination, - options: widget.options, - ); + oldWidget.pagination?.toJson()?.toString()) { + loadData(); } } } @@ -255,11 +234,11 @@ class _UserListCoreState extends State /// Header items are prefixed with the key `HEADER` While users are prefixed /// with `USER`. abstract class ListItem { - // ignore: public_member_api_docs + /// Unique key per list item String get key { if (this is ListHeaderItem) { final header = (this as ListHeaderItem).heading; - return 'HEADER-$header'; + return 'HEADER-${header.toLowerCase()}'; } if (this is ListUserItem) { final user = (this as ListUserItem).user; @@ -268,7 +247,8 @@ abstract class ListItem { return null; } - // ignore: public_member_api_docs + /// Helper function to build widget based on ListItem type + // ignore: missing_return Widget when({ @required Widget Function(String heading) headerItem, @required Widget Function(User user) userItem, @@ -279,33 +259,32 @@ abstract class ListItem { if (this is ListUserItem) { return userItem((this as ListUserItem).user); } - return const SizedBox(); } } -// ignore: public_member_api_docs +/// Header Item class ListHeaderItem extends ListItem { - // ignore: public_member_api_docs + /// Constructs a new [ListHeaderItem] ListHeaderItem(this.heading); - // ignore: public_member_api_docs + /// Heading used to build the item. final String heading; } -// ignore: public_member_api_docs +/// User Item class ListUserItem extends ListItem { - // ignore: public_member_api_docs + /// Constructs a new [ListUserItem] ListUserItem(this.user); - // ignore: public_member_api_docs + /// [User] used to build the item. final User user; } /// Controller used for paginating data in [ChannelListView] class UserListController { /// Call this function to reload data - VoidCallback loadData; + AsyncCallback loadData; /// Call this function to load further data - VoidCallback paginateData; + AsyncCallback paginateData; } diff --git a/packages/stream_chat_flutter_core/lib/src/users_bloc.dart b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart index f10a5aae..92881619 100644 --- a/packages/stream_chat_flutter_core/lib/src/users_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart @@ -51,10 +51,9 @@ class UsersBlocState extends State /// The current users list as a stream Stream> get usersStream => _usersController.stream; - final BehaviorSubject> _usersController = BehaviorSubject(); + final _usersController = BehaviorSubject>(); - final BehaviorSubject _queryUsersLoadingController = - BehaviorSubject.seeded(false); + final _queryUsersLoadingController = BehaviorSubject.seeded(false); /// The stream notifying the state of queryUsers call Stream get queryUsersLoading => _queryUsersLoadingController.stream; @@ -70,11 +69,12 @@ class UsersBlocState extends State }) async { final client = StreamChatCore.of(context).client; - if (client.state?.user == null || - _queryUsersLoadingController.value == true) { - return; + if (_queryUsersLoadingController.value == true) return; + + if (_usersController.hasValue) { + _queryUsersLoadingController.add(true); } - _queryUsersLoadingController.add(true); + try { final clear = pagination == null || pagination.offset == null || @@ -95,10 +95,15 @@ class UsersBlocState extends State final temp = oldUsers + usersResponse.users; _usersController.add(temp); } - - _queryUsersLoadingController.add(false); - } catch (err, stackTrace) { - _queryUsersLoadingController.addError(err, stackTrace); + if (_usersController.hasValue && _queryUsersLoadingController.value) { + _queryUsersLoadingController.add(false); + } + } catch (e, stk) { + if (_usersController.hasValue) { + _queryUsersLoadingController.addError(e, stk); + } else { + _usersController.addError(e, stk); + } } } diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index b62101d1..01463de0 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -2,14 +2,14 @@ library stream_chat_flutter_core; export 'package:stream_chat/stream_chat.dart'; -export 'src/channel_list_core.dart'; +export 'src/channel_list_core.dart' hide ChannelListCoreState; export 'src/channels_bloc.dart'; export 'src/lazy_load_scroll_view.dart'; -export 'src/message_list_core.dart'; +export 'src/message_list_core.dart' hide MessageListCoreState; export 'src/message_search_bloc.dart'; -export 'src/message_search_list_core.dart'; +export 'src/message_search_list_core.dart' hide MessageSearchListCoreState; export 'src/stream_channel.dart'; export 'src/stream_chat_core.dart'; export 'src/typedef.dart'; -export 'src/user_list_core.dart'; +export 'src/user_list_core.dart' hide UserListCoreState; export 'src/users_bloc.dart'; diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 09643899..cf8f9b00 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -15,8 +15,11 @@ dependencies: rxdart: ^0.25.0 stream_chat: ^1.5.0 +dependency_overrides: + stream_chat: + path: ../stream_chat + dev_dependencies: - fake_async: ^1.1.0 flutter_test: sdk: flutter - mockito: ^4.1.4 \ No newline at end of file + mockito: ^4.1.3 \ No newline at end of file diff --git a/packages/stream_chat_flutter_core/test/channel_list_core_test.dart b/packages/stream_chat_flutter_core/test/channel_list_core_test.dart new file mode 100644 index 00000000..bf24e591 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/channel_list_core_test.dart @@ -0,0 +1,500 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat_flutter_core/src/channel_list_core.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'mocks.dart'; + +void main() { + const pagination = PaginationParams(offset: 0, limit: 25); + + List _generateChannels( + StreamChatClient client, { + int count = 3, + int offset = 0, + }) { + return List.generate( + count, + (index) { + index = index + offset; + return Channel( + client, + 'testType$index', + 'testId$index', + {'extra_data_key': 'extra_data_value_$index'}, + ); + }, + ); + } + + test( + 'should throw assertion error in case listBuilder is null', + () { + final channelListCore = () => ChannelListCore( + listBuilder: null, + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + expect(channelListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case loadingBuilder is null', + () { + final channelListCore = () => ChannelListCore( + listBuilder: (_, __) => Offstage(), + loadingBuilder: null, + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + expect(channelListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case emptyBuilder is null', + () { + final channelListCore = () => ChannelListCore( + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: null, + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + expect(channelListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case errorBuilder is null', + () { + final channelListCore = () => ChannelListCore( + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: null, + ); + expect(channelListCore, throwsA(isA())); + }, + ); + + testWidgets( + 'should throw if ChannelListCore is used where ChannelsBloc is not present ' + 'in the widget tree', + (tester) async { + const channelListCoreKey = Key('channelListCore'); + final channelListCore = ChannelListCore( + key: channelListCoreKey, + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + + await tester.pumpWidget(channelListCore); + + expect(find.byKey(channelListCoreKey), findsNothing); + expect(tester.takeException(), isInstanceOf()); + }, + ); + + testWidgets( + 'should render ChannelListCore if used with ChannelsBloc as an ancestor', + (tester) async { + const channelListCoreKey = Key('channelListCore'); + final channelListCore = ChannelListCore( + key: channelListCoreKey, + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: ChannelsBloc( + child: channelListCore, + ), + ), + ); + + expect(find.byKey(channelListCoreKey), findsOneWidget); + }, + ); + + testWidgets( + 'should assign loadData and paginateData callback to ' + 'ChannelListController if passed', + (tester) async { + const channelListCoreKey = Key('channelListCore'); + final controller = ChannelListController(); + final channelListCore = ChannelListCore( + key: channelListCoreKey, + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + channelListController: controller, + ); + + expect(controller.loadData, isNull); + expect(controller.paginateData, isNull); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: ChannelsBloc( + child: channelListCore, + ), + ), + ); + + expect(find.byKey(channelListCoreKey), findsOneWidget); + expect(controller.loadData, isNotNull); + expect(controller.paginateData, isNotNull); + }, + ); + + testWidgets( + 'should build error widget if channelsBlocState.channelsStream emits error', + (tester) async { + const channelListCoreKey = Key('channelListCore'); + const errorWidgetKey = Key('errorWidget'); + final channelListCore = ChannelListCore( + key: channelListCoreKey, + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => + Container(key: errorWidgetKey), + ); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + const error = 'Error! Error! Error!'; + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: pagination, + )).thenThrow(error); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: ChannelsBloc( + child: channelListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(errorWidgetKey), findsOneWidget); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: pagination, + )).called(1); + }, + ); + + testWidgets( + 'should build empty widget if channelsBlocState.channelsStream emits empty data', + (tester) async { + const channelListCoreKey = Key('channelListCore'); + const emptyWidgetKey = Key('emptyWidget'); + final channelListCore = ChannelListCore( + key: channelListCoreKey, + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Container(key: emptyWidgetKey), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + const channels = []; + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: pagination, + )).thenAnswer((_) => Stream.value(channels)); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: ChannelsBloc( + child: channelListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(emptyWidgetKey), findsOneWidget); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: pagination, + )).called(1); + }, + ); + + testWidgets( + 'should build list widget if channelsBlocState.channelsStream emits some data', + (tester) async { + const channelListCoreKey = Key('channelListCore'); + const listWidgetKey = Key('listWidget'); + final channelListCore = ChannelListCore( + key: channelListCoreKey, + listBuilder: (_, __) => Container(key: listWidgetKey), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + final channels = _generateChannels(mockClient); + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: pagination, + )).thenAnswer((_) => Stream.value(channels)); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: ChannelsBloc( + child: channelListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: pagination, + )).called(1); + }, + ); + + testWidgets( + 'should build list widget with paginated data ' + 'on calling channelListCoreState.paginateData', + (tester) async { + const channelListCoreKey = Key('channelListCore'); + const listWidgetKey = Key('listWidget'); + final channelListCore = ChannelListCore( + key: channelListCoreKey, + listBuilder: (_, channels) { + return Container( + key: listWidgetKey, + child: Text( + channels.map((e) => e.cid).join(','), + ), + ); + }, + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + final channels = _generateChannels(mockClient); + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: pagination, + )).thenAnswer((_) => Stream.value(channels)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChatCore( + client: mockClient, + child: ChannelsBloc( + child: channelListCore, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + expect(find.text(channels.map((e) => e.cid).join(',')), findsOneWidget); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: pagination, + )).called(1); + + final channelListCoreState = tester.state( + find.byKey(channelListCoreKey), + ); + + final offset = channels.length; + final paginatedChannels = _generateChannels(mockClient, offset: offset); + final updatedPagination = pagination.copyWith(offset: offset); + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: updatedPagination, + )).thenAnswer((_) => Stream.value(paginatedChannels)); + + await channelListCoreState.paginateData(); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + expect( + find.text([ + ...channels, + ...paginatedChannels, + ].map((e) => e.cid).join(',')), + findsOneWidget, + ); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: updatedPagination, + )).called(1); + }, + ); + + testWidgets( + 'should rebuild ChannelListCore with updated widget data ' + 'on calling setState()', + (tester) async { + StateSetter _stateSetter; + int limit = pagination.limit; + + const channelListCoreKey = Key('channelListCore'); + const listWidgetKey = Key('listWidget'); + + ChannelListCore channelListCoreBuilder(int limit) => ChannelListCore( + key: channelListCoreKey, + listBuilder: (_, channels) { + return Container( + key: listWidgetKey, + child: Text( + channels.map((e) => e.cid).join(','), + ), + ); + }, + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + pagination: pagination.copyWith(limit: limit), + ); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + final channels = _generateChannels(mockClient); + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: pagination, + )).thenAnswer((_) => Stream.value(channels)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChatCore( + client: mockClient, + child: ChannelsBloc( + child: StatefulBuilder(builder: (context, stateSetter) { + // Assigning stateSetter for rebuilding ChannelListCore + _stateSetter = stateSetter; + return channelListCoreBuilder(limit); + }), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + expect(find.text(channels.map((e) => e.cid).join(',')), findsOneWidget); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: pagination, + )).called(1); + + // Rebuilding ChannelListCore with new pagination limit + _stateSetter(() => limit = 6); + + final updatedChannels = _generateChannels(mockClient, count: limit); + final updatedPagination = pagination.copyWith(limit: limit); + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: updatedPagination, + )).thenAnswer((_) => Stream.value(updatedChannels)); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + expect( + find.text(updatedChannels.map((e) => e.cid).join(',')), + findsOneWidget, + ); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: updatedPagination, + )).called(1); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/channels_bloc_test.dart b/packages/stream_chat_flutter_core/test/channels_bloc_test.dart new file mode 100644 index 00000000..e021e9b7 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/channels_bloc_test.dart @@ -0,0 +1,847 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'matchers/channel_matcher.dart'; +import 'mocks.dart'; + +void main() { + List _generateChannels( + StreamChatClient client, { + int count = 3, + int offset = 0, + }) { + return List.generate( + count, + (index) { + index = index + offset; + return Channel( + client, + 'testType$index', + 'testId$index', + {'extra_data_key': 'extra_data_value_$index'}, + ); + }, + ); + } + + test( + 'should throw assertion error if child is null', + () async { + const channelsBlocKey = Key('channelsBloc'); + final channelsBloc = () => ChannelsBloc( + key: channelsBlocKey, + child: null, + ); + expect(channelsBloc, throwsA(isA())); + }, + ); + + testWidgets( + 'should throw if ChannelsBloc is used where StreamChat is not present in the widget tree', + (tester) async { + const channelsBlocKey = Key('channelsBloc'); + const childKey = Key('child'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Offstage(key: childKey), + ); + + await tester.pumpWidget(channelsBloc); + + expect(find.byKey(channelsBlocKey), findsNothing); + expect(find.byKey(childKey), findsNothing); + expect(tester.takeException(), isInstanceOf()); + }, + ); + + testWidgets( + 'should render ChannelsBloc if used with StreamChatCore as an ancestor', + (tester) async { + const channelsBlocKey = Key('channelsBloc'); + const childKey = Key('child'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Offstage(key: childKey), + ); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: channelsBloc, + ), + ); + + expect(find.byKey(channelsBlocKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + }, + ); + + testWidgets( + 'channelsBlocState.queryChannels() should emit data through channelsStream', + (tester) async { + const channelsBlocKey = Key('channelsBloc'); + const childKey = Key('child'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Builder( + key: childKey, + builder: (context) { + return Offstage(); + }, + ), + ); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: channelsBloc, + ), + ); + + final channelsBlocState = tester.state( + find.byKey(channelsBlocKey), + ); + + final offlineChannels = _generateChannels(mockClient); + final onlineChannels = _generateChannels(mockClient, offset: 3); + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) => Stream.fromIterable([offlineChannels, onlineChannels]), + ); + + channelsBlocState.queryChannels(); + + await expectLater( + channelsBlocState.channelsStream, + emitsInOrder([ + isSameChannelListAs(offlineChannels), + isSameChannelListAs(onlineChannels), + ]), + ); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + }, + ); + + testWidgets( + 'channelsBlocState.channelsStream should emit error ' + 'if client.queryChannels() throws', + (tester) async { + const channelsBlocKey = Key('channelsBloc'); + const childKey = Key('child'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Builder( + key: childKey, + builder: (context) { + return Offstage(); + }, + ), + ); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: channelsBloc, + ), + ); + + final channelsBlocState = tester.state( + find.byKey(channelsBlocKey), + ); + + final error = 'Error! Error! Error!'; + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).thenThrow(error); + + channelsBlocState.queryChannels(); + + await expectLater( + channelsBlocState.channelsStream, + emitsError(error), + ); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + }, + ); + + testWidgets( + 'calling channelsBlocState.queryChannels() again with an offset ' + 'should emit new data through channelsStream and also emit loading state ' + 'through queryChannelsLoading', + (tester) async { + const channelsBlocKey = Key('channelsBloc'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Offstage(), + ); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: channelsBloc, + ), + ); + + final channelsBlocState = tester.state( + find.byKey(channelsBlocKey), + ); + + final channels = _generateChannels(mockClient); + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer((_) => Stream.value(channels)); + + channelsBlocState.queryChannels(); + + await expectLater( + channelsBlocState.channelsStream, + emits(isSameChannelListAs(channels)), + ); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + + final offset = channels.length; + final paginationParams = PaginationParams(offset: offset); + + final newChannels = _generateChannels(mockClient, offset: offset); + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: paginationParams, + )).thenAnswer( + (_) => Stream.value(newChannels), + ); + + channelsBlocState.queryChannels(paginationParams: paginationParams); + + await Future.wait([ + expectLater( + channelsBlocState.queryChannelsLoading, + emitsInOrder([true, false]), + ), + expectLater( + channelsBlocState.channelsStream, + emits(isSameChannelListAs(channels + newChannels)), + ), + ]); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: paginationParams, + )).called(1); + }, + ); + + testWidgets( + 'calling channelsBlocState.queryChannels() again with an offset ' + 'should emit error through queryChannelsLoading if ' + 'client.queryChannels() throws', + (tester) async { + const channelsBlocKey = Key('channelsBloc'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Offstage(), + ); + + final mockClient = MockClient(); + + when(mockClient.on(any, any, any, any)).thenAnswer((_) => Stream.empty()); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: channelsBloc, + ), + ); + + final channelsBlocState = tester.state( + find.byKey(channelsBlocKey), + ); + + final channels = _generateChannels(mockClient); + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer((_) => Stream.value(channels)); + + channelsBlocState.queryChannels(); + + await expectLater( + channelsBlocState.channelsStream, + emits(isSameChannelListAs(channels)), + ); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + + final offset = channels.length; + final paginationParams = PaginationParams(offset: offset); + + final error = 'Error! Error! Error!'; + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: paginationParams, + )).thenThrow(error); + + channelsBlocState.queryChannels(paginationParams: paginationParams); + + await expectLater( + channelsBlocState.queryChannelsLoading, + emitsError(error), + ); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: paginationParams, + )).called(1); + }, + ); + + group('event controller test', () { + StreamController eventController; + setUp(() { + eventController = StreamController.broadcast(); + }); + + testWidgets( + 'channel should get hide when EventType.channelHidden event is received', + (tester) async { + final mockClient = MockClient(); + const channelsBlocKey = Key('channelsBloc'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Offstage(), + ); + + when(mockClient.on(any, any, any, any)) + .thenAnswer((_) => Stream.empty()); + + when(mockClient.on( + EventType.channelHidden, + )).thenAnswer((_) => eventController.stream); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: channelsBloc, + ), + ); + + final channelsBlocState = tester.state( + find.byKey(channelsBlocKey), + ); + + final channels = _generateChannels(mockClient); + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) => Stream.value(channels), + ); + + await channelsBlocState.queryChannels(); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + + final channelHiddenEvent = Event( + type: EventType.channelHidden, + cid: channels.first.cid, + ); + + eventController.add(channelHiddenEvent); + + final newChannels = [...channels] + ..removeWhere((it) => it.cid == channelHiddenEvent.cid); + + await expectLater( + channelsBlocState.channelsStream, + emitsInOrder([ + isSameChannelListAs(channels), + isSameChannelListAs(newChannels), + ]), + ); + + verify(mockClient.on(EventType.channelHidden)).called(1); + }, + ); + + testWidgets( + 'channel should get removed when EventType.channelDeleted or ' + 'EventType.notificationRemovedFromChannel, event is received', + (tester) async { + final mockClient = MockClient(); + const channelsBlocKey = Key('channelsBloc'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Offstage(), + ); + + when(mockClient.on(any, any, any, any)) + .thenAnswer((_) => Stream.empty()); + + when(mockClient.on( + EventType.channelDeleted, + EventType.notificationRemovedFromChannel, + )).thenAnswer((_) => eventController.stream); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: channelsBloc, + ), + ); + + final channelsBlocState = tester.state( + find.byKey(channelsBlocKey), + ); + + final channels = _generateChannels(mockClient); + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) => Stream.value(channels), + ); + + await channelsBlocState.queryChannels(); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + + final channelDeletedOrNotificationRemovedEvent = Event( + channel: EventChannel(cid: channels.first.cid), + ); + + eventController.add(channelDeletedOrNotificationRemovedEvent); + + final channelCid = channelDeletedOrNotificationRemovedEvent.channel.cid; + final newChannels = [...channels] + ..removeWhere((it) => it.cid == channelCid); + + await expectLater( + channelsBlocState.channelsStream, + emitsInOrder([ + isSameChannelListAs(channels), + isSameChannelListAs(newChannels), + ]), + ); + + verify(mockClient.on( + EventType.channelDeleted, + EventType.notificationRemovedFromChannel, + )).called(1); + }, + ); + + testWidgets( + 'event channel should be moved to top of the list if present when' + 'EventType.messageNew event is received', + (tester) async { + final mockClient = MockClient(); + const channelsBlocKey = Key('channelsBloc'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Offstage(), + ); + + when(mockClient.on(any, any, any, any)) + .thenAnswer((_) => Stream.empty()); + + when(mockClient.on( + EventType.messageNew, + )).thenAnswer((_) => eventController.stream); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: channelsBloc, + ), + ); + + final channelsBlocState = tester.state( + find.byKey(channelsBlocKey), + ); + + final channels = _generateChannels(mockClient); + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) => Stream.value(channels), + ); + + await channelsBlocState.queryChannels(); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + + final messageNewEvent = Event( + type: EventType.messageNew, + cid: channels.last.cid, + ); + + eventController.add(messageNewEvent); + + final channelCid = messageNewEvent.cid; + final index = channels.indexWhere((it) => it.cid == channelCid); + final updatedChannel = channels[index]; + final newChannels = [...channels] + ..removeAt(index) + ..insert(0, updatedChannel); + + await expectLater( + channelsBlocState.channelsStream, + emitsInOrder([ + isSameChannelListAs(channels), + isSameChannelListAs(newChannels), + ]), + ); + + verify(mockClient.on(EventType.messageNew)).called(1); + }, + ); + + testWidgets( + 'event channel should be moved to top of the list if present inside ' + 'hiddenChannels list and shouldAddChannel is true when ' + 'EventType.messageNew event is received', + (tester) async { + final hiddenChannelEventController = StreamController(); + + addTearDown(() { + hiddenChannelEventController.close(); + }); + + final mockClient = MockClient(); + final channels = _generateChannels(mockClient); + const channelsBlocKey = Key('channelsBloc'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Offstage(), + shouldAddChannel: (e) => channels.map((it) => it.cid).contains(e.cid), + ); + + when(mockClient.on(any, any, any, any)) + .thenAnswer((_) => Stream.empty()); + + when(mockClient.on( + EventType.channelHidden, + )).thenAnswer((_) => hiddenChannelEventController.stream); + + when(mockClient.on( + EventType.messageNew, + )).thenAnswer((_) => eventController.stream); + + final messageNewEvent = Event( + type: EventType.messageNew, + cid: channels.last.cid, + ); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: channelsBloc, + ), + ); + + final channelsBlocState = tester.state( + find.byKey(channelsBlocKey), + ); + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) => Stream.value(channels), + ); + + await channelsBlocState.queryChannels(); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + + final channelHiddenEvent = Event( + type: EventType.channelHidden, + cid: channels.last.cid, + ); + + // Hiding the channel before passing messageNew event + hiddenChannelEventController.add(channelHiddenEvent); + + final channelsAfterHiddenEvent = [...channels]..removeLast(); + + eventController.add(messageNewEvent); + + final channelCid = messageNewEvent.cid; + final index = channels.indexWhere((it) => it.cid == channelCid); + final newChannels = [...channels] + ..removeAt(index) + ..insert(0, channels[index]); + + await expectLater( + channelsBlocState.channelsStream, + emitsInOrder([ + isSameChannelListAs(channels), + isSameChannelListAs(channelsAfterHiddenEvent), + isSameChannelListAs(newChannels), + ]), + ); + + verify(mockClient.on(EventType.channelHidden)).called(1); + verify(mockClient.on(EventType.messageNew)).called(1); + }, + ); + + testWidgets( + 'event channel should be moved to top of the list if present inside ' + 'channel state and shouldAddChannel is true when ' + 'EventType.messageNew event is received', + (tester) async { + final mockClient = MockClient(); + final channels = _generateChannels(mockClient); + final stateChannels = { + for (var c in _generateChannels(mockClient, offset: 5)) c.cid: c + }; + const channelsBlocKey = Key('channelsBloc'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Offstage(), + shouldAddChannel: (_) => true, + ); + + when(mockClient.state.channels).thenReturn(stateChannels); + + when(mockClient.on(any, any, any, any)) + .thenAnswer((_) => Stream.empty()); + + when(mockClient.on( + EventType.messageNew, + )).thenAnswer((_) => eventController.stream); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: channelsBloc, + ), + ); + + final channelsBlocState = tester.state( + find.byKey(channelsBlocKey), + ); + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) => Stream.value(channels), + ); + + await channelsBlocState.queryChannels(); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + + final messageNewEvent = Event( + type: EventType.messageNew, + cid: stateChannels.keys.first, + ); + + eventController.add(messageNewEvent); + + final newChannels = [...channels] + ..insert(0, stateChannels[stateChannels.keys.first]); + + await expectLater( + channelsBlocState.channelsStream, + emitsInOrder([ + isSameChannelListAs(channels), + isSameChannelListAs(newChannels), + ]), + ); + + verify(mockClient.on(EventType.messageNew)).called(1); + }, + ); + + testWidgets( + 'channels should get sorted according to channelsComparator when ' + 'EventType.messageNew event is received', + (tester) async { + final mockClient = MockClient(); + final channels = _generateChannels(mockClient); + int channelComparator(Channel a, Channel b) { + final aData = a.extraData['extra_data_key'] as String; + final bData = b.extraData['extra_data_key'] as String; + return bData.compareTo(aData); + } + + const channelsBlocKey = Key('channelsBloc'); + final channelsBloc = ChannelsBloc( + key: channelsBlocKey, + child: Offstage(), + shouldAddChannel: (_) => true, + channelsComparator: channelComparator, + ); + + when(mockClient.on(any, any, any, any)) + .thenAnswer((_) => Stream.empty()); + + when(mockClient.on( + EventType.messageNew, + )).thenAnswer((_) => eventController.stream); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: channelsBloc, + ), + ); + + final channelsBlocState = tester.state( + find.byKey(channelsBlocKey), + ); + + when(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) => Stream.value(channels), + ); + + await channelsBlocState.queryChannels(); + + verify(mockClient.queryChannels( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + + final messageNewEvent = Event( + type: EventType.messageNew, + cid: channels.first.cid, + ); + + eventController.add(messageNewEvent); + + final newChannels = [...channels]..sort(channelComparator); + + await expectLater( + channelsBlocState.channelsStream, + emitsInOrder([ + isSameChannelListAs(channels), + isSameChannelListAs(newChannels), + ]), + ); + + verify(mockClient.on(EventType.messageNew)).called(1); + }, + ); + + tearDown(() { + eventController.close(); + }); + }); +} diff --git a/packages/stream_chat_flutter_core/test/lazy_load_scroll_view_test.dart b/packages/stream_chat_flutter_core/test/lazy_load_scroll_view_test.dart new file mode 100644 index 00000000..19c1f2a2 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/lazy_load_scroll_view_test.dart @@ -0,0 +1,294 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_core/src/lazy_load_scroll_view.dart'; + +void main() { + test( + 'should throw assertion error if child is null', + () async { + const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); + final lazyLoadScrollView = () => LazyLoadScrollView( + key: lazyLoadScrollViewKey, + child: null, + ); + expect(lazyLoadScrollView, throwsA(isA())); + }, + ); + + testWidgets( + 'should render LazyLoadScrollView if child is provided', + (tester) async { + const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); + final lazyLoadScrollView = LazyLoadScrollView( + key: lazyLoadScrollViewKey, + child: Offstage(), + ); + + await tester.pumpWidget(lazyLoadScrollView); + + expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget); + }, + ); + + testWidgets( + 'should render LazyLoadScrollView if child is provided', + (tester) async { + const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); + const childKey = Key('childKey'); + final lazyLoadScrollView = LazyLoadScrollView( + key: lazyLoadScrollViewKey, + child: Offstage(key: childKey), + ); + + await tester.pumpWidget(lazyLoadScrollView); + + expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + }, + ); + + testWidgets( + 'should call onPageScrollStart if child scrollView scrolls', + (tester) async { + const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); + const childListViewKey = Key('childListView'); + + bool onPageScrollStartCalled = false; + + final lazyLoadScrollView = LazyLoadScrollView( + key: lazyLoadScrollViewKey, + onPageScrollStart: () { + onPageScrollStartCalled = true; + }, + child: ListView( + key: childListViewKey, + children: List.generate( + 12, + (index) => Container( + height: 100, + child: Text('Item #$index'), + ), + ), + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: lazyLoadScrollView, + ), + ); + + expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget); + expect(find.byKey(childListViewKey), findsOneWidget); + expect(onPageScrollStartCalled, isFalse); + + await tester.startGesture(const Offset(100.0, 100.0)); + await tester.pump(const Duration(seconds: 1)); + + expect(onPageScrollStartCalled, isTrue); + }, + ); + + testWidgets( + 'should call onPageScrollStart, onPageScrollEnd ' + 'if child scrollView starts scrolling and ends', + (tester) async { + const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); + const childListViewKey = Key('childListView'); + + bool onPageScrollStartCalled = false; + bool onPageScrollEndCalled = false; + + final lazyLoadScrollView = LazyLoadScrollView( + key: lazyLoadScrollViewKey, + onPageScrollStart: () { + onPageScrollStartCalled = true; + }, + onPageScrollEnd: () { + onPageScrollEndCalled = true; + }, + child: ListView( + key: childListViewKey, + children: List.generate( + 12, + (index) => Container( + height: 100, + child: Text('Item #$index'), + ), + ), + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: lazyLoadScrollView, + ), + ); + + expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget); + expect(find.byKey(childListViewKey), findsOneWidget); + expect(onPageScrollStartCalled, isFalse); + expect(onPageScrollEndCalled, isFalse); + + final gesture = await tester.createGesture(); + + await gesture.down(const Offset(100.0, 100.0)); + await tester.pump(const Duration(seconds: 1)); + + expect(onPageScrollStartCalled, isTrue); + + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + + expect(onPageScrollEndCalled, isTrue); + }, + ); + + testWidgets( + 'should call onInBetweenOfPage if child scrollView ' + 'scroll position is in-between of view', + (tester) async { + const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); + const childListViewKey = Key('childListView'); + + bool onInBetweenOfPageCalled = false; + + final lazyLoadScrollView = LazyLoadScrollView( + key: lazyLoadScrollViewKey, + onInBetweenOfPage: () async { + onInBetweenOfPageCalled = true; + }, + child: ListView( + key: childListViewKey, + children: List.generate( + 12, + (index) => Container( + height: 100, + child: Text('Item #$index'), + ), + ), + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: lazyLoadScrollView, + ), + ); + + expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget); + expect(find.byKey(childListViewKey), findsOneWidget); + expect(onInBetweenOfPageCalled, isFalse); + + final gesture = await tester.createGesture(); + + await gesture.down(const Offset(100.0, 100.0)); + await tester.pump(const Duration(seconds: 1)); + await gesture.moveBy(const Offset(-200.0, -200.0)); + await tester.pump(const Duration(seconds: 1)); + + expect(onInBetweenOfPageCalled, isTrue); + }, + ); + + testWidgets( + 'should call onStartOfPage if child scrollView ' + 'tends to reach the start', + (tester) async { + const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); + const childListViewKey = Key('childListView'); + + bool onStartOfPageCalled = false; + + final lazyLoadScrollView = LazyLoadScrollView( + key: lazyLoadScrollViewKey, + onStartOfPage: () async { + onStartOfPageCalled = true; + }, + child: ListView( + key: childListViewKey, + children: List.generate( + 12, + (index) => Container( + height: 100, + child: Text('Item #$index'), + ), + ), + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: lazyLoadScrollView, + ), + ); + + expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget); + expect(find.byKey(childListViewKey), findsOneWidget); + expect(onStartOfPageCalled, isFalse); + + final gesture = await tester.createGesture(); + + await gesture.down(const Offset(100.0, 100.0)); + await tester.pump(const Duration(seconds: 1)); + await gesture.moveBy(const Offset(-200.0, -200.0)); + await tester.pump(const Duration(seconds: 1)); + await gesture.moveBy(const Offset(201.0, 201.0)); + await tester.pump(const Duration(seconds: 1)); + + expect(onStartOfPageCalled, isTrue); + }, + ); + + testWidgets( + 'should call onEndOfPage if child scrollView ' + 'tends to reach the end', + (tester) async { + const lazyLoadScrollViewKey = Key('lazyLoadScrollView'); + const childListViewKey = Key('childListView'); + + bool onEndOfPageCalled = false; + + final lazyLoadScrollView = LazyLoadScrollView( + key: lazyLoadScrollViewKey, + onEndOfPage: () async { + onEndOfPageCalled = true; + }, + child: ListView( + key: childListViewKey, + children: List.generate( + 12, + (index) => Container( + height: 100, + child: Text('Item #$index'), + ), + ), + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: lazyLoadScrollView, + ), + ); + + expect(find.byKey(lazyLoadScrollViewKey), findsOneWidget); + expect(find.byKey(childListViewKey), findsOneWidget); + expect(onEndOfPageCalled, isFalse); + + final gesture = await tester.createGesture(); + + await gesture.down(const Offset(100.0, 100.0)); + await tester.pump(const Duration(seconds: 1)); + await gesture.moveBy(const Offset(-601.0, -601.0)); + await tester.pump(const Duration(seconds: 1)); + + expect(onEndOfPageCalled, isTrue); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/matchers/channel_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/channel_matcher.dart new file mode 100644 index 00000000..f371acff --- /dev/null +++ b/packages/stream_chat_flutter_core/test/matchers/channel_matcher.dart @@ -0,0 +1,49 @@ +import 'package:meta/meta.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +Matcher isSameChannelAs(Channel targetChannel) => + _IsSameChannelAs(targetChannel: targetChannel); + +class _IsSameChannelAs extends Matcher { + const _IsSameChannelAs({ + @required this.targetChannel, + }) : assert(targetChannel != null, ''); + + final Channel targetChannel; + + @override + bool matches(covariant Channel channel, Map matchState) => + channel.cid == targetChannel.cid; + + @override + Description describe(Description description) => + description.add('is same channel as $targetChannel'); +} + +Matcher isSameChannelListAs(List targetChannelList) => + _IsSameChannelListAs(targetChannelList: targetChannelList); + +class _IsSameChannelListAs extends Matcher { + const _IsSameChannelListAs({ + @required this.targetChannelList, + }) : assert(targetChannelList != null, ''); + + final List targetChannelList; + + @override + bool matches(covariant List channelList, Map matchState) { + bool matches = true; + for (var i = 0; i < channelList.length; i++) { + final channel = channelList[i]; + final targetChannel = targetChannelList[i]; + matches = isSameChannelAs(targetChannel).matches(channel, matchState); + if (!matches) break; + } + return matches; + } + + @override + Description describe(Description description) => + description.add('is same channelList as $targetChannelList'); +} diff --git a/packages/stream_chat_flutter_core/test/matchers/get_message_response_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/get_message_response_matcher.dart new file mode 100644 index 00000000..bd89420b --- /dev/null +++ b/packages/stream_chat_flutter_core/test/matchers/get_message_response_matcher.dart @@ -0,0 +1,55 @@ +import 'package:meta/meta.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +Matcher isSameMessageResponseAs(GetMessageResponse targetResponse) => + _IsSameMessageResponseAs(targetResponse: targetResponse); + +class _IsSameMessageResponseAs extends Matcher { + const _IsSameMessageResponseAs({ + @required this.targetResponse, + }) : assert(targetResponse != null, ''); + + final GetMessageResponse targetResponse; + + @override + bool matches(covariant GetMessageResponse response, Map matchState) => + response.message.id == targetResponse.message.id && + response.channel.cid == targetResponse.channel.cid; + + @override + Description describe(Description description) => + description.add('is same message response as $targetResponse'); +} + +Matcher isSameMessageResponseListAs( + List targetResponseList) => + _IsSameMessageResponseListAs(targetResponseList: targetResponseList); + +class _IsSameMessageResponseListAs extends Matcher { + const _IsSameMessageResponseListAs({ + @required this.targetResponseList, + }) : assert(targetResponseList != null, ''); + + final List targetResponseList; + + @override + bool matches( + covariant List responseList, Map matchState) { + bool matches = true; + for (var i = 0; i < responseList.length; i++) { + final response = responseList[i]; + final targetResponse = targetResponseList[i]; + matches = isSameMessageResponseAs(targetResponse).matches( + response, + matchState, + ); + if (!matches) break; + } + return matches; + } + + @override + Description describe(Description description) => + description.add('is same userResponseList as $targetResponseList'); +} diff --git a/packages/stream_chat_flutter_core/test/matchers/message_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/message_matcher.dart new file mode 100644 index 00000000..12a3e19d --- /dev/null +++ b/packages/stream_chat_flutter_core/test/matchers/message_matcher.dart @@ -0,0 +1,49 @@ +import 'package:meta/meta.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +Matcher isSameMessageAs(Message targetMessage) => + _IsSameMessageAs(targetMessage: targetMessage); + +class _IsSameMessageAs extends Matcher { + const _IsSameMessageAs({ + @required this.targetMessage, + }) : assert(targetMessage != null, ''); + + final Message targetMessage; + + @override + bool matches(covariant Message message, Map matchState) => + message.id == targetMessage.id; + + @override + Description describe(Description description) => + description.add('is same message as $targetMessage'); +} + +Matcher isSameMessageListAs(List targetMessageList) => + _IsSameMessageListAs(targetMessageList: targetMessageList); + +class _IsSameMessageListAs extends Matcher { + const _IsSameMessageListAs({ + @required this.targetMessageList, + }) : assert(targetMessageList != null, ''); + + final List targetMessageList; + + @override + bool matches(covariant List messageList, Map matchState) { + bool matches = true; + for (var i = 0; i < messageList.length; i++) { + final message = messageList[i]; + final targetMessage = targetMessageList[i]; + matches = isSameMessageAs(targetMessage).matches(message, matchState); + if (!matches) break; + } + return matches; + } + + @override + Description describe(Description description) => + description.add('is same messageList as $targetMessageList'); +} diff --git a/packages/stream_chat_flutter_core/test/matchers/users_matcher.dart b/packages/stream_chat_flutter_core/test/matchers/users_matcher.dart new file mode 100644 index 00000000..3816fa24 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/matchers/users_matcher.dart @@ -0,0 +1,47 @@ +import 'package:meta/meta.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +Matcher isSameUserAs(User targetUser) => _IsSameUserAs(targetUser: targetUser); + +class _IsSameUserAs extends Matcher { + const _IsSameUserAs({ + @required this.targetUser, + }) : assert(targetUser != null, ''); + + final User targetUser; + + @override + bool matches(covariant User user, Map matchState) => user.id == targetUser.id; + + @override + Description describe(Description description) => + description.add('is same user as $targetUser'); +} + +Matcher isSameUserListAs(List targetUserList) => + _IsSameUserListAs(targetUserList: targetUserList); + +class _IsSameUserListAs extends Matcher { + const _IsSameUserListAs({ + @required this.targetUserList, + }) : assert(targetUserList != null, ''); + + final List targetUserList; + + @override + bool matches(covariant List userList, Map matchState) { + bool matches = true; + for (var i = 0; i < userList.length; i++) { + final user = userList[i]; + final targetUser = targetUserList[i]; + matches = isSameUserAs(targetUser).matches(user, matchState); + if (!matches) break; + } + return matches; + } + + @override + Description describe(Description description) => + description.add('is same userList as $targetUserList'); +} diff --git a/packages/stream_chat_flutter_core/test/message_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_list_core_test.dart new file mode 100644 index 00000000..95446f95 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/message_list_core_test.dart @@ -0,0 +1,413 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat_flutter_core/src/message_list_core.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'mocks.dart'; + +void main() { + List _generateMessages({ + int count = 3, + int offset = 0, + bool threads = false, + }) { + final users = List.generate(count, (index) { + index = count + offset; + return User(id: 'testUserId$index'); + }); + final messages = List.generate( + count, + (index) { + index = index + offset; + return Message( + id: 'testMessageId$index', + type: 'testType', + user: users[index], + createdAt: DateTime.now(), + shadowed: false, + replyCount: index, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #$index', + pinned: true, + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId$index'), + ); + }, + ); + final threadMessages = List.generate( + count, + (index) { + index = index + offset; + return Message( + id: 'testThreadMessageId$index', + type: 'testType', + user: users[index], + parentId: messages[0].id, + createdAt: DateTime.now(), + shadowed: false, + replyCount: index, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #$index', + pinned: true, + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId$index'), + ); + }, + ); + return threads ? threadMessages : messages; + } + + test( + 'should throw assertion error in case messageListBuilder is null', + () { + final messageListCore = () => MessageListCore( + messageListBuilder: null, + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorWidgetBuilder: (BuildContext context, Object error) => + Offstage(), + ); + expect(messageListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case loadingBuilder is null', + () { + final messageListCore = () => MessageListCore( + messageListBuilder: (_, __) => Offstage(), + loadingBuilder: null, + emptyBuilder: (BuildContext context) => Offstage(), + errorWidgetBuilder: (BuildContext context, Object error) => + Offstage(), + ); + expect(messageListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case emptyBuilder is null', + () { + final messageListCore = () => MessageListCore( + messageListBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: null, + errorWidgetBuilder: (BuildContext context, Object error) => + Offstage(), + ); + expect(messageListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case errorWidgetBuilder is null', + () { + final messageListCore = () => MessageListCore( + messageListBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorWidgetBuilder: null, + ); + expect(messageListCore, throwsA(isA())); + }, + ); + + testWidgets( + 'should throw if MessageListCore is used where StreamChannel is not present ' + 'in the widget tree', + (tester) async { + const messageListCoreKey = Key('messageListCore'); + final messageListCore = MessageListCore( + key: messageListCoreKey, + messageListBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + ); + + await tester.pumpWidget(messageListCore); + + expect(find.byKey(messageListCoreKey), findsNothing); + expect(tester.takeException(), isInstanceOf()); + }, + ); + + testWidgets( + 'should render MessageListCore if used with StreamChannel as an ancestor', + (tester) async { + const messageListCoreKey = Key('messageListCore'); + final messageListCore = MessageListCore( + key: messageListCoreKey, + messageListBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockChannel = MockChannel(); + + when(mockChannel.state.isUpToDate).thenReturn(true); + + await tester.pumpWidget( + StreamChannel( + channel: mockChannel, + child: messageListCore, + ), + ); + + expect(find.byKey(messageListCoreKey), findsOneWidget); + }, + ); + + testWidgets( + 'should assign paginateData callback to MessageListController if passed', + (tester) async { + const messageListCoreKey = Key('messageListCore'); + final controller = MessageListController(); + final messageListCore = MessageListCore( + key: messageListCoreKey, + messageListBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + messageListController: controller, + ); + + expect(controller.paginateData, isNull); + + final mockChannel = MockChannel(); + + when(mockChannel.state.isUpToDate).thenReturn(true); + + await tester.pumpWidget( + StreamChannel( + channel: mockChannel, + child: messageListCore, + ), + ); + + expect(find.byKey(messageListCoreKey), findsOneWidget); + expect(controller.paginateData, isNotNull); + }, + ); + + testWidgets( + 'should build error widget if messagesStream emits error', + (tester) async { + const messageListCoreKey = Key('messageListCore'); + const errorWidgetKey = Key('errorWidget'); + final messageListCore = MessageListCore( + key: messageListCoreKey, + messageListBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorWidgetBuilder: (BuildContext context, Object error) => Offstage( + key: errorWidgetKey, + ), + ); + + final mockChannel = MockChannel(); + + when(mockChannel.state.isUpToDate).thenReturn(true); + when(mockChannel.initialized).thenAnswer((_) async => true); + + const error = 'Error! Error! Error!'; + when(mockChannel.state.messagesStream) + .thenAnswer((_) => Stream.error(error)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChannel( + channel: mockChannel, + child: messageListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(errorWidgetKey), findsOneWidget); + }, + ); + + testWidgets( + 'should build empty widget if the channel is upToDate and ' + 'messagesStream emits empty data', + (tester) async { + const messageListCoreKey = Key('messageListCore'); + const emptyWidgetKey = Key('emptyWidget'); + final messageListCore = MessageListCore( + key: messageListCoreKey, + messageListBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(key: emptyWidgetKey), + errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockChannel = MockChannel(); + + when(mockChannel.state.isUpToDate).thenReturn(true); + when(mockChannel.initialized).thenAnswer((_) async => true); + + const messages = []; + when(mockChannel.state.messagesStream) + .thenAnswer((_) => Stream.value(messages)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChannel( + channel: mockChannel, + child: messageListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(emptyWidgetKey), findsOneWidget); + }, + ); + + testWidgets( + 'should build list widget if the channel is not upToDate and ' + 'messagesStream emits empty data', + (tester) async { + const messageListCoreKey = Key('messageListCore'); + const listWidgetKey = Key('listWidget'); + final messageListCore = MessageListCore( + key: messageListCoreKey, + messageListBuilder: (_, __) => Offstage(key: listWidgetKey), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockChannel = MockChannel(); + + when(mockChannel.state.isUpToDate).thenReturn(false); + when(mockChannel.initialized).thenAnswer((_) async => true); + + const messages = []; + when(mockChannel.state.messagesStream) + .thenAnswer((_) => Stream.value(messages)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChannel( + channel: mockChannel, + child: messageListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + }, + ); + + testWidgets( + 'should build list widget and save it for future rebuilds ' + 'if messagesStream emits some data', + (tester) async { + const messageListCoreKey = Key('messageListCore'); + const listWidgetKey = Key('listWidget'); + final messageListCore = MessageListCore( + key: messageListCoreKey, + messageListBuilder: (_, messages) => Container( + key: listWidgetKey, + child: Text( + messages.reversed.map((it) => it.id).join(','), + ), + ), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockChannel = MockChannel(); + + when(mockChannel.state.isUpToDate).thenReturn(true); + when(mockChannel.initialized).thenAnswer((_) async => true); + + final messages = _generateMessages(); + when(mockChannel.state.messagesStream) + .thenAnswer((_) => Stream.value(messages)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChannel( + channel: mockChannel, + child: messageListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + expect(find.text(messages.map((it) => it.id).join(',')), findsOneWidget); + }, + ); + + testWidgets( + 'should build list widget with thread messages if parentMessage is passed', + (tester) async { + final messages = _generateMessages(threads: true); + final parentMessage = Message(id: messages.first.parentId); + const messageListCoreKey = Key('messageListCore'); + const listWidgetKey = Key('listWidget'); + final messageListCore = MessageListCore( + key: messageListCoreKey, + messageListBuilder: (_, messages) => Container( + key: listWidgetKey, + child: Text( + messages.reversed.map((it) => '${it.parentId}-${it.id}').join(','), + ), + ), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorWidgetBuilder: (BuildContext context, Object error) => Offstage(), + parentMessage: parentMessage, + ); + + final mockChannel = MockChannel(); + + when(mockChannel.state.isUpToDate).thenReturn(true); + when(mockChannel.initialized).thenAnswer((_) async => true); + + final threads = {parentMessage.id: messages}; + + when(mockChannel.state.threads).thenReturn(threads); + when(mockChannel.state.threadsStream) + .thenAnswer((_) => Stream.value(threads)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChannel( + channel: mockChannel, + child: messageListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + expect( + find.text(messages.map((it) => '${it.parentId}-${it.id}').join(',')), + findsOneWidget, + ); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart b/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart new file mode 100644 index 00000000..e639c03b --- /dev/null +++ b/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart @@ -0,0 +1,344 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/message_search_bloc.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'matchers/get_message_response_matcher.dart'; +import 'mocks.dart'; + +void main() { + List _generateMessages({ + int count = 3, + int offset = 0, + }) { + return List.generate( + count, + (index) { + index = index + offset; + return GetMessageResponse() + ..message = Message( + id: 'testId$index', + text: 'testTextData$index', + ) + ..channel = ChannelModel( + cid: 'testCid', + ); + }, + ); + } + + test( + 'should throw assertion error if child is null', + () async { + const messageSearchBlocKey = Key('messageSearchBloc'); + final messageSearchBloc = () => MessageSearchBloc( + key: messageSearchBlocKey, + child: null, + ); + expect(messageSearchBloc, throwsA(isA())); + }, + ); + + testWidgets( + 'messageSearchBlocState.search() should throw if used where ' + 'StreamChat is not present in the widget tree', + (tester) async { + const messageSearchBlocKey = Key('messageSearchBloc'); + const childKey = Key('child'); + final messageSearchBloc = MessageSearchBloc( + key: messageSearchBlocKey, + child: Offstage(key: childKey), + ); + + await tester.pumpWidget(messageSearchBloc); + + expect(find.byKey(messageSearchBlocKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + final usersBlocState = tester.state( + find.byKey(messageSearchBlocKey), + ); + + try { + await usersBlocState.search(); + } catch (e) { + expect(e, isInstanceOf()); + } + }, + ); + + testWidgets( + 'messageSearchBlocState.search() should emit data through usersStream', + (tester) async { + const messageSearchBlocKey = Key('messageSearchBloc'); + const childKey = Key('child'); + final messageSearchBloc = MessageSearchBloc( + key: messageSearchBlocKey, + child: Offstage(key: childKey), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: messageSearchBloc, + ), + ); + final messageSearchBlocState = tester.state( + find.byKey(messageSearchBlocKey), + ); + + final messageResponseList = _generateMessages(); + + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = messageResponseList, + ); + + messageSearchBlocState.search(); + + await expectLater( + messageSearchBlocState.messagesStream, + emits(isSameMessageResponseListAs(messageResponseList)), + ); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + }, + ); + + testWidgets( + 'messageSearchBlocState.messagesStream should emit error ' + 'if client.search() throws', + (tester) async { + const messageSearchBlocKey = Key('messageSearchBloc'); + const childKey = Key('child'); + final messageSearchBloc = MessageSearchBloc( + key: messageSearchBlocKey, + child: Offstage(key: childKey), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: messageSearchBloc, + ), + ); + final messageSearchBlocState = tester.state( + find.byKey(messageSearchBlocKey), + ); + + const error = 'Error! Error! Error!'; + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).thenThrow(error); + + messageSearchBlocState.search(); + + await expectLater( + messageSearchBlocState.messagesStream, + emitsError(error), + ); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + }, + ); + + testWidgets( + 'calling messageSearchBlocState.search() again with an offset ' + 'should emit new data through messagesStream and also emit loading state ' + 'through queryMessagesLoading', + (tester) async { + const messageSearchBlocKey = Key('messageSearchBloc'); + const childKey = Key('child'); + final messageSearchBloc = MessageSearchBloc( + key: messageSearchBlocKey, + child: Offstage(key: childKey), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: messageSearchBloc, + ), + ); + + final messageSearchBlocState = tester.state( + find.byKey(messageSearchBlocKey), + ); + + final messageResponseList = _generateMessages(); + + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = messageResponseList, + ); + + messageSearchBlocState.search(); + + await expectLater( + messageSearchBlocState.messagesStream, + emits(isSameMessageResponseListAs(messageResponseList)), + ); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + + final offset = messageResponseList.length; + final paginatedMessageResponseList = _generateMessages(offset: offset); + final pagination = PaginationParams(offset: offset); + + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: pagination, + )).thenAnswer( + (_) async => + SearchMessagesResponse()..results = paginatedMessageResponseList, + ); + + messageSearchBlocState.search(pagination: pagination); + + await Future.wait([ + expectLater( + messageSearchBlocState.queryMessagesLoading, + emitsInOrder([true, false]), + ), + expectLater( + messageSearchBlocState.messagesStream, + emits(isSameMessageResponseListAs( + messageResponseList + paginatedMessageResponseList, + )), + ), + ]); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: pagination, + )).called(1); + }, + ); + + testWidgets( + 'calling messageSearchBlocState.search() again with an offset ' + 'should emit error through queryUsersLoading if ' + 'client.search() throws', + (tester) async { + const messageSearchBlocKey = Key('messageSearchBloc'); + const childKey = Key('child'); + final messageSearchBloc = MessageSearchBloc( + key: messageSearchBlocKey, + child: Offstage(key: childKey), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: messageSearchBloc, + ), + ); + + final messageSearchBlocState = tester.state( + find.byKey(messageSearchBlocKey), + ); + + final messageResponseList = _generateMessages(); + + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = messageResponseList, + ); + + messageSearchBlocState.search(); + + await expectLater( + messageSearchBlocState.messagesStream, + emits(isSameMessageResponseListAs(messageResponseList)), + ); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + + final offset = messageResponseList.length; + final pagination = PaginationParams(offset: offset); + + const error = 'Error! Error! Error!'; + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: pagination, + )).thenThrow(error); + + messageSearchBlocState.search(pagination: pagination); + + await expectLater( + messageSearchBlocState.queryMessagesLoading, + emitsError(error), + ); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: pagination, + )).called(1); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart new file mode 100644 index 00000000..aa4a47f9 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/message_search_list_core_test.dart @@ -0,0 +1,535 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter_core/src/message_search_list_core.dart'; + +import 'mocks.dart'; + +void main() { + List _generateMessages({ + int count = 3, + int offset = 0, + }) { + return List.generate( + count, + (index) { + index = index + offset; + return GetMessageResponse() + ..message = Message( + id: 'testId$index', + text: 'testTextData$index', + ) + ..channel = ChannelModel( + cid: 'testCid', + ); + }, + ); + } + + test( + 'should throw assertion error in case childBuilder is null', + () { + final messageSearchListCore = () => MessageSearchListCore( + childBuilder: null, + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + expect(messageSearchListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case loadingBuilder is null', + () { + final messageSearchListCore = () => MessageSearchListCore( + childBuilder: (List messages) => Offstage(), + loadingBuilder: null, + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + expect(messageSearchListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case emptyBuilder is null', + () { + final messageSearchListCore = () => MessageSearchListCore( + childBuilder: (List messages) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: null, + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + expect(messageSearchListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case errorBuilder is null', + () { + final messageSearchListCore = () => MessageSearchListCore( + childBuilder: (List messages) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: null, + ); + expect(messageSearchListCore, throwsA(isA())); + }, + ); + + testWidgets( + 'should throw if MessageSearchListCore is used where MessageSearchBloc ' + 'is not present in the widget tree', + (tester) async { + const messageSearchListCoreKey = Key('messageSearchListCore'); + final messageSearchListCore = MessageSearchListCore( + key: messageSearchListCoreKey, + childBuilder: (List messages) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + + await tester.pumpWidget(messageSearchListCore); + + expect(find.byKey(messageSearchListCoreKey), findsNothing); + expect(tester.takeException(), isInstanceOf()); + }, + ); + + testWidgets( + 'should render MessageSearchListCore if used with ' + 'MessageSearchListCore as an ancestor', + (tester) async { + const messageSearchListCoreKey = Key('messageSearchListCore'); + final messageSearchListCore = MessageSearchListCore( + key: messageSearchListCoreKey, + childBuilder: (List messages) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: MessageSearchBloc( + child: messageSearchListCore, + ), + ), + ); + + expect(find.byKey(messageSearchListCoreKey), findsOneWidget); + }, + ); + + testWidgets( + 'should assign loadData and paginateData callback to ' + 'UserListController if passed', + (tester) async { + const messageSearchListCoreKey = Key('messageSearchListCore'); + final controller = MessageSearchListController(); + final messageSearchListCore = MessageSearchListCore( + key: messageSearchListCoreKey, + childBuilder: (List messages) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + messageSearchListController: controller, + ); + + expect(controller.loadData, isNull); + expect(controller.paginateData, isNull); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: MessageSearchBloc( + child: messageSearchListCore, + ), + ), + ); + + expect(find.byKey(messageSearchListCoreKey), findsOneWidget); + expect(controller.loadData, isNotNull); + expect(controller.paginateData, isNotNull); + }, + ); + + testWidgets( + 'should build error widget if messageSearchBloc.messagesStream emits error', + (tester) async { + const messageSearchListCoreKey = Key('messageSearchListCore'); + const errorWidgetKey = Key('errorWidget'); + final messageSearchListCore = MessageSearchListCore( + key: messageSearchListCoreKey, + childBuilder: (List messages) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage( + key: errorWidgetKey, + ), + ); + + final mockClient = MockClient(); + + const error = 'Error! Error! Error!'; + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).thenThrow(error); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: MessageSearchBloc( + child: messageSearchListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(errorWidgetKey), findsOneWidget); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + }, + ); + + testWidgets( + 'should build empty widget if messageSearchBloc.messagesStream ' + 'emits empty data', + (tester) async { + const messageSearchListCoreKey = Key('messageSearchListCore'); + const emptyWidgetKey = Key('emptyWidget'); + final messageSearchListCore = MessageSearchListCore( + key: messageSearchListCoreKey, + childBuilder: (List messages) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(key: emptyWidgetKey), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockClient = MockClient(); + + final messageResponseList = []; + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = messageResponseList, + ); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: MessageSearchBloc( + child: messageSearchListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(emptyWidgetKey), findsOneWidget); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + }, + ); + + testWidgets( + 'should build child widget if usersBlocState.usersStream emits some data', + (tester) async { + const messageSearchListCoreKey = Key('messageSearchListCore'); + const childWidgetKey = Key('childWidget'); + final messageSearchListCore = MessageSearchListCore( + key: messageSearchListCoreKey, + childBuilder: (List messages) => Offstage( + key: childWidgetKey, + ), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + ); + + final mockClient = MockClient(); + + final messageResponseList = _generateMessages(); + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = messageResponseList, + ); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: MessageSearchBloc( + child: messageSearchListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(childWidgetKey), findsOneWidget); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: anyNamed('paginationParams'), + )).called(1); + }, + ); + + testWidgets( + 'should build child widget with paginated data ' + 'on calling channelListCoreState.paginateData', + (tester) async { + const messageSearchListCoreKey = Key('messageSearchListCore'); + const childWidgetKey = Key('childWidget'); + const pagination = PaginationParams(); + final messageSearchListCore = MessageSearchListCore( + key: messageSearchListCoreKey, + childBuilder: (List messages) => Container( + key: childWidgetKey, + child: Text( + messages.map((e) => '${e.channel.cid}-${e.message.id}').join(','), + ), + ), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + paginationParams: pagination, + ); + + final mockClient = MockClient(); + + final messageResponseList = _generateMessages(); + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: pagination, + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = messageResponseList, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChatCore( + client: mockClient, + child: MessageSearchBloc( + child: messageSearchListCore, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(childWidgetKey), findsOneWidget); + expect( + find.text( + messageResponseList + .map((e) => '${e.channel.cid}-${e.message.id}') + .join(','), + ), + findsOneWidget, + ); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: pagination, + )).called(1); + + final messageSearchListCoreState = + tester.state( + find.byKey(messageSearchListCoreKey), + ); + + final offset = messageResponseList.length; + final paginatedMessageResponseList = _generateMessages(offset: offset); + final updatedPagination = pagination.copyWith(offset: offset); + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: updatedPagination, + )).thenAnswer( + (_) async => + SearchMessagesResponse()..results = paginatedMessageResponseList, + ); + + await messageSearchListCoreState.paginateData(); + + await tester.pumpAndSettle(); + + expect(find.byKey(childWidgetKey), findsOneWidget); + expect( + find.text([ + ...messageResponseList, + ...paginatedMessageResponseList, + ].map((e) => '${e.channel.cid}-${e.message.id}').join(',')), + findsOneWidget, + ); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: updatedPagination, + )).called(1); + }, + ); + + testWidgets( + 'should rebuild MessageSearchListCore with updated widget data ' + 'on calling setState()', + (tester) async { + const pagination = PaginationParams(); + + StateSetter _stateSetter; + int limit = pagination.limit; + + const messageSearchListCoreKey = Key('messageSearchListCore'); + const childWidgetKey = Key('childWidget'); + MessageSearchListCore messageSearchListCoreBuilder(int limit) => + MessageSearchListCore( + key: messageSearchListCoreKey, + childBuilder: (List messages) => Container( + key: childWidgetKey, + child: Text( + messages + .map((e) => '${e.channel.cid}-${e.message.id}') + .join(','), + ), + ), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (BuildContext context, Object error) => Offstage(), + paginationParams: pagination.copyWith(limit: limit), + ); + + final mockClient = MockClient(); + + final messageResponseList = _generateMessages(); + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: pagination, + )).thenAnswer( + (_) async => SearchMessagesResponse()..results = messageResponseList, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChatCore( + client: mockClient, + child: MessageSearchBloc( + child: StatefulBuilder(builder: (context, stateSetter) { + // Assigning stateSetter for rebuilding UserListCore + _stateSetter = stateSetter; + return messageSearchListCoreBuilder(limit); + }), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(childWidgetKey), findsOneWidget); + expect( + find.text( + messageResponseList + .map((e) => '${e.channel.cid}-${e.message.id}') + .join(','), + ), + findsOneWidget, + ); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: pagination, + )).called(1); + + // Rebuilding MessageSearchListCore with new pagination limit + _stateSetter(() => limit = 6); + + final updatedMessageResponseList = _generateMessages(count: limit); + final updatedPagination = pagination.copyWith(limit: limit); + when(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: updatedPagination, + )).thenAnswer( + (_) async => + SearchMessagesResponse()..results = updatedMessageResponseList, + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(childWidgetKey), findsOneWidget); + expect( + find.text(updatedMessageResponseList + .map((e) => '${e.channel.cid}-${e.message.id}') + .join(',')), + findsOneWidget, + ); + + verify(mockClient.search( + any, + query: anyNamed('query'), + sort: anyNamed('sort'), + messageFilters: anyNamed('messageFilters'), + paginationParams: updatedPagination, + )).called(1); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/mocks.dart b/packages/stream_chat_flutter_core/test/mocks.dart index c23a4be4..a46b7b5c 100644 --- a/packages/stream_chat_flutter_core/test/mocks.dart +++ b/packages/stream_chat_flutter_core/test/mocks.dart @@ -1,10 +1,39 @@ import 'package:mockito/mockito.dart'; import 'package:stream_chat/stream_chat.dart'; -class MockClient extends Mock implements StreamChatClient {} +class MockLogger extends Mock implements Logger {} -class MockClientState extends Mock implements ClientState {} +class MockClient extends Mock implements StreamChatClient { + final Logger logger = MockLogger(); -class MockChannel extends Mock implements Channel {} + ClientState _state; + + @override + ClientState get state => _state ??= MockClientState(); +} + +class MockClientState extends Mock implements ClientState { + OwnUser _user; + + @override + OwnUser get user => _user ??= OwnUser( + id: 'testUserId', + role: 'admin', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); +} + +class MockChannel extends Mock implements Channel { + ChannelClientState _state; + + @override + ChannelClientState get state => _state ??= MockChannelState(); + + StreamChatClient _client; + + @override + StreamChatClient get client => _client ??= MockClient(); +} class MockChannelState extends Mock implements ChannelClientState {} diff --git a/packages/stream_chat_flutter_core/test/stream_channel_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_test.dart new file mode 100644 index 00000000..4387a918 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_channel_test.dart @@ -0,0 +1,343 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'mocks.dart'; + +void main() { + List _generateMessages({ + int count = 3, + int offset = 0, + bool threads = false, + }) { + final users = List.generate(count, (index) { + index = count + offset; + return User(id: 'testUserId$index'); + }); + final messages = List.generate( + count, + (index) { + index = index + offset; + return Message( + id: 'testMessageId$index', + type: 'testType', + user: users[index], + createdAt: DateTime.now(), + shadowed: false, + replyCount: index, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #$index', + pinned: true, + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId$index'), + ); + }, + ); + final threadMessages = List.generate( + count, + (index) { + index = index + offset; + return Message( + id: 'testThreadMessageId$index', + type: 'testType', + user: users[index], + parentId: messages[0].id, + createdAt: DateTime.now(), + shadowed: false, + replyCount: index, + updatedAt: DateTime.now(), + extraData: {'extra_test_field': 'extraTestData'}, + text: 'Dummy text #$index', + pinned: true, + pinnedAt: DateTime.now(), + pinnedBy: User(id: 'testUserId$index'), + ); + }, + ); + return threads ? threadMessages : messages; + } + + test( + 'should throw assertion error if child is null', + () async { + final mockChannel = MockChannel(); + const streamChannelKey = Key('streamChannel'); + final streamChannel = () => StreamChannel( + key: streamChannelKey, + channel: mockChannel, + child: null, + ); + expect(streamChannel, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error if channel is null', + () async { + const streamChannelKey = Key('streamChannel'); + final streamChannel = () => StreamChannel( + key: streamChannelKey, + child: Offstage(), + channel: null, + ); + expect(streamChannel, throwsA(isA())); + }, + ); + + testWidgets( + 'should render StreamChannel if both channel and child is provided', + (tester) async { + final mockChannel = MockChannel(); + const streamChannelKey = Key('streamChannel'); + const childKey = Key('childKey'); + final streamChannel = StreamChannel( + key: streamChannelKey, + channel: mockChannel, + child: Offstage(key: childKey), + ); + + await tester.pumpWidget(streamChannel); + + expect(find.byKey(streamChannelKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + }, + ); + + testWidgets( + 'should build error widget if channel.initialized throws', + (tester) async { + final mockChannel = MockChannel(); + const streamChannelKey = Key('streamChannel'); + const childKey = Key('childKey'); + final streamChannel = StreamChannel( + key: streamChannelKey, + channel: mockChannel, + child: Offstage(key: childKey), + ); + + final errorMessage = 'Error! Error! Error!'; + final error = DioError(type: DioErrorType.RESPONSE, error: errorMessage); + when(mockChannel.initialized).thenAnswer((_) => Future.error(error)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: streamChannel, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text(errorMessage), findsOneWidget); + + verify(mockChannel.initialized).called(1); + }, + ); + + testWidgets( + 'should show circular progress indicator showLoading is true ' + 'and channel is still not initialized', + (tester) async { + final mockChannel = MockChannel(); + const streamChannelKey = Key('streamChannel'); + const childKey = Key('childKey'); + final streamChannel = StreamChannel( + key: streamChannelKey, + channel: mockChannel, + child: Offstage(key: childKey), + showLoading: true, + ); + + when(mockChannel.initialized).thenAnswer((_) async => false); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: streamChannel, + ), + ); + + await tester.pump(const Duration(seconds: 1)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + verify(mockChannel.initialized).called(1); + }, + ); + + testWidgets( + 'should preload extra messages if initialMessageId is provided', + (tester) async { + final mockChannel = MockChannel(); + const streamChannelKey = Key('streamChannel'); + const childKey = Key('childKey'); + final streamChannel = StreamChannel( + key: streamChannelKey, + channel: mockChannel, + child: Offstage(key: childKey), + initialMessageId: 'testInitialMessageId', + ); + + when(mockChannel.initialized).thenAnswer((_) async => true); + final messages = _generateMessages(); + when(mockChannel.query( + options: anyNamed('options'), + messagesPagination: anyNamed('messagesPagination'), + membersPagination: anyNamed('membersPagination'), + watchersPagination: anyNamed('watchersPagination'), + preferOffline: anyNamed('preferOffline'), + )).thenAnswer((_) async => ChannelState(messages: messages)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: streamChannel, + ), + ); + + await tester.pumpAndSettle(); + + verify(mockChannel.initialized).called(1); + verify(mockChannel.query( + options: anyNamed('options'), + messagesPagination: anyNamed('messagesPagination'), + membersPagination: anyNamed('membersPagination'), + watchersPagination: anyNamed('watchersPagination'), + preferOffline: anyNamed('preferOffline'), + )).called( + 2, // Fetching After messages + Fetching Before messages, + ); + }, + ); + + testWidgets( + 'should rebuild StreamChannel with updated widget data ' + 'on calling setState()', + (tester) async { + StateSetter _stateSetter; + + var initialMessageId = 'testInitialMessageId'; + + final mockChannel = MockChannel(); + const streamChannelKey = Key('streamChannel'); + const childKey = Key('childKey'); + StreamChannel streamChannelBuilder(String initialMessageId) => + StreamChannel( + key: streamChannelKey, + channel: mockChannel, + child: Offstage(key: childKey), + initialMessageId: initialMessageId, + ); + + final beforePagination = PaginationParams( + lessThan: initialMessageId, + limit: 20, + ); + + final afterPagination = PaginationParams( + greaterThanOrEqual: initialMessageId, + limit: 20, + ); + + when(mockChannel.initialized).thenAnswer((_) async => true); + + final messages = _generateMessages(); + + when(mockChannel.query( + options: anyNamed('options'), + messagesPagination: beforePagination, + membersPagination: anyNamed('membersPagination'), + watchersPagination: anyNamed('watchersPagination'), + preferOffline: anyNamed('preferOffline'), + )).thenAnswer((_) async => ChannelState(messages: messages)); + + when(mockChannel.query( + options: anyNamed('options'), + messagesPagination: afterPagination, + membersPagination: anyNamed('membersPagination'), + watchersPagination: anyNamed('watchersPagination'), + preferOffline: anyNamed('preferOffline'), + )).thenAnswer((_) async => ChannelState(messages: messages)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (context, stateSetter) { + // Assigning stateSetter for rebuilding StreamChannel + _stateSetter = stateSetter; + return streamChannelBuilder(initialMessageId); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + verify(mockChannel.query( + options: anyNamed('options'), + messagesPagination: beforePagination, + membersPagination: anyNamed('membersPagination'), + watchersPagination: anyNamed('watchersPagination'), + preferOffline: anyNamed('preferOffline'), + )).called(1); + + verify(mockChannel.query( + options: anyNamed('options'), + messagesPagination: afterPagination, + membersPagination: anyNamed('membersPagination'), + watchersPagination: anyNamed('watchersPagination'), + preferOffline: anyNamed('preferOffline'), + )).called(1); + + _stateSetter(() => initialMessageId = 'testInitialMessageId2'); + + final updatedBeforePagination = beforePagination.copyWith( + lessThan: initialMessageId, + ); + + final updatedAfterPagination = afterPagination.copyWith( + greaterThanOrEqual: initialMessageId, + ); + + when(mockChannel.query( + options: anyNamed('options'), + messagesPagination: updatedBeforePagination, + membersPagination: anyNamed('membersPagination'), + watchersPagination: anyNamed('watchersPagination'), + preferOffline: anyNamed('preferOffline'), + )).thenAnswer((_) async => ChannelState(messages: messages)); + + when(mockChannel.query( + options: anyNamed('options'), + messagesPagination: updatedAfterPagination, + membersPagination: anyNamed('membersPagination'), + watchersPagination: anyNamed('watchersPagination'), + preferOffline: anyNamed('preferOffline'), + )).thenAnswer((_) async => ChannelState(messages: messages)); + + await tester.pumpAndSettle(); + + verify(mockChannel.query( + options: anyNamed('options'), + messagesPagination: updatedBeforePagination, + membersPagination: anyNamed('membersPagination'), + watchersPagination: anyNamed('watchersPagination'), + preferOffline: anyNamed('preferOffline'), + )).called(1); + + verify(mockChannel.query( + options: anyNamed('options'), + messagesPagination: updatedAfterPagination, + membersPagination: anyNamed('membersPagination'), + watchersPagination: anyNamed('watchersPagination'), + preferOffline: anyNamed('preferOffline'), + )).called(1); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart index 52860c3e..4adc0062 100644 --- a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:fake_async/fake_async.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; @@ -8,169 +7,283 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'mocks.dart'; -class MockShowLocalNotifications extends Mock { +class MockOnBackgroundEventReceived extends Mock { void call(Event event); } void main() { - testWidgets( - 'StreamChatCore.of(context) should throw an exception', - (WidgetTester tester) async { - await tester.pumpWidget( - Builder( - builder: (context) { - expect(() => StreamChatCore.of(context), throwsException); - return Container(); - }, - ), - ); + test( + 'should throw assertion error in case client is null', + () { + final streamChatCore = () => StreamChatCore( + client: null, + child: Offstage(), + ); + expect(streamChatCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case child is null', + () { + final mockClient = MockClient(); + final streamChatCore = () => StreamChatCore( + client: mockClient, + child: null, + ); + expect(streamChatCore, throwsA(isA())); }, ); testWidgets( - 'StreamChatCore.of(context) should return the StreamChatCore ancestor', - (WidgetTester tester) async { - final client = MockClient(); - await tester.pumpWidget( - StreamChatCore( - client: client, - child: Builder( - builder: (context) { - final sc = StreamChatCore.of(context); - expect(sc, isNotNull); - return Container(); - }, - ), - ), + 'should render StreamChatCore if both client and child is provided', + (tester) async { + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + child: Offstage(key: childKey), ); + + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); }, ); testWidgets( - 'StreamChatCore.of(context).client should return the client', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn( - OwnUser( - id: 'test', - ), - ); - final userStream = Stream.value( - OwnUser( - id: 'test', - ), - ); - when(clientState.userStream).thenAnswer( - (_) => userStream, + 'should render StreamChatCore if both client and child is provided', + (tester) async { + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + child: Offstage(key: childKey), ); - await tester.pumpWidget( - StreamChatCore( - client: client, - child: Builder( - builder: (context) { - final sc = StreamChatCore.of(context); - expect(sc.client, client); - expect(sc.user, client.state.user); - expect(sc.userStream, userStream); - return Container(); - }, - ), - ), - ); + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); }, ); testWidgets( - 'StreamChatCore should disconnect on background', - (WidgetTester tester) async { - fakeAsync((_async) { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn( - OwnUser( - id: 'test', - ), - ); - final showLocalNotificationMock = MockShowLocalNotifications().call; - final eventStreamController = StreamController(); - when(client.on()).thenAnswer((_) => eventStreamController.stream); + 'didChangeAppLifecycleState should call client.disconnect() and return ' + 'if onBackgroundEventReceived is null and the widget lifestyle changes to ' + 'AppLifecycleState.paused', + (tester) async { + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + child: Offstage(key: childKey), + ); - when(client.channel('test', id: 'testid')).thenReturn(channel); + await tester.pumpWidget(streamChatCore); - final scKey = GlobalKey(); - tester.pumpWidget( - StreamChatCore( - key: scKey, - client: client, - onBackgroundEventReceived: showLocalNotificationMock, - backgroundKeepAlive: const Duration(seconds: 4), - child: Builder( - builder: (context) => Container(), - ), - ), + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + when(mockClient.disconnect()).thenAnswer((_) async { + return; + }); + + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); + + streamChatCoreState.didChangeAppLifecycleState(AppLifecycleState.paused); + + verify(mockClient.disconnect()).called(1); + }, + ); + + testWidgets( + 'didChangeAppLifecycleState should subscribe to client events for ' + 'backgroundKeepAlive duration if onBackgroundEventReceived is provided ' + 'and the widget lifestyle changes to AppLifecycleState.paused', + (tester) async { + await tester.runAsync(() async { + final mockClient = MockClient(); + final mockOnBackgroundEventReceived = MockOnBackgroundEventReceived(); + const backgroundKeepAlive = const Duration(seconds: 3); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + child: Offstage(key: childKey), + onBackgroundEventReceived: mockOnBackgroundEventReceived, + backgroundKeepAlive: backgroundKeepAlive, ); - scKey.currentState.didChangeAppLifecycleState(AppLifecycleState.paused); + await tester.pumpWidget(streamChatCore); - _async.elapse(const Duration(seconds: 5)); + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); - verify(client.disconnect()).called(1); - eventStreamController.close(); + final event = Event(); + when(mockClient.on()).thenAnswer((_) => Stream.value(event)); + when(mockClient.disconnect()).thenAnswer((_) async { + return; + }); + + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); + + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.paused); + + await untilCalled(mockOnBackgroundEventReceived.call(event)); + + verify(mockOnBackgroundEventReceived.call(event)).called(1); + + await Future.delayed(backgroundKeepAlive); + + verify(mockClient.disconnect()).called(1); + verifyNever(mockOnBackgroundEventReceived.call(event)); }); }, ); testWidgets( - 'StreamChatCore should handle notifications when on background and ' - 'connected', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - when(client.state).thenReturn(clientState); - when(clientState.user).thenReturn( - OwnUser( - id: 'test', - ), - ); - final showLocalNotificationMock = MockShowLocalNotifications().call; - final eventStreamController = StreamController(); - when(client.on()).thenAnswer((_) => eventStreamController.stream); + 'didChangeAppLifecycleState should cancel the backgroundKeepAlive timer ' + 'if it is currently running in case the widget lifestyle changes to ' + 'AppLifecycleState.resume', + (tester) async { + await tester.runAsync(() async { + final mockClient = MockClient(); + final mockOnBackgroundEventReceived = MockOnBackgroundEventReceived(); + const backgroundKeepAlive = const Duration(seconds: 3); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + child: Offstage(key: childKey), + onBackgroundEventReceived: mockOnBackgroundEventReceived, + backgroundKeepAlive: backgroundKeepAlive, + ); - when(client.channel('test', id: 'testid')).thenReturn(channel); + await tester.pumpWidget(streamChatCore); - final scKey = GlobalKey(); - await tester.pumpWidget( - StreamChatCore( - key: scKey, - client: client, - onBackgroundEventReceived: showLocalNotificationMock, - backgroundKeepAlive: const Duration(seconds: 4), - child: Builder( - builder: (context) => Container(), - ), - ), - ); + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); - scKey.currentState.didChangeAppLifecycleState(AppLifecycleState.paused); - final event = Event( - type: EventType.messageNew, - message: Message(text: 'hey'), - channelType: 'test', - channelId: 'testid', - user: User(id: 'other user'), - ); - eventStreamController.add(event); + final event = Event(); + when(mockClient.on()).thenAnswer((_) => Stream.value(event)); - await untilCalled(showLocalNotificationMock(event)); + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); - verify(showLocalNotificationMock(event)).called(1); - eventStreamController.close(); + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.paused); + + await untilCalled(mockOnBackgroundEventReceived.call(event)); + + verify(mockOnBackgroundEventReceived.call(event)).called(1); + + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.resumed); + + verifyNever(mockOnBackgroundEventReceived.call(event)); + }); + }, + ); + + testWidgets( + 'didChangeAppLifecycleState should call client.connect() ' + 'if the connectionStatus is ConnectionStatus.disconnected in case the ' + 'widget lifestyle changes to AppLifecycleState.resume', + (tester) async { + await tester.runAsync(() async { + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + child: Offstage(key: childKey), + ); + + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + final event = Event(); + when(mockClient.on()).thenAnswer((_) => Stream.value(event)); + when(mockClient.connect()).thenAnswer((_) async => event); + when(mockClient.wsConnectionStatus) + .thenReturn(ConnectionStatus.disconnected); + + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); + + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.paused); + + await Future.delayed(const Duration(seconds: 1)); + + streamChatCoreState + .didChangeAppLifecycleState(AppLifecycleState.resumed); + + verify(mockClient.connect()).called(1); + }); + }, + ); + + testWidgets( + 'streamChatCoreState.userStream should emit all the user events ' + 'provided by client', + (tester) async { + await tester.runAsync(() async { + final userController = StreamController(); + + final mockClient = MockClient(); + const streamChatCoreKey = Key('streamChatCore'); + const childKey = Key('child'); + final streamChatCore = StreamChatCore( + key: streamChatCoreKey, + client: mockClient, + child: Offstage(key: childKey), + ); + + await tester.pumpWidget(streamChatCore); + + expect(find.byKey(streamChatCoreKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + when(mockClient.state.userStream) + .thenAnswer((_) => userController.stream); + + final streamChatCoreState = tester.state( + find.byKey(streamChatCoreKey), + ); + + final ownUser = OwnUser(id: 'testUserId'); + userController.add(ownUser); + + await expectLater( + streamChatCoreState.userStream, + emits(ownUser), + ); + + addTearDown(() { + userController.close(); + }); + }); }, ); } diff --git a/packages/stream_chat_flutter_core/test/user_list_core_test.dart b/packages/stream_chat_flutter_core/test/user_list_core_test.dart new file mode 100644 index 00000000..d1c7e83a --- /dev/null +++ b/packages/stream_chat_flutter_core/test/user_list_core_test.dart @@ -0,0 +1,572 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat_flutter_core/src/user_list_core.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'mocks.dart'; + +void main() { + const alphabets = 'abcdefghijklmnopqrstuvwxyz'; + + List _generateUsers({ + int count = 3, + int offset = 0, + }) { + return List.generate( + count, + (index) { + index = index + offset; + return User( + id: 'testId$index', + role: 'testRole$index', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + lastActive: DateTime.now(), + online: true, + banned: false, + extraData: { + 'name': '${alphabets[index]}-testName', + }, + ); + }, + ); + } + + test( + 'should throw assertion error in case listBuilder is null', + () { + final userListCore = () => UserListCore( + listBuilder: null, + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (Object error) => Offstage(), + ); + expect(userListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case loadingBuilder is null', + () { + final userListCore = () => UserListCore( + listBuilder: (_, __) => Offstage(), + loadingBuilder: null, + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (Object error) => Offstage(), + ); + expect(userListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case emptyBuilder is null', + () { + final userListCore = () => UserListCore( + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: null, + errorBuilder: (Object error) => Offstage(), + ); + expect(userListCore, throwsA(isA())); + }, + ); + + test( + 'should throw assertion error in case errorBuilder is null', + () { + final userListCore = () => UserListCore( + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: null, + ); + expect(userListCore, throwsA(isA())); + }, + ); + + testWidgets( + 'should throw if UserListCore is used where UsersBloc is not present ' + 'in the widget tree', + (tester) async { + const userListCoreKey = Key('userListCore'); + final userListCore = UserListCore( + key: userListCoreKey, + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (Object error) => Offstage(), + ); + + await tester.pumpWidget(userListCore); + + expect(find.byKey(userListCoreKey), findsNothing); + expect(tester.takeException(), isInstanceOf()); + }, + ); + + testWidgets( + 'should render UserListCore if used with UsersBloc as an ancestor', + (tester) async { + const userListCoreKey = Key('userListCore'); + final userListCore = UserListCore( + key: userListCoreKey, + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (Object error) => Offstage(), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: UsersBloc( + child: userListCore, + ), + ), + ); + + expect(find.byKey(userListCoreKey), findsOneWidget); + }, + ); + + testWidgets( + 'should assign loadData and paginateData callback to ' + 'UserListController if passed', + (tester) async { + const userListCoreKey = Key('userListCore'); + final controller = UserListController(); + final userListCore = UserListCore( + key: userListCoreKey, + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (Object error) => Offstage(), + userListController: controller, + ); + + expect(controller.loadData, isNull); + expect(controller.paginateData, isNull); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: UsersBloc( + child: userListCore, + ), + ), + ); + + expect(find.byKey(userListCoreKey), findsOneWidget); + expect(controller.loadData, isNotNull); + expect(controller.paginateData, isNotNull); + }, + ); + + testWidgets( + 'should build error widget if usersBlocState.usersStream emits error', + (tester) async { + const userListCoreKey = Key('userListCore'); + const errorWidgetKey = Key('errorWidget'); + final userListCore = UserListCore( + key: userListCoreKey, + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (Object error) => Container(key: errorWidgetKey), + ); + + final mockClient = MockClient(); + + const error = 'Error! Error! Error!'; + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).thenThrow(error); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: UsersBloc( + child: userListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(errorWidgetKey), findsOneWidget); + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).called(1); + }, + ); + + testWidgets( + 'should build empty widget if usersBlocState.usersStream emits empty data', + (tester) async { + const userListCoreKey = Key('userListCore'); + const emptyWidgetKey = Key('emptyWidget'); + final userListCore = UserListCore( + key: userListCoreKey, + listBuilder: (_, __) => Offstage(), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Container(key: emptyWidgetKey), + errorBuilder: (Object error) => Offstage(), + ); + + final mockClient = MockClient(); + + const users = []; + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: UsersBloc( + child: userListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(emptyWidgetKey), findsOneWidget); + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).called(1); + }, + ); + + testWidgets( + 'should build list widget if usersBlocState.usersStream emits some data', + (tester) async { + const userListCoreKey = Key('userListCore'); + const listWidgetKey = Key('listWidget'); + final userListCore = UserListCore( + key: userListCoreKey, + listBuilder: (_, __) => Container(key: listWidgetKey), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (Object error) => Offstage(), + ); + + final mockClient = MockClient(); + + final users = _generateUsers(); + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: UsersBloc( + child: userListCore, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).called(1); + }, + ); + + testWidgets( + 'should build list widget with grouped data if groupAlphabetically is true', + (tester) async { + const userListCoreKey = Key('userListCore'); + const listWidgetKey = Key('listWidget'); + final userListCore = UserListCore( + key: userListCoreKey, + listBuilder: (_, items) => Container( + key: listWidgetKey, + child: ListView( + children: items.map((e) { + return Container( + key: Key(e.key), + child: e.when( + headerItem: (heading) => Text(heading), + userItem: (user) => Text(user.id), + ), + ); + }).toList(growable: false), + ), + ), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (Object error) => Offstage(), + groupAlphabetically: true, + ); + + final mockClient = MockClient(); + + final users = _generateUsers(); + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChatCore( + client: mockClient, + child: UsersBloc( + child: userListCore, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + for (final user in users) { + expect(find.byKey(Key('HEADER-${user.name[0]}')), findsOneWidget); + expect(find.byKey(Key('USER-${user.id}')), findsOneWidget); + } + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).called(1); + }, + ); + + testWidgets( + 'should build list widget with paginated data ' + 'on calling channelListCoreState.paginateData', + (tester) async { + const userListCoreKey = Key('userListCore'); + const listWidgetKey = Key('listWidget'); + const pagination = PaginationParams(); + final userListCore = UserListCore( + key: userListCoreKey, + listBuilder: (_, items) => Container( + key: listWidgetKey, + child: ListView( + children: items.map((e) { + return Container( + key: Key(e.key), + child: e.when( + headerItem: (heading) => Text(heading), + userItem: (user) => Text(user.id), + ), + ); + }).toList(growable: false), + ), + ), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (Object error) => Offstage(), + pagination: pagination, + groupAlphabetically: true, + ); + + final mockClient = MockClient(); + + final users = _generateUsers(); + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChatCore( + client: mockClient, + child: UsersBloc( + child: userListCore, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + for (final user in users) { + expect(find.byKey(Key('HEADER-${user.name[0]}')), findsOneWidget); + expect(find.byKey(Key('USER-${user.id}')), findsOneWidget); + } + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).called(1); + + final userListCoreState = tester.state( + find.byKey(userListCoreKey), + ); + + final offset = users.length; + final paginatedUsers = _generateUsers(offset: offset); + final updatedPagination = pagination.copyWith(offset: offset); + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: updatedPagination, + )).thenAnswer((_) async => QueryUsersResponse()..users = paginatedUsers); + + await userListCoreState.paginateData(); + + await tester.pumpAndSettle(); + + for (final user in users + paginatedUsers) { + expect(find.byKey(Key('HEADER-${user.name[0]}')), findsOneWidget); + expect(find.byKey(Key('USER-${user.id}')), findsOneWidget); + } + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: updatedPagination, + )).called(1); + }, + ); + + testWidgets( + 'should rebuild UserListCore with updated widget data ' + 'on calling setState()', + (tester) async { + const pagination = PaginationParams(); + + StateSetter _stateSetter; + int limit = pagination.limit; + + const userListCoreKey = Key('userListCore'); + const listWidgetKey = Key('listWidget'); + UserListCore userListCoreBuilder(int limit) => UserListCore( + key: userListCoreKey, + listBuilder: (_, items) => Container( + key: listWidgetKey, + child: ListView( + children: items.map((e) { + return Container( + key: Key(e.key), + child: e.when( + headerItem: (heading) => Text(heading), + userItem: (user) => Text(user.id), + ), + ); + }).toList(growable: false), + ), + ), + loadingBuilder: (BuildContext context) => Offstage(), + emptyBuilder: (BuildContext context) => Offstage(), + errorBuilder: (Object error) => Offstage(), + pagination: pagination.copyWith(limit: limit), + groupAlphabetically: true, + ); + + final mockClient = MockClient(); + + final users = _generateUsers(); + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StreamChatCore( + client: mockClient, + child: UsersBloc( + child: StatefulBuilder(builder: (context, stateSetter) { + // Assigning stateSetter for rebuilding UserListCore + _stateSetter = stateSetter; + return userListCoreBuilder(limit); + }), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byKey(listWidgetKey), findsOneWidget); + for (final user in users) { + expect(find.byKey(Key('HEADER-${user.name[0]}')), findsOneWidget); + expect(find.byKey(Key('USER-${user.id}')), findsOneWidget); + } + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).called(1); + + // Rebuilding UserListCore with new pagination limit + _stateSetter(() => limit = 6); + + final updatedUsers = _generateUsers(count: limit); + final updatedPagination = pagination.copyWith(limit: limit); + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: updatedPagination, + )).thenAnswer((_) async => QueryUsersResponse()..users = updatedUsers); + + await tester.pumpAndSettle(); + + for (final user in updatedUsers) { + expect(find.byKey(Key('HEADER-${user.name[0]}')), findsOneWidget); + expect(find.byKey(Key('USER-${user.id}')), findsOneWidget); + } + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: updatedPagination, + )).called(1); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/test/users_bloc_test.dart b/packages/stream_chat_flutter_core/test/users_bloc_test.dart new file mode 100644 index 00000000..e9ccd2e7 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/users_bloc_test.dart @@ -0,0 +1,327 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/stream_chat_core.dart'; +import 'package:stream_chat_flutter_core/src/users_bloc.dart'; +import 'package:mockito/mockito.dart'; + +import 'matchers/users_matcher.dart'; +import 'mocks.dart'; + +void main() { + List _generateUsers({ + int count = 3, + int offset = 0, + }) { + return List.generate( + count, + (index) { + index = index + offset; + return User( + id: 'testId$index', + role: 'testRole$index', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + lastActive: DateTime.now(), + online: true, + banned: false, + extraData: {'extra_data_key': 'extraDataValue'}, + ); + }, + ); + } + + test( + 'should throw assertion error if child is null', + () async { + const usersBlocKey = Key('usersBloc'); + final usersBloc = () => UsersBloc( + key: usersBlocKey, + child: null, + ); + expect(usersBloc, throwsA(isA())); + }, + ); + + testWidgets( + 'usersBlocState.queryUsers() should throw if used where ' + 'StreamChat is not present in the widget tree', + (tester) async { + const usersBlocKey = Key('usersBloc'); + const childKey = Key('child'); + final usersBloc = UsersBloc( + key: usersBlocKey, + child: Offstage(key: childKey), + ); + + await tester.pumpWidget(usersBloc); + + expect(find.byKey(usersBlocKey), findsOneWidget); + expect(find.byKey(childKey), findsOneWidget); + + final usersBlocState = tester.state( + find.byKey(usersBlocKey), + ); + + try { + await usersBlocState.queryUsers(); + } catch (e) { + expect(e, isInstanceOf()); + } + }, + ); + + testWidgets( + 'usersBlocState.queryUsers() should emit data through usersStream', + (tester) async { + const usersBlocKey = Key('usersBloc'); + const childKey = Key('child'); + final usersBloc = UsersBloc( + key: usersBlocKey, + child: Offstage(key: childKey), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: usersBloc, + ), + ); + + final usersBlocState = tester.state( + find.byKey(usersBlocKey), + ); + + final users = _generateUsers(); + + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); + + usersBlocState.queryUsers(); + + await expectLater( + usersBlocState.usersStream, + emits(isSameUserListAs(users)), + ); + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).called(1); + }, + ); + + testWidgets( + 'usersBlocState.usersStream should emit error ' + 'if client.queryUsers() throws', + (tester) async { + const usersBlocKey = Key('usersBloc'); + const childKey = Key('child'); + final usersBloc = UsersBloc( + key: usersBlocKey, + child: Offstage(key: childKey), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: usersBloc, + ), + ); + + final usersBlocState = tester.state( + find.byKey(usersBlocKey), + ); + + final error = 'Error! Error! Error!'; + + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).thenThrow(error); + + usersBlocState.queryUsers(); + + await expectLater( + usersBlocState.usersStream, + emitsError(error), + ); + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).called(1); + }, + ); + + testWidgets( + 'calling usersBlocState.queryUsers() again with an offset ' + 'should emit new data through usersStream and also emit loading state ' + 'through queryUsersLoading', + (tester) async { + const usersBlocKey = Key('usersBloc'); + const childKey = Key('child'); + final usersBloc = UsersBloc( + key: usersBlocKey, + child: Offstage(key: childKey), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: usersBloc, + ), + ); + + final usersBlocState = tester.state( + find.byKey(usersBlocKey), + ); + + final users = _generateUsers(); + + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); + + usersBlocState.queryUsers(); + + await expectLater( + usersBlocState.usersStream, + emits(isSameUserListAs(users)), + ); + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).called(1); + + final offset = users.length; + final paginatedUsers = _generateUsers(offset: offset); + final pagination = PaginationParams(offset: offset); + + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: pagination, + )).thenAnswer((_) async => QueryUsersResponse()..users = paginatedUsers); + + usersBlocState.queryUsers(pagination: pagination); + + await Future.wait([ + expectLater( + usersBlocState.queryUsersLoading, + emitsInOrder([true, false]), + ), + expectLater( + usersBlocState.usersStream, + emits(isSameUserListAs(users + paginatedUsers)), + ), + ]); + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: pagination, + )).called(1); + }, + ); + + testWidgets( + 'calling usersBlocState.queryUsers() again with an offset ' + 'should emit error through queryUsersLoading if ' + 'client.queryUsers() throws', + (tester) async { + const usersBlocKey = Key('usersBloc'); + const childKey = Key('child'); + final usersBloc = UsersBloc( + key: usersBlocKey, + child: Offstage(key: childKey), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: usersBloc, + ), + ); + + final usersBlocState = tester.state( + find.byKey(usersBlocKey), + ); + + final users = _generateUsers(); + + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).thenAnswer((_) async => QueryUsersResponse()..users = users); + + usersBlocState.queryUsers(); + + await expectLater( + usersBlocState.usersStream, + emits(isSameUserListAs(users)), + ); + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: anyNamed('pagination'), + )).called(1); + + final offset = users.length; + final pagination = PaginationParams(offset: offset); + + final error = 'Error! Error! Error!'; + + when(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: pagination, + )).thenThrow(error); + + usersBlocState.queryUsers(pagination: pagination); + + await expectLater( + usersBlocState.queryUsersLoading, + emitsError(error), + ); + + verify(mockClient.queryUsers( + filter: anyNamed('filter'), + sort: anyNamed('sort'), + options: anyNamed('options'), + pagination: pagination, + )).called(1); + }, + ); +}