diff --git a/core/container/angel_container/.gitignore b/core/container/container/.gitignore similarity index 100% rename from core/container/angel_container/.gitignore rename to core/container/container/.gitignore diff --git a/core/container/angel_container/AUTHORS.md b/core/container/container/AUTHORS.md similarity index 100% rename from core/container/angel_container/AUTHORS.md rename to core/container/container/AUTHORS.md diff --git a/core/container/angel_container/CHANGELOG.md b/core/container/container/CHANGELOG.md similarity index 100% rename from core/container/angel_container/CHANGELOG.md rename to core/container/container/CHANGELOG.md diff --git a/core/container/angel_container/LICENSE b/core/container/container/LICENSE similarity index 100% rename from core/container/angel_container/LICENSE rename to core/container/container/LICENSE diff --git a/core/container/angel_container/README.md b/core/container/container/README.md similarity index 100% rename from core/container/angel_container/README.md rename to core/container/container/README.md diff --git a/core/container/angel_container/analysis_options.yaml b/core/container/container/analysis_options.yaml similarity index 100% rename from core/container/angel_container/analysis_options.yaml rename to core/container/container/analysis_options.yaml diff --git a/core/container/angel_container/example/main.dart b/core/container/container/example/main.dart similarity index 100% rename from core/container/angel_container/example/main.dart rename to core/container/container/example/main.dart diff --git a/core/container/angel_container/example/throwing.dart b/core/container/container/example/throwing.dart similarity index 100% rename from core/container/angel_container/example/throwing.dart rename to core/container/container/example/throwing.dart diff --git a/core/container/angel_container/lib/angel3_container.dart b/core/container/container/lib/angel3_container.dart similarity index 100% rename from core/container/angel_container/lib/angel3_container.dart rename to core/container/container/lib/angel3_container.dart diff --git a/core/container/angel_container/lib/mirrors.dart b/core/container/container/lib/mirrors.dart similarity index 100% rename from core/container/angel_container/lib/mirrors.dart rename to core/container/container/lib/mirrors.dart diff --git a/core/container/angel_container/lib/src/container.dart b/core/container/container/lib/src/container.dart similarity index 100% rename from core/container/angel_container/lib/src/container.dart rename to core/container/container/lib/src/container.dart diff --git a/core/container/angel_container/lib/src/container_const.dart b/core/container/container/lib/src/container_const.dart similarity index 100% rename from core/container/angel_container/lib/src/container_const.dart rename to core/container/container/lib/src/container_const.dart diff --git a/core/container/angel_container/lib/src/empty/empty.dart b/core/container/container/lib/src/empty/empty.dart similarity index 100% rename from core/container/angel_container/lib/src/empty/empty.dart rename to core/container/container/lib/src/empty/empty.dart diff --git a/core/container/angel_container/lib/src/exception.dart b/core/container/container/lib/src/exception.dart similarity index 100% rename from core/container/angel_container/lib/src/exception.dart rename to core/container/container/lib/src/exception.dart diff --git a/core/container/angel_container/lib/src/mirrors/mirrors.dart b/core/container/container/lib/src/mirrors/mirrors.dart similarity index 100% rename from core/container/angel_container/lib/src/mirrors/mirrors.dart rename to core/container/container/lib/src/mirrors/mirrors.dart diff --git a/core/container/angel_container/lib/src/mirrors/reflector.dart b/core/container/container/lib/src/mirrors/reflector.dart similarity index 100% rename from core/container/angel_container/lib/src/mirrors/reflector.dart rename to core/container/container/lib/src/mirrors/reflector.dart diff --git a/core/container/angel_container/lib/src/reflectable/reflectable.dart b/core/container/container/lib/src/reflectable/reflectable.dart similarity index 100% rename from core/container/angel_container/lib/src/reflectable/reflectable.dart rename to core/container/container/lib/src/reflectable/reflectable.dart diff --git a/core/container/angel_container/lib/src/reflector.dart b/core/container/container/lib/src/reflector.dart similarity index 100% rename from core/container/angel_container/lib/src/reflector.dart rename to core/container/container/lib/src/reflector.dart diff --git a/core/container/angel_container/lib/src/static/static.dart b/core/container/container/lib/src/static/static.dart similarity index 100% rename from core/container/angel_container/lib/src/static/static.dart rename to core/container/container/lib/src/static/static.dart diff --git a/core/container/angel_container/lib/src/throwing.dart b/core/container/container/lib/src/throwing.dart similarity index 100% rename from core/container/angel_container/lib/src/throwing.dart rename to core/container/container/lib/src/throwing.dart diff --git a/core/container/angel_container/pubspec.yaml b/core/container/container/pubspec.yaml similarity index 100% rename from core/container/angel_container/pubspec.yaml rename to core/container/container/pubspec.yaml diff --git a/core/container/angel_container/test/common.dart b/core/container/container/test/common.dart similarity index 100% rename from core/container/angel_container/test/common.dart rename to core/container/container/test/common.dart diff --git a/core/container/angel_container/test/empty_reflector_test.dart b/core/container/container/test/empty_reflector_test.dart similarity index 100% rename from core/container/angel_container/test/empty_reflector_test.dart rename to core/container/container/test/empty_reflector_test.dart diff --git a/core/container/angel_container/test/has_test.dart b/core/container/container/test/has_test.dart similarity index 100% rename from core/container/angel_container/test/has_test.dart rename to core/container/container/test/has_test.dart diff --git a/core/container/angel_container/test/lazy_test.dart b/core/container/container/test/lazy_test.dart similarity index 100% rename from core/container/angel_container/test/lazy_test.dart rename to core/container/container/test/lazy_test.dart diff --git a/core/container/angel_container/test/mirrors_test.dart b/core/container/container/test/mirrors_test.dart similarity index 100% rename from core/container/angel_container/test/mirrors_test.dart rename to core/container/container/test/mirrors_test.dart diff --git a/core/container/angel_container/test/named_test.dart b/core/container/container/test/named_test.dart similarity index 100% rename from core/container/angel_container/test/named_test.dart rename to core/container/container/test/named_test.dart diff --git a/core/container/angel_container/test/throwing_reflector_test.dart b/core/container/container/test/throwing_reflector_test.dart similarity index 100% rename from core/container/angel_container/test/throwing_reflector_test.dart rename to core/container/container/test/throwing_reflector_test.dart diff --git a/core/container/angel_container_generator/.gitignore b/core/container/container_generator/.gitignore similarity index 100% rename from core/container/angel_container_generator/.gitignore rename to core/container/container_generator/.gitignore diff --git a/core/container/angel_container_generator/CHANGELOG.md b/core/container/container_generator/CHANGELOG.md similarity index 100% rename from core/container/angel_container_generator/CHANGELOG.md rename to core/container/container_generator/CHANGELOG.md diff --git a/core/container/angel_container_generator/LICENSE b/core/container/container_generator/LICENSE similarity index 100% rename from core/container/angel_container_generator/LICENSE rename to core/container/container_generator/LICENSE diff --git a/core/container/angel_container_generator/README.md b/core/container/container_generator/README.md similarity index 100% rename from core/container/angel_container_generator/README.md rename to core/container/container_generator/README.md diff --git a/core/container/angel_container_generator/analysis_options.yaml b/core/container/container_generator/analysis_options.yaml similarity index 100% rename from core/container/angel_container_generator/analysis_options.yaml rename to core/container/container_generator/analysis_options.yaml diff --git a/core/container/angel_container_generator/example/main.dart b/core/container/container_generator/example/main.dart similarity index 100% rename from core/container/angel_container_generator/example/main.dart rename to core/container/container_generator/example/main.dart diff --git a/core/container/angel_container_generator/example/main.reflectable.dart b/core/container/container_generator/example/main.reflectable.dart similarity index 100% rename from core/container/angel_container_generator/example/main.reflectable.dart rename to core/container/container_generator/example/main.reflectable.dart diff --git a/core/container/angel_container_generator/lib/angel3_container_generator.dart b/core/container/container_generator/lib/angel3_container_generator.dart similarity index 100% rename from core/container/angel_container_generator/lib/angel3_container_generator.dart rename to core/container/container_generator/lib/angel3_container_generator.dart diff --git a/core/container/angel_container_generator/pubspec.yaml b/core/container/container_generator/pubspec.yaml similarity index 100% rename from core/container/angel_container_generator/pubspec.yaml rename to core/container/container_generator/pubspec.yaml diff --git a/core/container/angel_container_generator/test/reflector_test.dart b/core/container/container_generator/test/reflector_test.dart similarity index 100% rename from core/container/angel_container_generator/test/reflector_test.dart rename to core/container/container_generator/test/reflector_test.dart diff --git a/core/container/angel_container_generator/test/reflector_test.reflectable.dart b/core/container/container_generator/test/reflector_test.reflectable.dart similarity index 100% rename from core/container/angel_container_generator/test/reflector_test.reflectable.dart rename to core/container/container_generator/test/reflector_test.reflectable.dart diff --git a/core/eventbus b/core/eventbus new file mode 160000 index 00000000..a50543d9 --- /dev/null +++ b/core/eventbus @@ -0,0 +1 @@ +Subproject commit a50543d9add747666cd7bfe131939a35de5b3859 diff --git a/core/http_exception/.gitignore b/core/exceptions/.gitignore similarity index 100% rename from core/http_exception/.gitignore rename to core/exceptions/.gitignore diff --git a/core/http_exception/AUTHORS.md b/core/exceptions/AUTHORS.md similarity index 100% rename from core/http_exception/AUTHORS.md rename to core/exceptions/AUTHORS.md diff --git a/core/http_exception/CHANGELOG.md b/core/exceptions/CHANGELOG.md similarity index 100% rename from core/http_exception/CHANGELOG.md rename to core/exceptions/CHANGELOG.md diff --git a/core/http_exception/LICENSE b/core/exceptions/LICENSE similarity index 100% rename from core/http_exception/LICENSE rename to core/exceptions/LICENSE diff --git a/core/http_exception/README.md b/core/exceptions/README.md similarity index 100% rename from core/http_exception/README.md rename to core/exceptions/README.md diff --git a/core/http_exception/analysis_options.yaml b/core/exceptions/analysis_options.yaml similarity index 100% rename from core/http_exception/analysis_options.yaml rename to core/exceptions/analysis_options.yaml diff --git a/core/http_exception/example/main.dart b/core/exceptions/example/main.dart similarity index 100% rename from core/http_exception/example/main.dart rename to core/exceptions/example/main.dart diff --git a/core/http_exception/lib/angel3_http_exception.dart b/core/exceptions/lib/angel3_http_exception.dart similarity index 100% rename from core/http_exception/lib/angel3_http_exception.dart rename to core/exceptions/lib/angel3_http_exception.dart diff --git a/core/http_exception/pubspec.yaml b/core/exceptions/pubspec.yaml similarity index 100% rename from core/http_exception/pubspec.yaml rename to core/exceptions/pubspec.yaml diff --git a/core/mock_request/.gitignore b/core/mocking/.gitignore similarity index 100% rename from core/mock_request/.gitignore rename to core/mocking/.gitignore diff --git a/core/mock_request/AUTHORS.md b/core/mocking/AUTHORS.md similarity index 100% rename from core/mock_request/AUTHORS.md rename to core/mocking/AUTHORS.md diff --git a/core/mock_request/CHANGELOG.md b/core/mocking/CHANGELOG.md similarity index 100% rename from core/mock_request/CHANGELOG.md rename to core/mocking/CHANGELOG.md diff --git a/core/mock_request/LICENSE b/core/mocking/LICENSE similarity index 100% rename from core/mock_request/LICENSE rename to core/mocking/LICENSE diff --git a/core/mock_request/README.md b/core/mocking/README.md similarity index 100% rename from core/mock_request/README.md rename to core/mocking/README.md diff --git a/core/mock_request/analysis_options.yaml b/core/mocking/analysis_options.yaml similarity index 100% rename from core/mock_request/analysis_options.yaml rename to core/mocking/analysis_options.yaml diff --git a/core/mock_request/example/main.dart b/core/mocking/example/main.dart similarity index 100% rename from core/mock_request/example/main.dart rename to core/mocking/example/main.dart diff --git a/core/mock_request/lib/angel3_mock_request.dart b/core/mocking/lib/angel3_mock_request.dart similarity index 100% rename from core/mock_request/lib/angel3_mock_request.dart rename to core/mocking/lib/angel3_mock_request.dart diff --git a/core/mock_request/lib/src/connection_info.dart b/core/mocking/lib/src/connection_info.dart similarity index 100% rename from core/mock_request/lib/src/connection_info.dart rename to core/mocking/lib/src/connection_info.dart diff --git a/core/mock_request/lib/src/headers.dart b/core/mocking/lib/src/headers.dart similarity index 100% rename from core/mock_request/lib/src/headers.dart rename to core/mocking/lib/src/headers.dart diff --git a/core/mock_request/lib/src/lockable_headers.dart b/core/mocking/lib/src/lockable_headers.dart similarity index 100% rename from core/mock_request/lib/src/lockable_headers.dart rename to core/mocking/lib/src/lockable_headers.dart diff --git a/core/mock_request/lib/src/request.dart b/core/mocking/lib/src/request.dart similarity index 100% rename from core/mock_request/lib/src/request.dart rename to core/mocking/lib/src/request.dart diff --git a/core/mock_request/lib/src/response.dart b/core/mocking/lib/src/response.dart similarity index 100% rename from core/mock_request/lib/src/response.dart rename to core/mocking/lib/src/response.dart diff --git a/core/mock_request/lib/src/session.dart b/core/mocking/lib/src/session.dart similarity index 100% rename from core/mock_request/lib/src/session.dart rename to core/mocking/lib/src/session.dart diff --git a/core/mock_request/pubspec.yaml b/core/mocking/pubspec.yaml similarity index 100% rename from core/mock_request/pubspec.yaml rename to core/mocking/pubspec.yaml diff --git a/core/mock_request/test/all_test.dart b/core/mocking/test/all_test.dart similarity index 100% rename from core/mock_request/test/all_test.dart rename to core/mocking/test/all_test.dart diff --git a/core/mqueue/.github/workflows/action.yaml b/core/mqueue/.github/workflows/action.yaml new file mode 100644 index 00000000..b1c9c11e --- /dev/null +++ b/core/mqueue/.github/workflows/action.yaml @@ -0,0 +1,43 @@ +name: build + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ“š Git Checkout + uses: actions/checkout@v4 + + - name: ๐ŸŽฏ Setup Dart + uses: dart-lang/setup-dart@v1 + + - name: ๐Ÿ“ฆ Install Dependencies + run: dart pub get + + - name: โœจ Check Formatting + run: dart format --line-length 80 --set-exit-if-changed . + + - name: ๐Ÿ•ต๏ธ Analyze + run: dart analyze --fatal-infos --fatal-warnings . + + - name: ๐Ÿงช Run Tests + run: | + dart pub global activate coverage 1.2.0 + dart test --coverage=coverage && dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info + + - name: ๐Ÿ“Š Check Code Coverage + uses: VeryGoodOpenSource/very_good_coverage@v2 + with: + path: ./coverage/lcov.info + min_coverage: 100 + + - name: ๐Ÿ“ˆ Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/core/mqueue/.gitignore b/core/mqueue/.gitignore new file mode 100644 index 00000000..3cceda55 --- /dev/null +++ b/core/mqueue/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/core/mqueue/CHANGELOG.md b/core/mqueue/CHANGELOG.md new file mode 100644 index 00000000..70c08990 --- /dev/null +++ b/core/mqueue/CHANGELOG.md @@ -0,0 +1,19 @@ +## 1.0.0 + +- Initial version of the package. + +## 1.0.1 + +- Fix documentation. (Image path) +- Update package description. +- Fix linter rules. (Type matching) +- Update `example` files. +- Remove `mocktail` dependency. + +## 1.0.2 + + - Update documentation. + +## 1.1.0 + + - `Deprecate` the `Consumer` mixin in favor of `ConsumerMixin`. ([#1](https://github.com/N-Razzouk/dart_mq/issues/1)) diff --git a/core/mqueue/LICENSE b/core/mqueue/LICENSE new file mode 100644 index 00000000..0c5e429d --- /dev/null +++ b/core/mqueue/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Naif Razzouk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/core/mqueue/README.md b/core/mqueue/README.md new file mode 100644 index 00000000..2563c788 --- /dev/null +++ b/core/mqueue/README.md @@ -0,0 +1,165 @@ +# DartMQ: A Message Queue System for Dart and Flutter + + + +[![Pub](https://img.shields.io/pub/v/dart_mq.svg)](https://pub.dev/packages/dart_mq) +[![coverage](https://codecov.io/gh/N-Razzouk/dart_mq/graph/badge.svg)](https://app.codecov.io/gh/N-Razzouk/dart_mq) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +DartMQ is a Dart package that provides message queue functionality for sending messages between different components in your Dart and Flutter applications. It offers a simple and efficient way to implement message queues, making it easier to build robust and scalable applications. + +## Table of Contents + +1. [Introduction](#introduction) +2. [Exchanges](#exchanges) +3. [Usage](#usage) +4. [Examples](#examples) +5. [Acknowledgment](#acknowledgment) + +### + +## Introduction + +In the development of complex applications, dependencies among components are almost inevitable. Often, different components within your application need to communicate with each other, leading to tight coupling between these elements. + +![Components](https://github.com/N-Razzouk/dart_mq/blob/master/assets/components.png?raw=true) + +### + +Message queues provide an effective means to decouple these components by enabling communication through messages. This decoupling strategy enhances the development of robust applications. + +![Components with MQ](https://github.com/N-Razzouk/dart_mq/blob/master/assets/components-mq.png?raw=true) + +### + +DartMQ employs the publish-subscribe pattern. **Producers** send messages, **Consumers** receive them, and **Queues** and **Exchanges** facilitate this communication. + +![Simple View](https://github.com/N-Razzouk/dart_mq/blob/master/assets/simple-view.png?raw=true) + +### + +Communication channels are called Exchanges. Exchanges receive messages from Producers, efficiently routing them to Queues for Consumer consumption. + +![Detailed View](https://github.com/N-Razzouk/dart_mq/blob/master/assets/detailed-view.png?raw=true) + +## Exchanges + +### DartMQ provides different types of Exchanges for different use cases. + +### + +- **Default Exchange**: Routes messages based on Queue names. + +![Default Exchange](https://github.com/N-Razzouk/dart_mq/blob/master/assets/default-exchange.png?raw=true) + +### + +- **Fanout Exchange**: Sends messages to all bound Queues. + +![Fanout Exchange](https://github.com/N-Razzouk/dart_mq/blob/master/assets/fanout-exchange.png?raw=true) + +### + +- **Direct Exchange**: Routes messages to Queues based on routing keys. + +![Direct Exchange](https://github.com/N-Razzouk/dart_mq/blob/master/assets/direct-exchange.png?raw=true) + +## Usage + +### Initialize an MQClient: + + + +```dart +import 'package:dart_mq/dart_mq.dart'; + +void main() { + // Initialize DartMQ + MQClient.initialize(); + + // Your application code here +} + +``` + +### Declare a Queue: + +```dart +MQClient.declareQueue('my_queue'); +``` + +> Note: Queues are idempotent, which means that if you declare a Queue multiple times, it will not create multiple Queues. Instead, it will return the existing Queue. + +### Create a Producer: + +```dart +class MyProducer with ProducerMixin { + void greet(String message) { + // Send a message to the queue + sendMessage( + routingKey: 'my_queue', + payload: message, + ); + } +} +``` + +> Note: `exchangeName` is optional. If you don't specify an exchange name, the message is sent to the default exchange. + +### Create a Consumer: + +```dart +class MyConsumer with ConsumerMixin { + void listenToQueue() { + // Subscribe to the queue and process incoming messages + subscribe( + queueId: 'my_queue', + callback: (message) { + // Handle incoming message + print('Received message: $message'); + }, + ) + } +} +``` + +### Putting it all together: + +```dart +void main() { + // Initialize DartMQ + MQClient.initialize(); + + // Declare a Queue + MQClient.declareQueue('my_queue'); + + // Create a Producer + final producer = MyProducer(); + + // Create a Consumer + final consumer = MyConsumer(); + + // Start listening + consumer.listenToQueue(); + + // Send a message + producer.greet('Hello World!'); + + // Your application code here + ... +} +``` + +## Examples + +- [Hello World](example/hello_world): A simple example that demonstrates how to send and receive messages using DartMQ. + +- [Message Filtering](example/message_filtering): An example that demonstrates how to multiple consumers can listen to the same queue and filter messages accordingly. + +- [Routing](example/routing): An example that demonstrates how to use Direct Exchanges to route messages to different queues based on the routing key. + +- [RPC (Remote Procedure Call)](example/rpc): An example that demonstrates how to send RPC requests and receive responses using DartMQ. + +## Acknowledgment + +- [RabbitMQ](https://www.rabbitmq.com/): This package is inspired by RabbitMQ, an open-source message-broker software that implements the Advanced Message Queuing Protocol (AMQP). diff --git a/core/mqueue/analysis_options.yaml b/core/mqueue/analysis_options.yaml new file mode 100644 index 00000000..662618b5 --- /dev/null +++ b/core/mqueue/analysis_options.yaml @@ -0,0 +1,211 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +linter: + rules: + - prefer_const_constructors + - prefer_const_literals_to_create_immutables + - prefer_final_fields + - always_put_required_named_parameters_first + - avoid_init_to_null + - lines_longer_than_80_chars + - use_function_type_syntax_for_parameters + - avoid_relative_lib_imports + - avoid_shadowing_type_parameters + - avoid_equals_and_hash_code_on_mutable_classes + - unnecessary_brace_in_string_interps + - always_declare_return_types + - always_use_package_imports + - annotate_overrides + - avoid_bool_literals_in_conditional_expressions + - avoid_catching_errors + - avoid_double_and_int_checks + - avoid_dynamic_calls + - avoid_empty_else + - avoid_escaping_inner_quotes + - avoid_field_initializers_in_const_classes + - avoid_final_parameters + - avoid_function_literals_in_foreach_calls + - avoid_js_rounded_ints + - avoid_multiple_declarations_per_line + - avoid_null_checks_in_equality_operators + - avoid_positional_boolean_parameters + - avoid_print + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null_for_void + - avoid_returning_this + - avoid_setters_without_getters + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_type_to_string + - avoid_types_as_parameter_names + - avoid_unnecessary_containers + - avoid_unused_constructor_parameters + - avoid_void_async + - avoid_web_libraries_in_flutter + - await_only_futures + - camel_case_extensions + - camel_case_types + - cancel_subscriptions + - cascade_invocations + - cast_nullable_to_non_nullable + - collection_methods_unrelated_type + - combinators_ordering + - comment_references + - conditional_uri_does_not_exist + - constant_identifier_names + - control_flow_in_finally + - curly_braces_in_flow_control_structures + - dangling_library_doc_comments + - depend_on_referenced_packages + - deprecated_consistency + - directives_ordering + - empty_catches + - empty_constructor_bodies + - empty_statements + - eol_at_end_of_file + - exhaustive_cases + - file_names + - flutter_style_todos + - hash_and_equals + - implementation_imports + - implicit_call_tearoffs + - implicit_reopen + - invalid_case_patterns + - join_return_with_assignment + - leading_newlines_in_multiline_strings + - library_annotations + - library_names + - library_prefixes + - library_private_types_in_public_api + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_default_cases + - no_duplicate_case_values + - no_leading_underscores_for_library_prefixes + - no_leading_underscores_for_local_identifiers + - no_logic_in_create_state + - no_runtimeType_toString + - non_constant_identifier_names + - noop_primitive_operations + - null_check_on_nullable_type_parameter + - null_closures + - omit_local_variable_types + - one_member_abstracts + - only_throw_errors + - overridden_fields + - package_api_docs + - package_names + - package_prefixed_library_names + - parameter_assignments + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + - prefer_asserts_with_message + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_constructors_over_static_methods + - prefer_contains + - prefer_final_in_for_each + - prefer_final_locals + - prefer_for_elements_to_map_fromIterable + - prefer_function_declarations_over_variables + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + - prefer_int_literals + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_whereType + - prefer_null_aware_method_calls + - prefer_null_aware_operators + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + - provide_deprecation_message + - public_member_api_docs + - recursive_getters + - require_trailing_commas + - secure_pubspec_urls + - sized_box_for_whitespace + - sized_box_shrink_expand + - slash_for_doc_comments + - sort_child_properties_last + - sort_constructors_first + - sort_pub_dependencies + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + - tighten_type_of_initializing_formals + - type_annotate_public_apis + - type_init_formals + - unawaited_futures + - unnecessary_await_in_return + - unnecessary_breaks + - unnecessary_const + - unnecessary_constructor_name + - unnecessary_getters_setters + - unnecessary_lambdas + - unnecessary_late + - unnecessary_library_directive + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_checks + - unnecessary_null_in_if_null_operators + - unnecessary_nullable_for_final_variable_declarations + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_raw_strings + - unnecessary_statements + - unnecessary_string_escapes + - unnecessary_string_interpolations + - unnecessary_this + - unnecessary_to_list_in_spreads + - unrelated_type_equality_checks + - use_build_context_synchronously + - use_colored_box + - use_enums + - use_full_hex_values_for_flutter_colors + - use_if_null_to_convert_nulls_to_bools + - use_is_even_rather_than_modulo + - use_key_in_widget_constructors + - use_late_for_private_fields_and_variables + - use_named_constants + - use_raw_strings + - use_rethrow_when_possible + - use_setters_to_change_properties + - use_string_buffers + - use_string_in_part_of_directives + - use_super_parameters + - use_test_throws_matchers + - use_to_and_as_if_applicable + - valid_regexps + - void_checks + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/core/mqueue/assets/components-mq.png b/core/mqueue/assets/components-mq.png new file mode 100644 index 00000000..c0bf8458 Binary files /dev/null and b/core/mqueue/assets/components-mq.png differ diff --git a/core/mqueue/assets/components.png b/core/mqueue/assets/components.png new file mode 100644 index 00000000..bfced0ab Binary files /dev/null and b/core/mqueue/assets/components.png differ diff --git a/core/mqueue/assets/default-exchange.png b/core/mqueue/assets/default-exchange.png new file mode 100644 index 00000000..1b721a2d Binary files /dev/null and b/core/mqueue/assets/default-exchange.png differ diff --git a/core/mqueue/assets/detailed-view.png b/core/mqueue/assets/detailed-view.png new file mode 100644 index 00000000..2ce7eee6 Binary files /dev/null and b/core/mqueue/assets/detailed-view.png differ diff --git a/core/mqueue/assets/direct-exchange.png b/core/mqueue/assets/direct-exchange.png new file mode 100644 index 00000000..f519dce7 Binary files /dev/null and b/core/mqueue/assets/direct-exchange.png differ diff --git a/core/mqueue/assets/fanout-exchange.png b/core/mqueue/assets/fanout-exchange.png new file mode 100644 index 00000000..823a2502 Binary files /dev/null and b/core/mqueue/assets/fanout-exchange.png differ diff --git a/core/mqueue/assets/simple-view.png b/core/mqueue/assets/simple-view.png new file mode 100644 index 00000000..b24e6b8b Binary files /dev/null and b/core/mqueue/assets/simple-view.png differ diff --git a/core/mqueue/example/main.dart b/core/mqueue/example/main.dart new file mode 100644 index 00000000..83f3e031 --- /dev/null +++ b/core/mqueue/example/main.dart @@ -0,0 +1,16 @@ +import 'package:angel3_mq/mq.dart'; + +import 'receiver.dart'; +import 'sender.dart'; + +void main() async { + MQClient.initialize(); + + final sender = Sender(); + + final receiver = Receiver()..listenToGreeting(); + + await sender.sendGreeting(greeting: 'Hello, World!'); + + receiver.stopListening(); +} diff --git a/core/mqueue/example/message_filtering/main.dart b/core/mqueue/example/message_filtering/main.dart new file mode 100644 index 00000000..c5945a74 --- /dev/null +++ b/core/mqueue/example/message_filtering/main.dart @@ -0,0 +1,27 @@ +import 'package:angel3_mq/mq.dart'; + +import 'task_manager.dart'; +import 'worker_one.dart'; +import 'worker_two.dart'; + +void main() async { + MQClient.initialize(); + + final workerOne = WorkerOne(); + + final workerTwo = WorkerTwo(); + + final taskManager = TaskManager(); + + workerOne.startListening(); + + workerTwo.startListening(); + + taskManager + ..sendTask(task: 'Hello..') + ..sendTask(task: 'Hello...') + ..sendTask(task: 'Hello....') + ..sendTask(task: 'Hello.') + ..sendTask(task: 'Hello.......') + ..sendTask(task: 'Hello..'); +} diff --git a/core/mqueue/example/message_filtering/task_manager.dart b/core/mqueue/example/message_filtering/task_manager.dart new file mode 100644 index 00000000..9c6aee9a --- /dev/null +++ b/core/mqueue/example/message_filtering/task_manager.dart @@ -0,0 +1,12 @@ +import 'package:angel3_mq/mq.dart'; + +final class TaskManager with ProducerMixin { + TaskManager() { + MQClient.instance.declareQueue('task_queue'); + } + + void sendTask({required String task}) => sendMessage( + payload: task, + routingKey: 'task_queue', + ); +} diff --git a/core/mqueue/example/message_filtering/worker_one.dart b/core/mqueue/example/message_filtering/worker_one.dart new file mode 100644 index 00000000..788c6bb1 --- /dev/null +++ b/core/mqueue/example/message_filtering/worker_one.dart @@ -0,0 +1,22 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +final class WorkerOne with ConsumerMixin { + WorkerOne() { + MQClient.instance.declareQueue('task_queue'); + } + + void startListening() => subscribe( + queueId: 'task_queue', + filter: (Object messagePayload) => messagePayload + .toString() + .split('') + .where((String char) => char == '.') + .length + .isEven, + callback: (Message message) { + log('WorkerOne reacting to ${message.payload}'); + }, + ); +} diff --git a/core/mqueue/example/message_filtering/worker_two.dart b/core/mqueue/example/message_filtering/worker_two.dart new file mode 100644 index 00000000..a4cbd982 --- /dev/null +++ b/core/mqueue/example/message_filtering/worker_two.dart @@ -0,0 +1,24 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +final class WorkerTwo with ConsumerMixin { + WorkerTwo() { + MQClient.instance.declareQueue('task_queue'); + } + + void startListening() => subscribe( + queueId: 'task_queue', + filter: (Object messagePayload) => + messagePayload + .toString() + .split('') + .where((String char) => char == '.') + .length % + 2 != + 0, + callback: (Message message) { + log('WorkerTwo reacting to ${message.payload}'); + }, + ); +} diff --git a/core/mqueue/example/receiver.dart b/core/mqueue/example/receiver.dart new file mode 100644 index 00000000..8b929a8e --- /dev/null +++ b/core/mqueue/example/receiver.dart @@ -0,0 +1,18 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +final class Receiver with ConsumerMixin { + Receiver() { + MQClient.instance.declareQueue('hello'); + } + + void listenToGreeting() => subscribe( + queueId: 'hello', + callback: (Message message) { + log('Received: ${message.payload}'); + }, + ); + + void stopListening() => unsubscribe(queueId: 'hello'); +} diff --git a/core/mqueue/example/routing/debug_logger.dart b/core/mqueue/example/routing/debug_logger.dart new file mode 100644 index 00000000..e45beced --- /dev/null +++ b/core/mqueue/example/routing/debug_logger.dart @@ -0,0 +1,39 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +final class DebugLogger with ConsumerMixin { + DebugLogger() { + MQClient.instance.declareExchange( + exchangeName: 'logs', + exchangeType: ExchangeType.direct, + ); + _queueName = MQClient.instance.declareQueue('debug'); + } + + late final String _queueName; + + void startListening() { + MQClient.instance.bindQueue( + queueId: _queueName, + exchangeName: 'logs', + bindingKey: 'info', + ); + MQClient.instance.bindQueue( + queueId: _queueName, + exchangeName: 'logs', + bindingKey: 'warning', + ); + MQClient.instance.bindQueue( + queueId: _queueName, + exchangeName: 'logs', + bindingKey: 'error', + ); + subscribe( + queueId: _queueName, + callback: (Message message) { + log('Debug Logger recieved: ${message.payload}'); + }, + ); + } +} diff --git a/core/mqueue/example/routing/logger.dart b/core/mqueue/example/routing/logger.dart new file mode 100644 index 00000000..98f6e8c5 --- /dev/null +++ b/core/mqueue/example/routing/logger.dart @@ -0,0 +1,21 @@ +import 'package:angel3_mq/mq.dart'; + +final class Logger with ProducerMixin { + Logger() { + MQClient.instance.declareExchange( + exchangeName: 'logs', + exchangeType: ExchangeType.direct, + ); + } + + Future log({ + required String level, + required String message, + }) async { + sendMessage( + payload: message, + exchangeName: 'logs', + routingKey: level, + ); + } +} diff --git a/core/mqueue/example/routing/main.dart b/core/mqueue/example/routing/main.dart new file mode 100644 index 00000000..41360325 --- /dev/null +++ b/core/mqueue/example/routing/main.dart @@ -0,0 +1,30 @@ +import 'package:angel3_mq/mq.dart'; + +import 'debug_logger.dart'; +import 'logger.dart'; +import 'production_logger.dart'; + +void main() async { + MQClient.initialize(); + + DebugLogger().startListening(); + + ProductionLogger().startListening(); + + final logger = Logger(); + + await logger.log( + level: 'info', + message: 'This is an info message', + ); + + await logger.log( + level: 'warning', + message: 'This is a warning message', + ); + + await logger.log( + level: 'error', + message: 'This is an error message', + ); +} diff --git a/core/mqueue/example/routing/production_logger.dart b/core/mqueue/example/routing/production_logger.dart new file mode 100644 index 00000000..e7c345a7 --- /dev/null +++ b/core/mqueue/example/routing/production_logger.dart @@ -0,0 +1,29 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +final class ProductionLogger with ConsumerMixin { + ProductionLogger() { + MQClient.instance.declareExchange( + exchangeName: 'logs', + exchangeType: ExchangeType.direct, + ); + _queueName = MQClient.instance.declareQueue('production'); + } + + late final String _queueName; + + void startListening() { + MQClient.instance.bindQueue( + queueId: _queueName, + exchangeName: 'logs', + bindingKey: 'error', + ); + subscribe( + queueId: _queueName, + callback: (Message message) { + log('Production Logger recieved: ${message.payload}'); + }, + ); + } +} diff --git a/core/mqueue/example/rpc/main.dart b/core/mqueue/example/rpc/main.dart new file mode 100644 index 00000000..ba059963 --- /dev/null +++ b/core/mqueue/example/rpc/main.dart @@ -0,0 +1,19 @@ +import 'package:angel3_mq/mq.dart'; + +import 'service_one.dart'; +import 'service_two.dart'; + +void main() { + MQClient.initialize(); + + MQClient.instance.declareExchange( + exchangeName: 'ServiceRPC', + exchangeType: ExchangeType.direct, + ); + + final serviceOne = ServiceOne(); + + ServiceTwo().startListening(); + + serviceOne.requestFoo(); +} diff --git a/core/mqueue/example/rpc/service_one.dart b/core/mqueue/example/rpc/service_one.dart new file mode 100644 index 00000000..a2d4f979 --- /dev/null +++ b/core/mqueue/example/rpc/service_one.dart @@ -0,0 +1,19 @@ +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +class ServiceOne with ProducerMixin { + Future requestFoo() async { + final res = await sendRPCMessage( + exchangeName: 'ServiceRPC', + routingKey: 'rpcBinding', + processId: 'foo', + args: {}, + ); + _handleFuture(res); + } + + void _handleFuture(String data) { + log('Service One received: $data\n'); + } +} diff --git a/core/mqueue/example/rpc/service_two.dart b/core/mqueue/example/rpc/service_two.dart new file mode 100644 index 00000000..20e4ba22 --- /dev/null +++ b/core/mqueue/example/rpc/service_two.dart @@ -0,0 +1,46 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:angel3_mq/mq.dart'; + +class ServiceTwo with ConsumerMixin { + ServiceTwo() { + MQClient.instance.declareExchange( + exchangeName: 'ServiceRPC', + exchangeType: ExchangeType.direct, + ); + _queueName = MQClient.instance.declareQueue('two'); + } + + late final String _queueName; + + Future startListening() async { + MQClient.instance.bindQueue( + queueId: _queueName, + exchangeName: 'ServiceRPC', + bindingKey: 'rpcBinding', + ); + subscribe( + queueId: _queueName, + callback: (Message message) async { + log('Service Two got message $message\n'); + if (message.headers['type'] == 'RPC') { + switch (message.headers['processId']) { + case 'foo': + final data = await foo(); + final Completer completer = + message.headers['completer'] ?? (throw Exception()); + completer.complete(data); + default: + } + } + }, + ); + } + + Future foo() async { + // log('Service Two bar\n'); + await Future.delayed(const Duration(seconds: 2)); + return 'Hello, world!'; + } +} diff --git a/core/mqueue/example/sender.dart b/core/mqueue/example/sender.dart new file mode 100644 index 00000000..6d9b8425 --- /dev/null +++ b/core/mqueue/example/sender.dart @@ -0,0 +1,12 @@ +import 'package:angel3_mq/mq.dart'; + +final class Sender with ProducerMixin { + Sender() { + MQClient.instance.declareQueue('hello'); + } + + Future sendGreeting({required String greeting}) async => sendMessage( + routingKey: 'hello', + payload: greeting, + ); +} diff --git a/core/mqueue/lib/mq.dart b/core/mqueue/lib/mq.dart new file mode 100644 index 00000000..e3736c81 --- /dev/null +++ b/core/mqueue/lib/mq.dart @@ -0,0 +1,11 @@ +/// Library definition. +library angel3_mq; + +/// Export files. +export 'src/consumer/consumer.dart'; +export 'src/consumer/consumer.mixin.dart'; +export 'src/core/constants/enums.dart'; +export 'src/message/message.dart'; +export 'src/mq/mq.dart'; +export 'src/producer/producer.dart'; +export 'src/producer/producer.mixin.dart'; diff --git a/core/mqueue/lib/src/binding/binding.dart b/core/mqueue/lib/src/binding/binding.dart new file mode 100644 index 00000000..c93d94a0 --- /dev/null +++ b/core/mqueue/lib/src/binding/binding.dart @@ -0,0 +1,75 @@ +import 'package:angel3_mq/src/binding/binding.interface.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// A class representing a binding between a topic and its associated queues. +/// +/// The `Binding` class implements the [BindingInterface] interface and is +/// responsible for managing the association between a topic and its associated +/// queues. It allows the addition and removal of queues to the binding and the +/// publication of messages to all associated queues. +/// +/// Example: +/// ```dart +/// final binding = Binding('my_binding'); +/// final queue1 = Queue('queue_1'); +/// final queue2 = Queue('queue_2'); +/// +/// // Add queues to the binding. +/// binding.addQueue(queue1); +/// binding.addQueue(queue2); +/// +/// // Publish a message to all associated queues. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// binding.publishMessage(message); +/// +/// // Check if the binding has associated queues. +/// final hasQueues = binding.hasQueues(); // Returns true +/// ``` +final class Binding implements BindingInterface { + /// Creates a new binding with the specified [id]. + /// + /// The [id] parameter represents the unique identifier for the binding. + Binding(this.id); + + /// The unique identifier for the binding. + final String id; + + /// A list of associated queues. + final List _queues = []; + + @override + bool hasQueues() => _queues.isNotEmpty; + + @override + void addQueue(Queue queue) => _queues.add(queue); + + @override + void removeQueue(String queueId) => _queues.removeWhere( + (Queue queue) => queue.id == queueId && queue.hasListeners() + ? throw QueueHasSubscribersException(queueId) + : queue.id == queueId, + ); + + @override + void publishMessage(Message message) { + for (final queue in _queues) { + queue.enqueue(message); + } + } + + @override + void clear() { + for (final queue in _queues) { + if (queue.hasListeners()) { + throw QueueHasSubscribersException(queue.id); + } + } + _queues.clear(); + } +} diff --git a/core/mqueue/lib/src/binding/binding.interface.dart b/core/mqueue/lib/src/binding/binding.interface.dart new file mode 100644 index 00000000..18fd7621 --- /dev/null +++ b/core/mqueue/lib/src/binding/binding.interface.dart @@ -0,0 +1,44 @@ +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// An abstract interface class defining the contract for managing bindings. +/// +/// The `BindingInterface` abstract interface class defines a contract for +/// classes that are responsible for managing bindings between topics and +/// queues. Implementing classes must provide functionality for adding and +/// removing queues from the binding, publishing messages to the associated +/// queues, and checking if the binding has queues. +/// +/// Example: +/// ```dart +/// class MyBinding implements BindingInterface { +/// // Custom implementation of the binding interface methods. +/// } +/// ``` +abstract interface class BindingInterface { + /// Checks if the binding has associated queues. + /// + /// Returns `true` if the binding has one or more associated queues; + /// otherwise, `false`. + bool hasQueues(); + + /// Adds a queue to the binding. + /// + /// The [queue] parameter represents the queue to be associated with the + /// binding. + void addQueue(Queue queue); + + /// Removes a queue from the binding based on its ID. + /// + /// The [queueId] parameter represents the ID of the queue to be removed. + void removeQueue(String queueId); + + /// Publishes a message to all associated queues in the binding. + /// + /// The [message] parameter represents the message to be published to the + /// queues. + void publishMessage(Message message); + + /// Removes all queues from the binding. + void clear(); +} diff --git a/core/mqueue/lib/src/consumer/consumer.dart b/core/mqueue/lib/src/consumer/consumer.dart new file mode 100644 index 00000000..8d420651 --- /dev/null +++ b/core/mqueue/lib/src/consumer/consumer.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:angel3_mq/src/consumer/consumer.interface.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/core/registrar/simple_registrar.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/mq/mq.dart'; + +/// A mixin implementing the `ConsumerInterface` for message consumption. +/// +/// The `Consumer` mixin provides a concrete implementation of the +/// `ConsumerInterface`for message consumption. It allows classes to easily +/// consume messages from specific queues by subscribing to them, handling +/// received messages, and managing subscriptions. +/// +/// Example: +/// ```dart +/// class MyMessageConsumer with Consumer { +/// // Custom implementation of the message consumer. +/// } +/// ``` +@Deprecated('Please use `ConsumerMixin` instead. ' + 'This will be removed in v2.0.0') +mixin Consumer implements ConsumerInterface { + /// A registry of active message subscriptions. + final Registrar> _subscriptions = + Registrar>(); + + @override + Message? getLatestMessage(String queueId) => + MQClient.instance.getLatestMessage(queueId); + + @override + void subscribe({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }) { + try { + final messageStream = MQClient.instance.fetchQueue(queueId); + + final sub = filter != null + ? messageStream.listen((Message message) { + if (filter(message.payload)) { + callback(message); + } + }) + : messageStream.listen(callback); + + _subscriptions.register(queueId, sub); + } on IdAlreadyRegisteredException catch (_) { + throw ConsumerAlreadySubscribedException( + consumer: runtimeType.toString(), + queue: queueId, + ); + } + } + + @override + void unsubscribe({required String queueId}) { + _subscriptions.get(queueId).cancel(); + _subscriptions.unregister(queueId); + } + + @override + void pauseSubscription(String queueId) => _subscriptions.get(queueId).pause(); + + @override + void resumeSubscription(String queueId) => + _subscriptions.get(queueId).resume(); + + @override + void updateSubscription({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }) { + _subscriptions.get(queueId).cancel(); + _subscriptions.unregister(queueId); + subscribe( + queueId: queueId, + callback: callback, + filter: filter, + ); + } + + @override + void clearSubscriptions() { + for (final StreamSubscription sub in _subscriptions.getAll()) { + sub.cancel(); + } + + _subscriptions.clear(); + } +} diff --git a/core/mqueue/lib/src/consumer/consumer.interface.dart b/core/mqueue/lib/src/consumer/consumer.interface.dart new file mode 100644 index 00000000..68900991 --- /dev/null +++ b/core/mqueue/lib/src/consumer/consumer.interface.dart @@ -0,0 +1,74 @@ +import 'package:angel3_mq/src/message/message.dart'; + +/// An abstract interface class defining the contract for a message consumer. +/// +/// The `ConsumerInterface` abstract interface class defines a contract for +/// classes that implement a message consumer. Implementing classes must +/// provide methods for subscribing and unsubscribing from queues, pausing and +/// resuming subscriptions, updating subscriptions, retrieving the +/// latest message from a queue, and clearing all subscriptions. +/// +/// Example: +/// ```dart +/// class MyConsumer implements ConsumerInterface { +/// // Custom implementation of the message consumer. +/// } +/// ``` +abstract interface class ConsumerInterface { + /// Subscribes to a queue to receive messages. + /// + /// The [queueId] parameter represents the ID of the queue to subscribe to. + /// The [callback] parameter is a function that will be invoked for each + /// received message. + /// The [filter] parameter is an optional function that can be used to filter + /// messages based on custom criteria. + void subscribe({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }); + + /// Unsubscribes from a previously subscribed queue. + /// + /// The [queueId] parameter represents the ID of the queue to unsubscribe + /// from. + void unsubscribe({required String queueId}); + + /// Pauses message subscription for a specified queue. + /// + /// The [queueId] parameter represents the ID of the queue to pause the + /// subscription. + void pauseSubscription(String queueId); + + /// Resumes a paused subscription for a specified queue. + /// + /// The [queueId] parameter represents the ID of the queue to resume the + /// subscription. + void resumeSubscription(String queueId); + + /// Updates an existing subscription with a new callback and/or filter. + /// + /// The [queueId] parameter represents the ID of the queue to update the + /// subscription. + /// The [callback] parameter is a new function that will be invoked for each + /// received message. + /// The [filter] parameter is an optional new filter function for message + /// filtering. + void updateSubscription({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }); + + /// Retrieves the latest message from a queue. + /// + /// The [queueId] parameter represents the ID of the queue to fetch the latest + /// message from. + /// + /// Returns the latest message from the specified queue or `null` if the queue + /// is empty. + Message? getLatestMessage(String queueId); + + /// Clears all active subscriptions, unsubscribing from all queues. + void clearSubscriptions(); +} diff --git a/core/mqueue/lib/src/consumer/consumer.mixin.dart b/core/mqueue/lib/src/consumer/consumer.mixin.dart new file mode 100644 index 00000000..b3bc347d --- /dev/null +++ b/core/mqueue/lib/src/consumer/consumer.mixin.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:angel3_mq/src/consumer/consumer.interface.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/core/registrar/simple_registrar.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/mq/mq.dart'; + +/// A mixin implementing the `ConsumerInterface` for message consumption. +/// +/// The `ConsumerMixin` mixin provides a concrete implementation of the +/// `ConsumerInterface`for message consumption. It allows classes to easily +/// consume messages from specific queues by subscribing to them, handling +/// received messages, and managing subscriptions. +/// +/// Example: +/// ```dart +/// class MyMessageConsumer with ConsumerMixin { +/// // Custom implementation of the message consumer. +/// } +/// ``` +mixin ConsumerMixin implements ConsumerInterface { + /// A registry of active message subscriptions. + final Registrar> _subscriptions = + Registrar>(); + + @override + Message? getLatestMessage(String queueId) => + MQClient.instance.getLatestMessage(queueId); + + @override + void subscribe({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }) { + try { + final messageStream = MQClient.instance.fetchQueue(queueId); + + final sub = filter != null + ? messageStream.listen((Message message) { + if (filter(message.payload)) { + callback(message); + } + }) + : messageStream.listen(callback); + + _subscriptions.register(queueId, sub); + } on IdAlreadyRegisteredException catch (_) { + throw ConsumerAlreadySubscribedException( + consumer: runtimeType.toString(), + queue: queueId, + ); + } + } + + @override + void unsubscribe({required String queueId}) => _subscriptions + ..get(queueId).cancel() + ..unregister(queueId); + + @override + void pauseSubscription(String queueId) => _subscriptions.get(queueId).pause(); + + @override + void resumeSubscription(String queueId) => + _subscriptions.get(queueId).resume(); + + @override + void updateSubscription({ + required String queueId, + required Function(Message) callback, + bool Function(Object)? filter, + }) { + _subscriptions.get(queueId).cancel(); + _subscriptions.unregister(queueId); + subscribe( + queueId: queueId, + callback: callback, + filter: filter, + ); + } + + @override + void clearSubscriptions() { + for (final StreamSubscription sub in _subscriptions.getAll()) { + sub.cancel(); + } + + _subscriptions.clear(); + } +} diff --git a/core/mqueue/lib/src/core/constants/enums.dart b/core/mqueue/lib/src/core/constants/enums.dart new file mode 100644 index 00000000..582d02cf --- /dev/null +++ b/core/mqueue/lib/src/core/constants/enums.dart @@ -0,0 +1,22 @@ +/// An enumeration representing different types of message exchanges. +/// +/// The [ExchangeType] enum defines various types of message exchanges that are +/// commonly used in messaging systems. Each type represents a specific behavior +/// for distributing messages to multiple queues or consumers. +/// +/// - `direct`: A direct exchange routes messages to queues based on a specified +/// routing key. +/// - `base`: The default exchange (unnamed) routes messages to queues using +/// their names. +/// - `fanout`: A fanout exchange routes messages to all connected queues, +/// ignoring routing keys. +enum ExchangeType { + /// Represents a direct message exchange. + direct, + + /// Represents the default exchange (unnamed). + base, + + /// Represents a fanout message exchange. + fanout, +} diff --git a/core/mqueue/lib/src/core/constants/error_strings.dart b/core/mqueue/lib/src/core/constants/error_strings.dart new file mode 100644 index 00000000..0ea4dc59 --- /dev/null +++ b/core/mqueue/lib/src/core/constants/error_strings.dart @@ -0,0 +1,99 @@ +/// A utility class providing exception-related error messages. +/// +/// The `ExceptionStrings` class defines static methods that generate error +/// messages for various exception scenarios. These messages can be used to +/// provide descriptive error information in exception handling and debugging. +class ExceptionStrings { + /// Generates an error message when MQClient is not initialized. + /// + /// This message is used when attempting to use the MQClient before it has + /// been properly initialized using the `MQClient.initialize()` method. + static String mqClientNotInitialized() => + 'MQClient is not initialized. Please make sure to call ' + 'MQClient.initialize() first.'; + + /// Generates an error message for a Queue that is not registered. + /// + /// The [queueId] parameter represents the name of the unregistered queue. + static String queueNotRegistered(String queueId) => + 'Queue: $queueId is not registered.'; + + /// Generates an error message for a queue with active subscribers. + /// + /// The [queueId] parameter represents the ID of the queue with active + /// subscribers. + static String queueHasSubscribers(String queueId) => + 'Queue: $queueId has subscribers.'; + + /// Generates an error message for a queue with no name. + /// + /// This message is used when the name of the queue is not provided and is + /// null. + static String queueIdNull() => "Queue name can't be null."; + + /// Generates an error message for a required routing key. + /// + /// This message is used when a routing key is required for a specific + /// operation but is not provided. + static String routingKeyRequired() => 'Routing key is required.'; + + /// Generates an error message for a non-existent binding key. + /// + /// The [bindingKey] parameter represents the non-existent binding key. + static String bindingKeyNotFound(String bindingKey) => + 'The binding key "$bindingKey" was not found.'; + + /// Generates an error message for a missing binding key. + /// + /// This message is used when a binding operation expects a binding key to + static String bindingKeyRequired() => 'Binding key is required.'; + + /// Generates an error message for an exchange that is not registered. + /// + /// The [exchangeName] parameter represents the name of the unregistered + /// exchange. + static String exchangeNotRegistered(String exchangeName) => + 'Exchange: $exchangeName is not registered.'; + + /// Generates an error message for invalid exchange type. + static String invalidExchangeType() => 'Exchange type is invalid.'; + + /// Generates an error message for a consumer that is not subscribed to a + /// queue. + /// + /// The [consumerId] parameter represents the ID of the consumer. + /// The [queue] parameter represents the name of the queue. + static String consumerNotSubscribed(String consumerId, String queue) => + 'The consumer "$consumerId" is not subscribed to the queue "$queue".'; + + /// Generates an error message for a consumer that is already subscribed to + /// a queue. + /// + /// The [consumerId] parameter represents the ID of the consumer. + /// The [queue] parameter represents the name of the queue. + static String consumerAlreadySubscribed(String consumerId, String queue) => + 'The consumer "$consumerId" is already subscribed to the queue "$queue".'; + + /// Generates an error message for a consumer that is not registered. + /// + /// The [consumerId] parameter represents the ID of the consumer. + static String consumerNotRegistered(String consumerId) => + 'The consumer "$consumerId" is not registered.'; + + /// Generates an error message for a consumer that has active subscriptions. + /// + /// The [consumerId] parameter represents the ID of the consumer. + static String consumerHasSubscriptions(String consumerId) => + 'The consumer "$consumerId" has active subscriptions.'; + + /// Generates an error message for an ID that is already registered. + /// + /// The [id] parameter represents the ID that is already registered. + static String idAlreadyRegistered(String id) => + 'Id "$id" already registered.'; + + /// Generates an error message for an ID that is not registered. + /// + /// The [id] parameter represents the ID that is not registered. + static String idNotRegistered(String id) => 'Id "$id" not registered.'; +} diff --git a/core/mqueue/lib/src/core/exceptions/binding_exceptions.dart b/core/mqueue/lib/src/core/exceptions/binding_exceptions.dart new file mode 100644 index 00000000..898f5f13 --- /dev/null +++ b/core/mqueue/lib/src/core/exceptions/binding_exceptions.dart @@ -0,0 +1,42 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [BindingException] class represents a base exception related to +/// bindings. +/// +/// It is used to handle exceptions that may occur when working with bindings, +/// such as when a binding key is not found or when a binding key is required +/// but not provided. +/// +/// Subclasses of [BindingException] can provide more specific information about +/// the nature of the exception. +abstract base class BindingException implements Exception { + /// Creates a new [BindingException] with the specified error [message]. + BindingException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [BindingKeyNotFoundException] class represents an exception that occurs +/// when a binding key is not found. +/// +/// This exception is thrown when attempting to access a binding key that does +/// not exist in the context of bindings. +final class BindingKeyNotFoundException extends BindingException { + /// Creates a new [BindingKeyNotFoundException] instance. + BindingKeyNotFoundException(String key) + : super(ExceptionStrings.bindingKeyNotFound(key)); +} + +/// The [BindingKeyRequiredException] class represents an exception that occurs +/// when a binding key is required but not provided. +/// +/// This exception is thrown when a binding operation expects a binding key to +/// be provided, but it is missing or empty. +final class BindingKeyRequiredException extends BindingException { + /// Creates a new [BindingKeyRequiredException] instance. + BindingKeyRequiredException() : super(ExceptionStrings.bindingKeyRequired()); +} diff --git a/core/mqueue/lib/src/core/exceptions/consumer_exceptions.dart b/core/mqueue/lib/src/core/exceptions/consumer_exceptions.dart new file mode 100644 index 00000000..9cd76ccc --- /dev/null +++ b/core/mqueue/lib/src/core/exceptions/consumer_exceptions.dart @@ -0,0 +1,73 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [ConsumerException] class represents a base exception related to +/// consumers. +/// +/// It is used to handle exceptions that may occur when working with consumers, +/// such as when a consumer is not registered, is already subscribed to a queue, +/// is not subscribed to a queue when expected, or has active subscriptions. +/// +/// Subclasses of [ConsumerException] can provide more specific information +/// about the nature of the exception. +abstract base class ConsumerException implements Exception { + /// Creates a new [ConsumerException] with the specified error [message]. + ConsumerException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [ConsumerNotRegisteredException] class represents an exception that +/// occurs when a consumer is not registered. +/// +/// This exception is thrown when attempting to perform operations on a consumer +/// that has not been registered. +final class ConsumerNotRegisteredException extends ConsumerException { + /// Creates a new [ConsumerNotRegisteredException] instance with the + /// specified [consumer]. + ConsumerNotRegisteredException(String consumer) + : super(ExceptionStrings.consumerNotRegistered(consumer)); +} + +/// The [ConsumerAlreadySubscribedException] class represents an exception that +/// occurs when a consumer is already subscribed to a queue. +/// +/// This exception is thrown when attempting to subscribe a consumer to a queue +/// that it is already subscribed to. +final class ConsumerAlreadySubscribedException extends ConsumerException { + /// Creates a new [ConsumerAlreadySubscribedException] instance with the + /// specified [queue]. + ConsumerAlreadySubscribedException({ + required String consumer, + required String queue, + }) : super(ExceptionStrings.consumerAlreadySubscribed(consumer, queue)); +} + +/// The [ConsumerNotSubscribedException] class represents an exception that +/// occurs when a consumer is not subscribed to a queue when expected. +/// +/// This exception is thrown when an operation expects a consumer to be +/// subscribed to a queue, but the consumer is not. +final class ConsumerNotSubscribedException extends ConsumerException { + /// Creates a new [ConsumerNotSubscribedException] instance with the + /// specified [queue]. + ConsumerNotSubscribedException({ + required String consumer, + required String queue, + }) : super(ExceptionStrings.consumerNotSubscribed(consumer, queue)); +} + +/// The [ConsumerHasSubscriptionsException] class represents an exception that +/// occurs when a consumer has active subscriptions. +/// +/// This exception is thrown when an operation expects a consumer to have no +/// active subscriptions, but the consumer has active subscriptions. +final class ConsumerHasSubscriptionsException extends ConsumerException { + /// Creates a new [ConsumerHasSubscriptionsException] instance with the + /// specified [consumer]. + ConsumerHasSubscriptionsException(String consumer) + : super(ExceptionStrings.consumerHasSubscriptions(consumer)); +} diff --git a/core/mqueue/lib/src/core/exceptions/exceptions.dart b/core/mqueue/lib/src/core/exceptions/exceptions.dart new file mode 100644 index 00000000..77c94bad --- /dev/null +++ b/core/mqueue/lib/src/core/exceptions/exceptions.dart @@ -0,0 +1,7 @@ +export 'binding_exceptions.dart'; +export 'consumer_exceptions.dart'; +export 'exchange_exceptions.dart'; +export 'mq_client_exceptions.dart'; +export 'queue_exceptions.dart'; +export 'registrar_exceptions.dart'; +export 'routing_key_exceptions.dart'; diff --git a/core/mqueue/lib/src/core/exceptions/exchange_exceptions.dart b/core/mqueue/lib/src/core/exceptions/exchange_exceptions.dart new file mode 100644 index 00000000..eb9336ff --- /dev/null +++ b/core/mqueue/lib/src/core/exceptions/exchange_exceptions.dart @@ -0,0 +1,44 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [ExchangeException] class represents a base exception related to +/// exchanges. +/// +/// It is used to handle exceptions that may occur when working with exchanges, +/// such as when an exchange is not registered or when an invalid exchange type +/// is encountered. +/// +/// Subclasses of [ExchangeException] can provide more specific information +/// about the nature of the exception. +abstract base class ExchangeException implements Exception { + /// Creates a new [ExchangeException] with the specified error [message]. + ExchangeException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [ExchangeNotRegisteredException] class represents an exception that +/// occurs when an exchange is not registered. +/// +/// This exception is thrown when attempting to perform operations on an +/// exchange that has not been registered. +final class ExchangeNotRegisteredException extends ExchangeException { + /// Creates a new [ExchangeNotRegisteredException] instance with the + /// specified [exchangeName]. + ExchangeNotRegisteredException(String exchangeName) + : super(ExceptionStrings.exchangeNotRegistered(exchangeName)); +} + +/// The [InvalidExchangeTypeException] class represents an exception that occurs +/// when an invalid exchange type is encountered. +/// +/// This exception is thrown when an operation encounters an exchange type that +/// is not recognized or supported. +final class InvalidExchangeTypeException extends ExchangeException { + /// Creates a new [InvalidExchangeTypeException] instance. + InvalidExchangeTypeException() + : super(ExceptionStrings.invalidExchangeType()); +} diff --git a/core/mqueue/lib/src/core/exceptions/mq_client_exceptions.dart b/core/mqueue/lib/src/core/exceptions/mq_client_exceptions.dart new file mode 100644 index 00000000..bc2c819a --- /dev/null +++ b/core/mqueue/lib/src/core/exceptions/mq_client_exceptions.dart @@ -0,0 +1,31 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [MQClientException] class represents a base exception related to the +/// MQClient. +/// +/// It is used to handle exceptions that may occur when working with the +/// MQClient, such as when the MQClient is not initialized. +/// +/// Subclasses of [MQClientException] can provide more specific information +/// about the nature of the exception. +abstract base class MQClientException implements Exception { + /// Creates a new [MQClientException] with the specified error [message]. + MQClientException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [MQClientNotInitializedException] class represents an exception that +/// occurs when the MQClient is not initialized. +/// +/// This exception is thrown when attempting to use the MQClient before it has +/// been properly initialized using the `MQClient.initialize()` method. +final class MQClientNotInitializedException extends MQClientException { + /// Creates a new [MQClientNotInitializedException] instance. + MQClientNotInitializedException() + : super(ExceptionStrings.mqClientNotInitialized()); +} diff --git a/core/mqueue/lib/src/core/exceptions/queue_exceptions.dart b/core/mqueue/lib/src/core/exceptions/queue_exceptions.dart new file mode 100644 index 00000000..5a2947db --- /dev/null +++ b/core/mqueue/lib/src/core/exceptions/queue_exceptions.dart @@ -0,0 +1,54 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [QueueException] class represents a base exception related to queues. +/// +/// It is used to handle exceptions that may occur when working with queues, +/// such as when a queue is not registered or when there are subscribers to a +/// queue. +/// +/// Subclasses of [QueueException] can provide more specific information about +/// the nature of the exception. +abstract class QueueException implements Exception { + /// Creates a new [QueueException] with the specified error [message]. + QueueException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [QueueNotRegisteredException] class represents an exception that occurs +/// when a queue with a specific ID is not registered. +/// +/// This exception is thrown when attempting to perform an operation on an +/// unregistered queue. +final class QueueNotRegisteredException extends QueueException { + /// Creates a new [QueueNotRegisteredException] instance with the specified + /// [queueId]. + QueueNotRegisteredException(String queueId) + : super(ExceptionStrings.queueNotRegistered(queueId)); +} + +/// The [QueueHasSubscribersException] class represents an exception that occurs +/// when there are active subscribers to a queue. +/// +/// This exception is thrown when attempting to delete a queue that still has +/// subscribers listening to it. +final class QueueHasSubscribersException extends QueueException { + /// Creates a new [QueueHasSubscribersException] instance with the specified + /// [queueId]. + QueueHasSubscribersException(String queueId) + : super(ExceptionStrings.queueHasSubscribers(queueId)); +} + +/// The [QueueIdNullException] class represents an exception that occurs when +/// attempting to create a queue with a null name. +/// +/// This exception is thrown when the name of the queue is not provided and is +/// null. +final class QueueIdNullException extends QueueException { + /// Creates a new [QueueIdNullException] instance. + QueueIdNullException() : super(ExceptionStrings.queueIdNull()); +} diff --git a/core/mqueue/lib/src/core/exceptions/registrar_exceptions.dart b/core/mqueue/lib/src/core/exceptions/registrar_exceptions.dart new file mode 100644 index 00000000..c5ef09be --- /dev/null +++ b/core/mqueue/lib/src/core/exceptions/registrar_exceptions.dart @@ -0,0 +1,43 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [RegistrarException] class represents a base exception related to +/// registrar operations. +/// +/// It is used to handle exceptions that may occur when working with registrar +/// objects, which are responsible for managing and registering items. +/// +/// Subclasses of [RegistrarException] can provide more specific information +/// about the nature of the exception. +abstract class RegistrarException implements Exception { + /// Creates a new [RegistrarException] with the specified error [message]. + RegistrarException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [IdAlreadyRegisteredException] class represents an exception that occurs +/// when attempting to register an ID that is already registered in a registrar. +/// +/// This exception is thrown when a duplicate ID is detected during the +/// registration process. +final class IdAlreadyRegisteredException extends RegistrarException { + /// Creates a new [IdAlreadyRegisteredException] instance with the specified + /// [id]. + IdAlreadyRegisteredException(String id) + : super(ExceptionStrings.idAlreadyRegistered(id)); +} + +/// The [IdNotRegisteredException] class represents an exception that occurs +/// when attempting to access an ID that is not registered in a registrar. +/// +/// This exception is thrown when an operation is performed on an unregistered +/// ID. +final class IdNotRegisteredException extends RegistrarException { + /// Creates a new [IdNotRegisteredException] instance with the specified [id]. + IdNotRegisteredException(String id) + : super(ExceptionStrings.idNotRegistered(id)); +} diff --git a/core/mqueue/lib/src/core/exceptions/routing_key_exceptions.dart b/core/mqueue/lib/src/core/exceptions/routing_key_exceptions.dart new file mode 100644 index 00000000..407b2f08 --- /dev/null +++ b/core/mqueue/lib/src/core/exceptions/routing_key_exceptions.dart @@ -0,0 +1,30 @@ +import 'package:angel3_mq/src/core/constants/error_strings.dart'; + +/// The [RoutingKeyException] class represents a base exception related to +/// routing key operations. +/// +/// It is used to handle exceptions that may occur when working with routing +/// keys, which are used for message routing in message broker systems. +/// +/// Subclasses of [RoutingKeyException] can provide more specific information +/// about the nature of the exception. +abstract class RoutingKeyException implements Exception { + /// Creates a new [RoutingKeyException] with the specified error [message]. + RoutingKeyException(this.message); + + /// The error message associated with the exception. + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// The [RoutingKeyRequiredException] class represents an exception that occurs +/// when a routing key is required for a specific operation but is not provided. +/// +/// This exception is thrown when an operation expects a routing key to be +/// provided, but it is missing. +final class RoutingKeyRequiredException extends RoutingKeyException { + /// Creates a new [RoutingKeyRequiredException] instance. + RoutingKeyRequiredException() : super(ExceptionStrings.routingKeyRequired()); +} diff --git a/core/mqueue/lib/src/core/registrar/simple_registrar.dart b/core/mqueue/lib/src/core/registrar/simple_registrar.dart new file mode 100644 index 00000000..472d4c08 --- /dev/null +++ b/core/mqueue/lib/src/core/registrar/simple_registrar.dart @@ -0,0 +1,100 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; + +/// A generic registrar for managing and storing objects by their unique +/// identifiers. +/// +/// The [Registrar] class allows you to register, get, unregister, and manage +/// objects associated with unique identifiers (IDs). It provides a way to store +/// and access objects in a key-value fashion. +/// +/// Example: +/// ```dart +/// final registrar = Registrar(); +/// +/// // Register objects with unique IDs. +/// registrar.register('user_1', 'Alice'); +/// registrar.register('user_2', 'Bob'); +/// +/// // Get an object by its ID. +/// final user1 = registrar.get('user_1'); // Returns 'Alice' +/// +/// // Check if an object with a specific ID exists. +/// final hasUser2 = registrar.has('user_2'); // Returns true +/// +/// // Unregister an object by its ID. +/// registrar.unregister('user_1'); +/// +/// // Check the number of registered objects. +/// final count = registrar.count; // Returns 1 +/// ``` +final class Registrar { + /// A map to store objects with their associated IDs. + final Map _registry = {}; + + /// Registers an object with a unique ID. + /// + /// The [id] parameter represents the unique identifier for the object. + /// The [value] parameter represents the object to be registered. + /// + /// If an object with the same ID already exists, an + /// [IdAlreadyRegisteredException] is thrown. + void register(String id, T value) { + if (_registry.containsKey(id)) { + throw IdAlreadyRegisteredException(id); + } + _registry[id] = value; + } + + /// Gets an object by its unique ID. + /// + /// The [id] parameter represents the unique identifier of the object to + /// retrieve. + /// + /// If no object with the specified ID is found, an [IdNotRegisteredException] + /// is thrown. + T get(String id) { + if (!_registry.containsKey(id)) { + throw IdNotRegisteredException(id); + } + return _registry[id]!; + } + + /// Retrieves a list of all registered objects. + List getAll() => _registry.values.toList(); + + /// Unregisters an object by its unique ID. + /// + /// The [id] parameter represents the unique identifier of the object to + /// unregister. + /// + /// If no object with the specified ID is found, an [IdNotRegisteredException] + /// is thrown. + void unregister(String id) { + if (!_registry.containsKey(id)) { + throw IdNotRegisteredException(id); + } + _registry.remove(id); + } + + /// Clears the registrar, removing all registered objects. + void clear() => _registry.clear(); + + /// Checks if an object with a specific ID is registered. + /// + /// The [id] parameter represents the unique identifier to check. + /// + /// Returns `true` if an object with the specified ID is registered; + /// otherwise, `false`. + bool has(String id) => _registry.containsKey(id); + + /// Returns the count of registered objects. + int get count => _registry.length; + + @override + String toString() { + return ''' +Registrar( +\t${_registry.entries.map((e) => '${e.key}: ${e.value}').join(',\n\t')} + )'''; + } +} diff --git a/core/mqueue/lib/src/exchange/default_exchange.dart b/core/mqueue/lib/src/exchange/default_exchange.dart new file mode 100644 index 00000000..4c20164a --- /dev/null +++ b/core/mqueue/lib/src/exchange/default_exchange.dart @@ -0,0 +1,86 @@ +import 'package:angel3_mq/src/binding/binding.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/exchange/exchange.base.dart'; +import 'package:angel3_mq/src/exchange/exchange_interface.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// A class representing the default message exchange for message routing. +/// +/// The `DefaultExchange` class is a specific implementation of the +/// `BaseExchange` abstract base class, representing the default exchange. +/// It provides functionality for binding queues, forwarding messages based on +/// routing keys, and preventing unbinding from the default exchange. +/// +/// Example: +/// ```dart +/// final defaultExchange = DefaultExchange('default_exchange'); +/// +/// // Bind a queue to the default exchange. +/// final queue = Queue('my_queue'); +/// defaultExchange.bindQueue(queue: queue, bindingKey: 'my_routing_key'); +/// +/// // Forward a message to the default exchange using a routing key. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// defaultExchange.forwardMessage(message, routingKey: 'my_routing_key'); +/// ``` +final class DefaultExchange extends BaseExchange implements ExchangeInterface { + /// Creates a new instance of the default exchange with the specified [id]. + /// + /// The [id] parameter represents the unique identifier for the default + /// exchange. + DefaultExchange(super.id); + + @override + void bindQueue({ + required Queue queue, + required String bindingKey, + }) => + (bindings.has(bindingKey) + ? bindings.get(bindingKey) + : _registerAndGetBinding(bindingKey)) + ..addQueue(queue); + + Binding _registerAndGetBinding(String bindingKey) { + bindings.register(bindingKey, Binding(bindingKey)); + return bindings.get(bindingKey); + } + + @override + void unbindQueue({ + required String queueId, + required String bindingKey, + }) { + (bindings.has(bindingKey) + ? bindings.get(bindingKey) + : throw BindingKeyNotFoundException(bindingKey)) + .removeQueue(queueId); + + if (!bindings.get(bindingKey).hasQueues()) { + bindings.unregister(bindingKey); + } + } + + @override + void forwardMessage({ + required Message message, + String? routingKey, + }) => + (bindings.has( + routingKey ?? (throw RoutingKeyRequiredException()), + ) + ? bindings.get(routingKey) + : throw BindingKeyNotFoundException(routingKey)) + .publishMessage(message); + + @override + void deleteQueue(String queueId) { + for (final binding in bindings.getAll()) { + binding.removeQueue(queueId); + } + } +} diff --git a/core/mqueue/lib/src/exchange/direct_exchange.dart b/core/mqueue/lib/src/exchange/direct_exchange.dart new file mode 100644 index 00000000..2560f299 --- /dev/null +++ b/core/mqueue/lib/src/exchange/direct_exchange.dart @@ -0,0 +1,89 @@ +import 'package:angel3_mq/src/binding/binding.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/exchange/exchange.base.dart'; +import 'package:angel3_mq/src/exchange/exchange_interface.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// A class representing a direct message exchange for message routing. +/// +/// The `DirectExchange` class is a specific implementation of the +/// `BaseExchange` abstract base class, representing a direct exchange. A +/// direct exchange routes messages to queues based on matching routing keys. +/// It provides functionality for binding queues, forwarding messages based on +/// routing keys, and unbinding queues from the direct exchange. +/// +/// Example: +/// ```dart +/// final directExchange = DirectExchange('my_direct_exchange'); +/// +/// // Bind queues to the direct exchange with different routing keys. +/// final queue1 = Queue('queue_1'); +/// final queue2 = Queue('queue_2'); +/// directExchange.bindQueue(queue: queue1, bindingKey: 'routing_key_1'); +/// directExchange.bindQueue(queue: queue2, bindingKey: 'routing_key_2'); +/// +/// // Forward a message with a matching routing key to the appropriate queue. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// directExchange.forwardMessage(message, routingKey: 'routing_key_1'); +/// ``` +final class DirectExchange extends BaseExchange implements ExchangeInterface { + /// Creates a new instance of the direct exchange with the specified [id]. + /// + /// The [id] parameter represents the unique identifier for the direct + /// exchange. + DirectExchange(super.id); + + @override + void bindQueue({ + required Queue queue, + required String bindingKey, + }) => + (bindings.has(bindingKey) + ? bindings.get(bindingKey) + : _registerAndGetBinding(bindingKey)) + .addQueue(queue); + + Binding _registerAndGetBinding(String bindingKey) { + bindings.register(bindingKey, Binding(bindingKey)); + return bindings.get(bindingKey); + } + + @override + void unbindQueue({ + required String queueId, + required String bindingKey, + }) { + (bindings.has(bindingKey) + ? bindings.get(bindingKey) + : throw BindingKeyNotFoundException(bindingKey)) + .removeQueue(queueId); + + if (!bindings.get(bindingKey).hasQueues()) { + bindings.unregister(bindingKey); + } + } + + @override + void forwardMessage({ + required Message message, + String? routingKey, + }) => + (bindings.has( + routingKey ?? (throw RoutingKeyRequiredException()), + ) + ? bindings.get(routingKey) + : throw BindingKeyNotFoundException(routingKey)) + .publishMessage(message); + + @override + void deleteQueue(String queueId) { + for (final binding in bindings.getAll()) { + binding.removeQueue(queueId); + } + } +} diff --git a/core/mqueue/lib/src/exchange/exchange.base.dart b/core/mqueue/lib/src/exchange/exchange.base.dart new file mode 100644 index 00000000..18e81848 --- /dev/null +++ b/core/mqueue/lib/src/exchange/exchange.base.dart @@ -0,0 +1,27 @@ +import 'package:angel3_mq/src/binding/binding.dart'; +import 'package:angel3_mq/src/core/registrar/simple_registrar.dart'; +import 'package:angel3_mq/src/exchange/exchange_interface.dart'; + +/// An abstract base class representing an exchange for message routing. +/// +/// The `BaseExchange` abstract base class defines the core functionality of a +/// message exchange for routing messages to specific queues or bindings. +/// +/// Example: +/// ```dart +/// class MyExchange extends BaseExchange { +/// // Custom implementation of the exchange. +/// } +/// ``` +abstract base class BaseExchange implements ExchangeInterface { + /// Creates a new exchange with the specified [id]. + /// + /// The [id] parameter represents the unique identifier for the exchange. + BaseExchange(this.id); + + /// The unique identifier for the exchange. + final String id; + + /// A registrar for managing bindings associated with the exchange. + Registrar bindings = Registrar(); +} diff --git a/core/mqueue/lib/src/exchange/exchange_interface.dart b/core/mqueue/lib/src/exchange/exchange_interface.dart new file mode 100644 index 00000000..638ca728 --- /dev/null +++ b/core/mqueue/lib/src/exchange/exchange_interface.dart @@ -0,0 +1,51 @@ +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// An abstract interface class defining the contract for managing exchanges. +/// +/// The `ExchangeInterface` defines a contract for classes that are responsible +/// for managing exchanges. Implementing classes must provide functionality for +/// binding queues to the exchange, unbinding queues from the exchange, +/// forwarding messages to queues or bindings, and removing queues from all +/// associated bindings. +/// +/// Example: +/// ```dart +/// class MyExchange implements ExchangeInterface { +/// // Custom implementation of the exchange. +/// } +/// ``` +abstract interface class ExchangeInterface { + /// Binds a queue to the exchange with a specific binding key. + /// + /// The [queue] parameter represents the queue to be bound to the exchange. + /// The [bindingKey] parameter represents the binding key for the queue. + void bindQueue({ + required Queue queue, + required String bindingKey, + }); + + /// Unbinds a queue from the exchange based on its ID and binding key. + /// + /// The [queueId] parameter represents the ID of the queue to be unbound. + /// The [bindingKey] parameter represents the binding key for the queue. + void unbindQueue({ + required String queueId, + required String bindingKey, + }); + + /// Forwards a message to queues or bindings based on the routing key. + /// + /// The [message] parameter represents the message to be forwarded. + /// The [routingKey] parameter represents the optional routing key to + /// determine the destination queues or bindings. + void forwardMessage({ + required Message message, + String? routingKey, + }); + + /// Removes a queue from all associated bindings. + /// + /// The [queueId] parameter represents the ID of the queue to be removed. + void deleteQueue(String queueId); +} diff --git a/core/mqueue/lib/src/exchange/fanout_exchange.dart b/core/mqueue/lib/src/exchange/fanout_exchange.dart new file mode 100644 index 00000000..a7a225ee --- /dev/null +++ b/core/mqueue/lib/src/exchange/fanout_exchange.dart @@ -0,0 +1,70 @@ +import 'package:angel3_mq/src/binding/binding.dart'; +import 'package:angel3_mq/src/exchange/exchange.base.dart'; +import 'package:angel3_mq/src/exchange/exchange_interface.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// A class representing a fanout message exchange for message routing. +/// +/// The `FanoutExchange` class is a specific implementation of the +/// `BaseExchange` abstract base class, representing a fanout exchange. +/// A fanout exchange routes messages to all associated queues without +/// considering routing keys. It provides functionality for binding queues, +/// forwarding messages to all associated queues, and unbinding queues +/// from the fanout exchange. +/// +/// Example: +/// ```dart +/// final fanoutExchange = FanoutExchange('my_fanout_exchange'); +/// +/// // Bind multiple queues to the fanout exchange. +/// final queue1 = Queue('queue_1'); +/// final queue2 = Queue('queue_2'); +/// fanoutExchange.bindQueue(queue: queue1, bindingKey: 'binding_key_1'); +/// fanoutExchange.bindQueue(queue: queue2, bindingKey: 'binding_key_2'); +/// +/// // Forward a message to all associated queues in the fanout exchange. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// fanoutExchange.forwardMessage(message); +/// ``` +final class FanoutExchange extends BaseExchange implements ExchangeInterface { + /// Creates a new instance of the fanout exchange with the specified [id]. + /// + /// The [id] parameter represents the unique identifier for the fanout + /// exchange. + FanoutExchange(super.id) { + bindings.register('', Binding('')); + } + + @override + void bindQueue({ + required Queue queue, + required String bindingKey, + }) => + bindings.get('').addQueue(queue); + + @override + void unbindQueue({ + required String queueId, + required String bindingKey, + }) => + bindings.get('').removeQueue(queueId); + + @override + void forwardMessage({ + required Message message, + String? routingKey, + }) => + bindings.get('').publishMessage(message); + + @override + void deleteQueue(String queueId) { + for (final binding in bindings.getAll()) { + binding.removeQueue(queueId); + } + } +} diff --git a/core/mqueue/lib/src/message/message.base.dart b/core/mqueue/lib/src/message/message.base.dart new file mode 100644 index 00000000..57e101d8 --- /dev/null +++ b/core/mqueue/lib/src/message/message.base.dart @@ -0,0 +1,43 @@ +/// Represents a base message with headers, payload, and an optional timestamp. +/// +/// A [BaseMessage] is a fundamental unit of data used in various messaging +/// systems. It typically contains metadata in the form of headers, the actual +/// payload, and an optional timestamp indicating when the message was created. +/// +/// The `headers` property is a map that can contain additional information +/// about the message, such as content type, sender, or any custom metadata. +/// +/// The `payload` property stores the main content of the message. It can be +/// of any type, allowing flexibility in the data that can be transmitted. +/// +/// The `timestamp` property, if provided, represents the time when the message +/// was created. It is formatted as an ISO 8601 string. +abstract class BaseMessage { + /// Creates a new `BaseMessage` with the specified headers, payload, and + /// timestamp. + /// + /// The [headers] parameter is a map that can contain additional information + /// about the message. It is optional and defaults to an empty map if not + /// provided. + /// + /// The [payload] parameter represents the main content of the message and is + /// required. + /// + /// The [timestamp] parameter is an optional ISO 8601 formatted timestamp + /// indicating when the message was created. If not provided, it will be + /// `null`. + BaseMessage( + Map? headers, + this.payload, + this.timestamp, + ) : headers = headers ?? {}; + + /// A map containing headers or metadata associated with the message. + final Map headers; + + /// The main content of the message. + final Object payload; + + /// An optional timestamp indicating when the message was created. + final String? timestamp; +} diff --git a/core/mqueue/lib/src/message/message.dart b/core/mqueue/lib/src/message/message.dart new file mode 100644 index 00000000..fadb80c2 --- /dev/null +++ b/core/mqueue/lib/src/message/message.dart @@ -0,0 +1,76 @@ +import 'package:angel3_mq/src/message/message.base.dart'; + +/// Represents a message with headers, payload, and an optional timestamp. +/// +/// A [Message] is a specific type of message that extends the [BaseMessage] +/// class. +/// +/// Example: +/// ```dart +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// ``` +class Message extends BaseMessage { + /// Creates a new [Message] with the specified headers, payload, and + /// timestamp. + /// + /// The [headers] parameter is a map that can contain additional information + /// about the message. It is optional and defaults to an empty map if not + /// provided. + /// + /// The [payload] parameter represents the main content of the message and is + /// required. + /// + /// The [timestamp] parameter is an optional ISO 8601 formatted timestamp + /// indicating when the message was created. If not provided, the current + /// timestamp will be used. + /// + /// Example: + /// ```dart + /// final message = Message( + /// headers: {'contentType': 'json', 'sender': 'Alice'}, + /// payload: {'text': 'Hello, World!'}, + /// timestamp: '2023-09-07T12:00:002', + /// ); + /// ``` + Message({ + required Object payload, + Map? headers, + String? timestamp, + }) : super( + headers, + payload, + timestamp ?? DateTime.now().toUtc().toIso8601String(), + ); + + /// Returns a human-readable string representation of the message. + /// + /// Example: + /// ```dart + /// final message = Message( + /// headers: {'contentType': 'json', 'sender': 'Alice'}, + /// payload: {'text': 'Hello, World!'}, + /// timestamp: '2023-09-07T12:00:002', + /// ); + /// + /// print(message.toString()); + /// // Output: + /// // Message{ + /// // headers: {contentType: json, sender: Alice}, + /// // payload: {text: Hello, World!}, + /// // timestamp: 2023-09-07T12:00:002, + /// // } + /// ``` + @override + String toString() { + return ''' +Message{ + headers: $headers, + payload: $payload, + timestamp: $timestamp, + }'''; + } +} diff --git a/core/mqueue/lib/src/mq/mq.base.dart b/core/mqueue/lib/src/mq/mq.base.dart new file mode 100644 index 00000000..aba73789 --- /dev/null +++ b/core/mqueue/lib/src/mq/mq.base.dart @@ -0,0 +1,14 @@ +/// An abstract base class representing a message queue client. +/// +/// The `BaseMQClient` abstract base class defines the core functionality and +/// contract for implementing message queue clients. It serves as a foundation +/// for creating client implementations that interact with message queues for +/// sending and receiving messages. +/// +/// Example: +/// ```dart +/// class MyMQClient extends BaseMQClient { +/// // Custom implementation of the message queue client. +/// } +/// ``` +abstract base class BaseMQClient {} diff --git a/core/mqueue/lib/src/mq/mq.dart b/core/mqueue/lib/src/mq/mq.dart new file mode 100644 index 00000000..10f4ca64 --- /dev/null +++ b/core/mqueue/lib/src/mq/mq.dart @@ -0,0 +1,246 @@ +import 'package:angel3_mq/src/core/constants/enums.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/core/registrar/simple_registrar.dart'; +import 'package:angel3_mq/src/exchange/default_exchange.dart'; +import 'package:angel3_mq/src/exchange/direct_exchange.dart'; +import 'package:angel3_mq/src/exchange/exchange.base.dart'; +import 'package:angel3_mq/src/exchange/fanout_exchange.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/mq/mq.base.dart'; +import 'package:angel3_mq/src/mq/mq.interface.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; + +/// A class representing a message queue client with various messaging +/// functionalities. +/// +/// The `MQClient` class is an implementation of both the `BaseMQClient` class +/// and the `MQClientInterface` interface. It provides features for interacting +/// with message queues, including declaring and managing queues and exchanges, +/// sending and receiving messages, and binding/unbinding queues to/from exchanges. +/// +/// Example: +/// ```dart +/// // Initialize the message queue client. +/// MQClient.initialize(); +/// +/// // Declare a queue and an exchange. +/// final queueId = MQClient.instance.declareQueue(); +/// final exchangeName = 'my_direct_exchange'; +/// MQClient.instance.declareExchange( +/// exchangeName: exchangeName, +/// exchangeType: ExchangeType.direct, +/// ); +/// +/// // Bind the queue to the exchange. +/// MQClient.instance.bindQueue( +/// queueId: queueId, +/// exchangeName: exchangeName, +/// ); +/// +/// // Send a message to the exchange. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// MQClient.instance.sendMessage( +/// exchangeName: exchangeName, +/// message: message, +/// routingKey: queueId, +/// ); +/// +/// // Fetch messages from the queue. +/// final messageStream = MQClient.instance.fetchQueue(queueId); +/// messageStream.listen((message) { +/// print('Received message: $message'); +/// }); +/// ``` +final class MQClient extends BaseMQClient implements MQClientInterface { + /// Private constructor to create the `MQClient` instance. + MQClient._internal() { + _exchanges.register('', DefaultExchange('')); + } + + /// Initializes the `MQClient` and creates a singleton instance. + /// + /// This method should be called before using the `MQClient`. + factory MQClient.initialize() => _instance ??= MQClient._internal(); + + /// Singleton instance of the `MQClient`. + static MQClient? _instance; + + /// Gets the singleton instance of the `MQClient`. + /// + /// Throws a [MQClientNotInitializedException] if the client has not been + /// initialized. + static MQClient get instance => + _instance ?? (throw MQClientNotInitializedException()); + + final Registrar _exchanges = Registrar(); + final Registrar _queues = Registrar(); + + @override + String declareQueue(String queueId) { + try { + _queues.register(queueId, Queue(queueId)); + + _exchanges.get('').bindQueue( + queue: _queues.get(queueId), + bindingKey: queueId, + ); + + return queueId; + } on IdAlreadyRegisteredException catch (_) { + return queueId; + } + } + + @override + void deleteQueue(String queueId) { + try { + final queue = _queues.get(queueId); + + if (queue.hasListeners()) { + throw QueueHasSubscribersException(queueId); + } else { + _deleteQueue(queueId); + } + } on IdNotRegisteredException catch (_) { + throw QueueNotRegisteredException(queueId); + } + } + + void _deleteQueue(String queueId) { + _queues.get(queueId).dispose(); + _exchanges.getAll().forEach( + (BaseExchange exchange) => exchange.deleteQueue(queueId), + ); + _queues.unregister(queueId); + } + + @override + Stream fetchQueue(String queueId) => _fetchQueue(queueId).dataStream; + + Queue _fetchQueue(String queueId) { + try { + return _queues.get(queueId); + } on IdNotRegisteredException catch (_) { + throw QueueNotRegisteredException(queueId); + } + } + + @override + List listQueues() => _queues + .getAll() + .map( + (Queue queue) => queue.id, + ) + .toList(); + + @override + void sendMessage({ + required Message message, + String? exchangeName, + String? routingKey, + }) { + try { + _exchanges + .get(exchangeName ?? '') + .forwardMessage(routingKey: routingKey, message: message); + } on IdNotRegisteredException catch (_) { + throw ExchangeNotRegisteredException(exchangeName ?? ''); + } + } + + @override + Message? getLatestMessage(String queueId) => + _fetchQueue(queueId).latestMessage; + + @override + void bindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }) { + try { + final exchange = _exchanges.get(exchangeName); + switch (exchange) { + case DirectExchange _: + if (bindingKey == null) { + throw BindingKeyRequiredException(); + } + exchange.bindQueue( + queue: _fetchQueue(queueId), + bindingKey: bindingKey, + ); + case FanoutExchange _: + exchange.bindQueue( + queue: _fetchQueue(queueId), + bindingKey: '', + ); + default: + return; + } + } on IdNotRegisteredException catch (_) { + throw ExchangeNotRegisteredException(exchangeName); + } + } + + @override + void unbindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }) { + try { + final exchange = _exchanges.get(exchangeName); + if (exchange.runtimeType == DirectExchange && bindingKey == null) { + throw BindingKeyRequiredException(); + } + exchange.unbindQueue( + queueId: queueId, + bindingKey: bindingKey ?? '', + ); + } on IdNotRegisteredException catch (_) { + throw ExchangeNotRegisteredException(exchangeName); + } + } + + @override + void declareExchange({ + required String exchangeName, + required ExchangeType exchangeType, + }) { + try { + switch (exchangeType) { + case ExchangeType.direct: + _exchanges.register(exchangeName, DirectExchange(exchangeName)); + case ExchangeType.fanout: + _exchanges.register(exchangeName, FanoutExchange(exchangeName)); + case ExchangeType.base: + throw InvalidExchangeTypeException(); + } + } on IdAlreadyRegisteredException catch (_) { + return; + } + } + + @override + void deleteExchange(String exchangeName) { + try { + _exchanges.unregister(exchangeName); + } catch (_) { + return; + } + } + + @override + void close() { + _queues.getAll().forEach( + (Queue queue) => queue.dispose(), + ); + _queues.clear(); + _exchanges.clear(); + _instance = null; + } +} diff --git a/core/mqueue/lib/src/mq/mq.interface.dart b/core/mqueue/lib/src/mq/mq.interface.dart new file mode 100644 index 00000000..a1301c10 --- /dev/null +++ b/core/mqueue/lib/src/mq/mq.interface.dart @@ -0,0 +1,115 @@ +import 'package:angel3_mq/src/core/constants/enums.dart'; +import 'package:angel3_mq/src/message/message.dart'; + +/// An abstract interface class defining the contract for a message queue +/// client. +/// +/// The `MQClientInterface` abstract interface class defines a contract for +/// classes that implement a message queue client. Implementing classes must +/// provide methods for fetching messages from a queue, sending messages to an +/// exchange, declaring queues and exchanges, deleting queues and exchanges, +/// binding and unbinding queues from exchanges, and more. +/// +/// Example: +/// ```dart +/// class MyMQClient implements MQClientInterface { +/// // Custom implementation of the message queue client. +/// } +/// ``` +abstract interface class MQClientInterface { + /// Declares a queue in the message queue system. + /// + /// The [queueId] parameter represents the optional ID for the queue. + /// + /// Returns the ID of the declared queue. + String declareQueue(String queueId); + + /// Deletes a queue from the message queue system. + /// + /// The [queueId] parameter represents the ID of the queue to be deleted. + void deleteQueue(String queueId); + + /// Fetches messages from a queue. + /// + /// The [queueId] parameter represents the ID of the queue to fetch messages + /// from. + /// + /// Returns a stream of messages from the specified queue. + Stream fetchQueue(String queueId); + + /// Retrieves the list of queues. + /// + /// Returns a list of queue IDs. + List listQueues(); + + /// Sends a message to an exchange for routing to queues. + /// + /// The [exchangeName] parameter represents the name of the exchange to send + /// the message to. + /// The [message] parameter represents the message to be sent. + /// The [routingKey] parameter represents the optional routing key for message + /// routing within the exchange. + void sendMessage({ + required Message message, + String? exchangeName, + String? routingKey, + }); + + /// Retrieves the latest message from a queue. + /// + /// The [queueId] parameter represents the ID of the queue to fetch the latest + /// message from. + /// + /// Returns the latest message from the specified queue or `null` if the queue + /// is empty. + Message? getLatestMessage(String queueId); + + /// Binds a queue to an exchange for message routing. + /// + /// The [queueId] parameter represents the ID of the queue to be bound. + /// The [exchangeName] parameter represents the name of the exchange to bind + /// to. + /// The [bindingKey] parameter represents the optional binding key for routing + /// messages to the queue within the exchange. + void bindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }); + + /// Unbinds a queue from an exchange to stop message routing. + /// + /// The [queueId] parameter represents the ID of the queue to be unbound. + /// The [exchangeName] parameter represents the name of the exchange to unbind + /// from. + /// The [bindingKey] parameter represents the optional binding key previously + /// used for binding. + void unbindQueue({ + required String queueId, + required String exchangeName, + String? bindingKey, + }); + + /// Declares an exchange in the message queue system. + /// + /// The [exchangeName] parameter represents the name of the exchange to be + /// declared. + /// The [exchangeType] parameter represents the type of exchange (e.g., + /// direct, fanout). + void declareExchange({ + required String exchangeName, + required ExchangeType exchangeType, + }); + + /// Deletes an exchange from the message queue system. + /// + /// The [exchangeName] parameter represents the name of the exchange to be + /// deleted. + void deleteExchange(String exchangeName); + + /// Closes the connection to the message queue system. + /// + /// This method should be called when the message queue client is no longer + /// needed. + void close(); +} diff --git a/core/mqueue/lib/src/producer/producer.dart b/core/mqueue/lib/src/producer/producer.dart new file mode 100644 index 00000000..2ec6bb57 --- /dev/null +++ b/core/mqueue/lib/src/producer/producer.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/mq/mq.dart'; +import 'package:angel3_mq/src/producer/producer.interface.dart'; + +/// A mixin implementing the `ProducerInterface` for message production. +/// +/// The `Producer` mixin provides a concrete implementation of the +/// `ProducerInterface` for message production. It allows classes to easily send +/// messages to exchanges, send RPC (Remote Procedure Call) messages, and set a +/// callback for handling push notifications. +/// +/// Example: +/// ```dart +/// class MyMessageProducer with Producer { +/// // Custom implementation of the message producer. +/// } +/// ``` +@Deprecated('Please use `ProducerMixin` instead. ' + 'This will be removed in v2.0.0') +mixin Producer implements ProducerInterface { + /// A callback function for handling push notifications (received messages). + Function(Message message)? _callback; + + @override + void sendMessage({ + required Object payload, + String? exchangeName, + Map? headers, + String? routingKey, + String? timestamp, + }) { + final newMessage = Message( + payload: payload, + headers: headers, + timestamp: timestamp, + ); + MQClient.instance.sendMessage( + exchangeName: exchangeName, + routingKey: routingKey, + message: newMessage, + ); + + _callback?.call(newMessage); + } + + @override + Future sendRPCMessage({ + required String processId, + required String exchangeName, + Map? args, + String? routingKey, + T Function(Object)? mapper, + String? timestamp, + }) async { + final Completer completer = + mapper == null ? Completer() : Completer(); + + final newMessage = Message( + payload: 'RPC', + headers: { + 'type': 'RPC', + 'processId': processId, + 'args': args, + 'completer': completer, + }, + timestamp: timestamp, + ); + + MQClient.instance.sendMessage( + exchangeName: exchangeName, + routingKey: routingKey, + message: newMessage, + ); + + if (mapper == null) { + _callback?.call(newMessage); + final res = await completer.future.then((value) => value); + return res; + } else { + _callback?.call(newMessage); + final rawData = await completer.future.then((value) => value); + final data = mapper(rawData); + return data; + } + } + + @override + void setPushCallback(Function(Message message) callback) => + _callback = callback; +} diff --git a/core/mqueue/lib/src/producer/producer.interface.dart b/core/mqueue/lib/src/producer/producer.interface.dart new file mode 100644 index 00000000..2fec00e9 --- /dev/null +++ b/core/mqueue/lib/src/producer/producer.interface.dart @@ -0,0 +1,56 @@ +import 'package:angel3_mq/src/message/message.dart'; + +/// An abstract interface class defining the contract for a message producer. +/// +/// The `ProducerInterface` abstract interface class defines a contract for +/// classes that implement a message producer. Implementing classes must provide +/// methods for sending messages to exchanges, sending RPC (Remote Procedure +/// Call) messages, and setting a callback for push notifications. +/// +/// Example: +/// ```dart +/// class MyProducer implements ProducerInterface { +/// // Custom implementation of the message producer. +/// } +/// ``` +abstract interface class ProducerInterface { + /// Sends a message to an exchange. + /// + /// The [payload] parameter represents the message payload to send. + /// The [exchangeName] parameter is the name of the exchange to send the + /// message to. + /// The [headers] parameter is an optional map of headers for the message. + /// The [routingKey] parameter is an optional routing key for the message. + void sendMessage({ + required Object payload, + required String exchangeName, + Map? headers, + String? routingKey, + }); + + /// Sends an RPC (Remote Procedure Call) message and awaits a response. + /// + /// The [processId] parameter is a unique identifier for the RPC request. + /// The [args] parameter is an optional map of arguments for the RPC request. + /// The [exchangeName] parameter is the name of the exchange for RPC + /// communication. + /// The [routingKey] parameter is an optional routing key for the RPC message. + /// The [mapper] parameter is an optional function to map the response + /// payload. + /// + /// Returns a future that completes with the response payload. + Future sendRPCMessage({ + required String processId, + required String exchangeName, + Map? args, + String? routingKey, + T Function(Object)? mapper, + }); + + /// Sets a callback function to be called after every 'sendMessage` or + /// `sendRPCMessage`. + /// + /// The [callback] parameter is a function that will be invoked when a push + /// notification (message) is received. + void setPushCallback(Function(Message message) callback); +} diff --git a/core/mqueue/lib/src/producer/producer.mixin.dart b/core/mqueue/lib/src/producer/producer.mixin.dart new file mode 100644 index 00000000..5953fbbf --- /dev/null +++ b/core/mqueue/lib/src/producer/producer.mixin.dart @@ -0,0 +1,90 @@ +import 'dart:async'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/mq/mq.dart'; +import 'package:angel3_mq/src/producer/producer.interface.dart'; + +/// A mixin implementing the `ProducerInterface` for message production. +/// +/// The `ProducerMixin` mixin provides a concrete implementation of the +/// `ProducerInterface` for message production. It allows classes to easily send +/// messages to exchanges, send RPC (Remote Procedure Call) messages, and set a +/// callback for handling push notifications. +/// +/// Example: +/// ```dart +/// class MyMessageProducer with ProducerMixin { +/// // Custom implementation of the message producer. +/// } +/// ``` +mixin ProducerMixin implements ProducerInterface { + /// A callback function for handling push notifications (received messages). + Function(Message message)? _callback; + + @override + void sendMessage({ + required Object payload, + String? exchangeName, + Map? headers, + String? routingKey, + String? timestamp, + }) { + final newMessage = Message( + payload: payload, + headers: headers, + timestamp: timestamp, + ); + + MQClient.instance.sendMessage( + exchangeName: exchangeName, + routingKey: routingKey, + message: newMessage, + ); + + _callback?.call(newMessage); + } + + @override + Future sendRPCMessage({ + required String processId, + required String exchangeName, + Map? args, + String? routingKey, + T Function(Object)? mapper, + String? timestamp, + }) async { + final Completer completer = + mapper == null ? Completer() : Completer(); + + final newMessage = Message( + payload: 'RPC', + headers: { + 'type': 'RPC', + 'processId': processId, + 'args': args, + 'completer': completer, + }, + timestamp: timestamp, + ); + + MQClient.instance.sendMessage( + exchangeName: exchangeName, + routingKey: routingKey, + message: newMessage, + ); + + if (mapper == null) { + _callback?.call(newMessage); + final res = await completer.future.then((value) => value); + return res; + } else { + _callback?.call(newMessage); + final rawData = await completer.future.then((value) => value); + final data = mapper(rawData); + return data; + } + } + + @override + void setPushCallback(Function(Message message) callback) => + _callback = callback; +} diff --git a/core/mqueue/lib/src/queue/data_stream.base.dart b/core/mqueue/lib/src/queue/data_stream.base.dart new file mode 100644 index 00000000..5cb83f60 --- /dev/null +++ b/core/mqueue/lib/src/queue/data_stream.base.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:angel3_mq/src/message/message.dart'; + +/// An abstract base class for data streams that produce [Message] objects. +/// +/// The `BaseDataStream` class provides the foundation for creating data +/// streams that emit [Message] objects to their listeners. It includes a +/// [StreamController] to manage the stream of messages and methods to enqueue +/// messages and dispose of the stream when it's no longer needed. +/// +/// Example: +/// ```dart +/// class MyDataStream extends BaseDataStream { +/// // Custom methods and logic specific to your data stream can be added here. +/// } +/// ``` +abstract class BaseDataStream { + /// A [StreamController] for broadcasting [Message] objects to listeners. + final StreamController _data = StreamController.broadcast(); + + /// Returns a [Stream] of [Message] objects from this data stream. + Stream get dataStream => _data.stream; + + /// The latest [Message] enqueued in the data stream. + /// + /// This property keeps track of the most recently enqueued message. + Message? _latestMessage; + + /// Exposes the [_latestMessage] property. + /// + /// This getter returns the most recently enqueued message. + Message? get latestMessage => _latestMessage; + + /// Enqueues a [Message] to be emitted by the data stream. + /// + /// The [message] parameter represents the [Message] to enqueue, and it + /// becomes the latest message in the stream. + void enqueue(Message message) { + _latestMessage = message; + _data.add(message); + } + + /// Closes the data stream, freeing up resources. + /// + /// This method should be called when the data stream is no longer needed + /// to prevent resource leaks. + void dispose() => _data.close(); + + /// Checks if there are any active listeners on the data stream. + /// + /// Returns `true` if there are active listeners, and `false` otherwise. + bool hasListeners() => _data.hasListener; +} diff --git a/core/mqueue/lib/src/queue/queue.dart b/core/mqueue/lib/src/queue/queue.dart new file mode 100644 index 00000000..3b8fdb28 --- /dev/null +++ b/core/mqueue/lib/src/queue/queue.dart @@ -0,0 +1,36 @@ +import 'package:angel3_mq/src/queue/data_stream.base.dart'; +import 'package:equatable/equatable.dart'; + +/// A class representing a queue for message streaming. +/// +/// The `Queue` class extends the [BaseDataStream] class and adds an +/// identifier, making it suitable for managing and streaming messages in a +/// queue-like fashion. +/// +/// Example: +/// ```dart +/// final myQueue = Queue('my_queue_id'); +/// +/// // Enqueue a message to the queue. +/// final message = Message( +/// headers: {'contentType': 'json', 'sender': 'Alice'}, +/// payload: {'text': 'Hello, World!'}, +/// timestamp: '2023-09-07T12:00:002', +/// ); +/// myQueue.enqueue(message); +/// +/// // Check if the queue has active listeners. +/// final hasListeners = myQueue.hasListeners(); +/// ``` +class Queue extends BaseDataStream with EquatableMixin { + /// Creates a new queue with the specified [id]. + /// + /// The [id] parameter is a unique identifier for the queue. + Queue(this.id); + + /// The unique identifier for the queue. + final String id; + + @override + List get props => [id]; +} diff --git a/core/mqueue/pubspec.yaml b/core/mqueue/pubspec.yaml new file mode 100644 index 00000000..c78a4926 --- /dev/null +++ b/core/mqueue/pubspec.yaml @@ -0,0 +1,17 @@ +name: angel3_mq +description: DartMQ is a message-queue system that facilitates communication between different components in the application. +repository: https://github.com/N-Razzouk/dart_mq +issue_tracker: https://github.com/N-Razzouk/dart_mq/issues +homepage: https://github.com/N-Razzouk/dart_mq +documentation: https://github.com/N-Razzouk/dart_mq +version: 1.1.0 + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + equatable: ^2.0.5 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.21.0 diff --git a/core/mqueue/test/binding/binding_test.dart b/core/mqueue/test/binding/binding_test.dart new file mode 100644 index 00000000..23d87c0a --- /dev/null +++ b/core/mqueue/test/binding/binding_test.dart @@ -0,0 +1,97 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/binding/binding.dart'; +import 'package:angel3_mq/src/core/exceptions/queue_exceptions.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; +import 'package:test/test.dart'; + +void main() { + late Binding binding; + late Queue queue1; + late Queue queue2; + + setUp(() { + binding = Binding('my_binding'); + queue1 = Queue('queue_1'); + queue2 = Queue('queue_2'); + }); + + test('addQueue adds a queue to the binding', () { + binding.addQueue(queue1); + expect(binding.hasQueues(), isTrue); + }); + + test('removeQueue removes a queue from the binding', () { + binding.addQueue(queue1); + expect(binding.hasQueues(), isTrue); + + binding.removeQueue('queue_1'); + expect(binding.hasQueues(), isFalse); + }); + + test( + 'removeQueue throws QueueHasSubscribersException if queue has ' + 'subscribers', () { + final sub = queue1.dataStream.listen((_) {}); + + binding.addQueue(queue1); + + expect( + () => binding.removeQueue('queue_1'), + throwsA(isA()), + ); + + sub.cancel(); + }); + + test('publishMessage publishes a message to all associated queues', () { + binding + ..addQueue(queue1) + ..addQueue(queue2); + + final message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + + binding.publishMessage(message); + + expect(queue1.latestMessage, equals(message)); + expect(queue2.latestMessage, equals(message)); + }); + + test('hasQueues returns true if the binding has associated queues', () { + expect(binding.hasQueues(), isFalse); + + binding.addQueue(queue1); + expect(binding.hasQueues(), isTrue); + }); + + test('clear clears all queues from the binding', () { + binding + ..addQueue(queue1) + ..addQueue(queue2); + + expect(binding.hasQueues(), isTrue); + + binding.clear(); + expect(binding.hasQueues(), isFalse); + }); + + test('clear throws QueueHasSubscribersException if a queue has subscribers', + () { + final sub = queue1.dataStream.listen((_) {}); + + binding + ..addQueue(queue1) + ..addQueue(queue2); + + expect(binding.hasQueues(), isTrue); + + expect(() => binding.clear(), throwsA(isA())); + + expect(binding.hasQueues(), isTrue); + + sub.cancel(); + }); +} diff --git a/core/mqueue/test/consumer/consumer_test.dart b/core/mqueue/test/consumer/consumer_test.dart new file mode 100644 index 00000000..f7fb078c --- /dev/null +++ b/core/mqueue/test/consumer/consumer_test.dart @@ -0,0 +1,333 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +class MyMessageConsumer with ConsumerMixin { + // Custom implementation of the message consumer. +} + +void main() { + group('Consumer', () { + final consumer = MyMessageConsumer(); + setUpAll(() { + MQClient.initialize(); + + MQClient.instance.declareQueue('test-queue'); + }); + + test('subscribe should register a subscription and receive messages', + () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final callbackMessages = []; + + consumer.subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Ensure that the callback was called with the expected messages + expect(callbackMessages, contains(message1)); + expect(callbackMessages, contains(message2)); + }); + + test('unsubscribe should cancel a subscription', () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final callbackMessages = []; + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Unsubscribe and ensure that the callback is not called + consumer.unsubscribe(queueId: queueId); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + expect(callbackMessages, contains(message1)); + expect(callbackMessages.length, equals(1)); + }); + + test('pauseSubscription should pause a subscription', () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final callbackMessages = []; + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Pause the subscription and ensure that the callback is not called + consumer.pauseSubscription(queueId); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + expect(callbackMessages, contains(message1)); + expect(callbackMessages.length, equals(1)); + }); + + test('resumeSubscription should resume a paused subscription', () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final callbackMessages = []; + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish a message to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + + // Pause and then resume the subscription and ensure that the callback is + // called. + consumer + ..pauseSubscription(queueId) + ..resumeSubscription(queueId); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + expect(callbackMessages, contains(message1)); + expect(callbackMessages, contains(message2)); + expect(callbackMessages.length, equals(2)); + }); + + test( + 'updateSubscription should update a subscription with a new callback ' + 'and filter', () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final message3 = Message(payload: 'Message 3'); + final callbackMessages = []; + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + // Update the subscription with a new callback and filter + consumer.updateSubscription( + queueId: queueId, + callback: (message) { + if (message.payload == 'Message 2') { + callbackMessages.add(message); + } + }, + filter: (payload) => payload == 'Message 2', + ); + + // Publish another message to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message3, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Ensure that the callback is only called with 'Message 2' + expect(callbackMessages, contains(message1)); + expect(callbackMessages, contains(message2)); + expect(callbackMessages.contains(message3), isFalse); + expect(callbackMessages.length, equals(3)); + }); + + test('clearSubscriptions should clear all subscriptions', () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final message3 = Message(payload: 'Message 3'); + final callbackMessages = []; + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (message) { + callbackMessages.add(message); + }, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + // Update the subscription with a new callback and filter + consumer.clearSubscriptions(); + + // Publish another message to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message3, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Ensure that the callback is only called on the first two messages. + expect(callbackMessages, contains(message1)); + expect(callbackMessages, contains(message2)); + expect(callbackMessages.contains(message3), isFalse); + expect(callbackMessages.length, equals(2)); + }); + + test('getLatestMessage should return the latest message from a queue', + () async { + const queueId = 'test-queue'; + final message1 = Message(payload: 'Message 1'); + final message2 = Message(payload: 'Message 2'); + final message3 = Message(payload: 'Message 3'); + + consumer + ..clearSubscriptions() + ..subscribe( + queueId: queueId, + callback: (_) {}, + ); + + // Publish messages to the queue + MQClient.instance.sendMessage( + exchangeName: '', + message: message1, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message2, + routingKey: queueId, + ); + MQClient.instance.sendMessage( + exchangeName: '', + message: message3, + routingKey: queueId, + ); + + await Future.delayed(Duration.zero); + + // Get the latest message + final latestMessage = consumer.getLatestMessage(queueId); + + // Ensure that the latest message is 'Message 3' + expect(latestMessage, equals(message3)); + }); + + test( + 'subscribing to a queue that has already been subscribed to throws an ' + 'error.', () { + const queueId = 'test-queue'; + + consumer + ..clearSubscriptions() + ..subscribe(queueId: queueId, callback: (_) {}); + + expect( + () => consumer.subscribe(queueId: queueId, callback: (_) {}), + throwsA(isA()), + ); + }); + }); +} diff --git a/core/mqueue/test/core/exceptions/binding_exceptions_test.dart b/core/mqueue/test/core/exceptions/binding_exceptions_test.dart new file mode 100644 index 00000000..8d0e1bd4 --- /dev/null +++ b/core/mqueue/test/core/exceptions/binding_exceptions_test.dart @@ -0,0 +1,24 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('BindingException', () { + test('BindingKeyNotFoundException', () { + final exception = BindingKeyNotFoundException('test-key'); + expect(exception.toString(), contains('BindingKeyNotFoundException')); + expect( + exception.toString(), + contains( + 'BindingKeyNotFoundException:' + ' The binding key "test-key" was not found.', + ), + ); + }); + + test('BindingKeyRequiredException', () { + final exception = BindingKeyRequiredException(); + expect(exception.toString(), contains('BindingKeyRequiredException')); + expect(exception.toString(), contains('Binding key is required')); + }); + }); +} diff --git a/core/mqueue/test/core/exceptions/consumer_exceptions_test.dart b/core/mqueue/test/core/exceptions/consumer_exceptions_test.dart new file mode 100644 index 00000000..0e302d23 --- /dev/null +++ b/core/mqueue/test/core/exceptions/consumer_exceptions_test.dart @@ -0,0 +1,60 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('ConsumerException', () { + test('ConsumerNotRegisteredException', () { + final exception = ConsumerNotRegisteredException('Alice'); + expect(exception.toString(), contains('ConsumerNotRegisteredException')); + expect( + exception.toString(), + contains('ConsumerNotRegisteredException: The consumer "Alice" is not ' + 'registered.'), + ); + }); + + test('ConsumerAlreadySubscribedException', () { + final exception = ConsumerAlreadySubscribedException( + consumer: 'NewsConsumer', + queue: 'NewsQueue', + ); + expect( + exception.toString(), + contains('ConsumerAlreadySubscribedException'), + ); + expect( + exception.toString(), + contains( + 'ConsumerAlreadySubscribedException: The consumer "NewsConsumer" ' + 'is already subscribed to the queue "NewsQueue".'), + ); + }); + + test('ConsumerNotSubscribedException', () { + final exception = ConsumerNotSubscribedException( + consumer: 'WeatherConsumer', + queue: 'WeatherQueue', + ); + expect(exception.toString(), contains('ConsumerNotSubscribedException')); + expect( + exception.toString(), + contains( + 'ConsumerNotSubscribedException: The consumer "WeatherConsumer" ' + 'is not subscribed to the queue "WeatherQueue".'), + ); + }); + + test('ConsumerHasSubscriptionsException', () { + final exception = ConsumerHasSubscriptionsException('Bob'); + expect( + exception.toString(), + contains('ConsumerHasSubscriptionsException'), + ); + expect( + exception.toString(), + contains('ConsumerHasSubscriptionsException: The consumer "Bob" has ' + 'active subscriptions.'), + ); + }); + }); +} diff --git a/core/mqueue/test/core/exceptions/exchange_exceptions_test.dart b/core/mqueue/test/core/exceptions/exchange_exceptions_test.dart new file mode 100644 index 00000000..da202142 --- /dev/null +++ b/core/mqueue/test/core/exceptions/exchange_exceptions_test.dart @@ -0,0 +1,21 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('ExchangeException', () { + test('ExchangeNotRegisteredException', () { + final exception = ExchangeNotRegisteredException('NewsExchange'); + expect(exception.toString(), contains('ExchangeNotRegisteredException')); + expect( + exception.toString(), + contains('Exchange: NewsExchange is not registered'), + ); + }); + + test('InvalidExchangeTypeException', () { + final exception = InvalidExchangeTypeException(); + expect(exception.toString(), contains('InvalidExchangeTypeException')); + expect(exception.toString(), contains('Exchange type is invalid.')); + }); + }); +} diff --git a/core/mqueue/test/core/exceptions/mq_client_exceptions_test.dart b/core/mqueue/test/core/exceptions/mq_client_exceptions_test.dart new file mode 100644 index 00000000..d38a122d --- /dev/null +++ b/core/mqueue/test/core/exceptions/mq_client_exceptions_test.dart @@ -0,0 +1,17 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('MQClientException', () { + test('MQClientNotInitializedException', () { + final exception = MQClientNotInitializedException(); + expect(exception.toString(), contains('MQClientNotInitializedException')); + expect( + exception.toString(), + contains('MQClientNotInitializedException: MQClient is not ' + 'initialized. Please make sure to call MQClient.initialize() ' + 'first.'), + ); + }); + }); +} diff --git a/core/mqueue/test/core/exceptions/queue_exceptions_test.dart b/core/mqueue/test/core/exceptions/queue_exceptions_test.dart new file mode 100644 index 00000000..a0be43ac --- /dev/null +++ b/core/mqueue/test/core/exceptions/queue_exceptions_test.dart @@ -0,0 +1,30 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('QueueException', () { + test('QueueNotRegisteredException', () { + final exception = QueueNotRegisteredException('my_queue_id'); + expect(exception.toString(), contains('QueueNotRegisteredException')); + expect( + exception.toString(), + contains('Queue: my_queue_id is not registered'), + ); + }); + + test('QueueHasSubscribersException', () { + final exception = QueueHasSubscribersException('my_queue_id'); + expect(exception.toString(), contains('QueueHasSubscribersException')); + expect( + exception.toString(), + contains('Queue: my_queue_id has subscribers'), + ); + }); + + test('QueueIdNullException', () { + final exception = QueueIdNullException(); + expect(exception.toString(), contains('QueueIdNullException')); + expect(exception.toString(), contains("Queue name can't be null")); + }); + }); +} diff --git a/core/mqueue/test/core/exceptions/registrar_exceptions_test.dart b/core/mqueue/test/core/exceptions/registrar_exceptions_test.dart new file mode 100644 index 00000000..8f1d3f94 --- /dev/null +++ b/core/mqueue/test/core/exceptions/registrar_exceptions_test.dart @@ -0,0 +1,25 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('RegistrarException', () { + test('IdAlreadyRegisteredException', () { + final exception = IdAlreadyRegisteredException('my_id'); + expect(exception.toString(), contains('IdAlreadyRegisteredException')); + expect( + exception.toString(), + contains('IdAlreadyRegisteredException: Id ' + '"my_id" already registered'), + ); + }); + + test('IdNotRegisteredException', () { + final exception = IdNotRegisteredException('my_id'); + expect(exception.toString(), contains('IdNotRegisteredException')); + expect( + exception.toString(), + contains('IdNotRegisteredException: Id "my_id" not registered.'), + ); + }); + }); +} diff --git a/core/mqueue/test/core/exceptions/routing_key_exceptionss_test.dart b/core/mqueue/test/core/exceptions/routing_key_exceptionss_test.dart new file mode 100644 index 00000000..de8f3898 --- /dev/null +++ b/core/mqueue/test/core/exceptions/routing_key_exceptionss_test.dart @@ -0,0 +1,12 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('RoutingKeyException', () { + test('RoutingKeyRequiredException', () { + final exception = RoutingKeyRequiredException(); + expect(exception.toString(), contains('RoutingKeyRequiredException')); + expect(exception.toString(), contains('Routing key is required')); + }); + }); +} diff --git a/core/mqueue/test/core/registrar/simple_registrar_test.dart b/core/mqueue/test/core/registrar/simple_registrar_test.dart new file mode 100644 index 00000000..975d699e --- /dev/null +++ b/core/mqueue/test/core/registrar/simple_registrar_test.dart @@ -0,0 +1,105 @@ +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/core/registrar/simple_registrar.dart'; +import 'package:test/test.dart'; + +void main() { + late Registrar registrar; + + setUp(() { + registrar = Registrar(); + }); + + test('register and get objects', () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob'); + + expect(registrar.get('user_1'), equals('Alice')); + expect(registrar.get('user_2'), equals('Bob')); + }); + + test('register throws IdAlreadyRegisteredException for duplicate IDs', () { + registrar.register('user_1', 'Alice'); + expect( + () => registrar.register('user_1', 'Another Alice'), + throwsA(const TypeMatcher()), + ); + }); + + test('get throws IdNotRegisteredException for unknown IDs', () { + expect( + () => registrar.get('unknown_id'), + throwsA(const TypeMatcher()), + ); + }); + + test('getAll returns a list of all registered objects', () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob'); + + final allObjects = registrar.getAll(); + + expect(allObjects, contains('Alice')); + expect(allObjects, contains('Bob')); + }); + + test('unregister removes objects', () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob') + ..unregister('user_1'); + + expect( + () => registrar.get('user_1'), + throwsA(const TypeMatcher()), + ); + expect(registrar.get('user_2'), equals('Bob')); + }); + + test('unregister throws IdNotRegisteredException for unknown IDs', () { + expect( + () => registrar.unregister('unknown_id'), + throwsA(const TypeMatcher()), + ); + }); + + test('clear removes all registered objects', () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob') + ..clear(); + + expect(registrar.count, equals(0)); + }); + + test('has checks if an object is registered', () { + registrar.register('user_1', 'Alice'); + + expect(registrar.has('user_1'), isTrue); + expect(registrar.has('user_2'), isFalse); + }); + + test('count returns the number of registered objects', () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob'); + + expect(registrar.count, equals(2)); + }); + + test('toString returns a formatted string representation of the registrar', + () { + registrar + ..register('user_1', 'Alice') + ..register('user_2', 'Bob'); + + const expectedString = ''' +Registrar( +\tuser_1: Alice, +\tuser_2: Bob + )'''; + + expect(registrar.toString(), equals(expectedString)); + }); +} diff --git a/core/mqueue/test/exchange/default_exchange_test.dart b/core/mqueue/test/exchange/default_exchange_test.dart new file mode 100644 index 00000000..547f9e1a --- /dev/null +++ b/core/mqueue/test/exchange/default_exchange_test.dart @@ -0,0 +1,79 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/exchange/default_exchange.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; +import 'package:test/test.dart'; + +void main() { + late DefaultExchange defaultExchange; + late Queue queue; + late Message message; + + setUp(() { + defaultExchange = DefaultExchange('default_exchange'); + queue = Queue('my_queue'); + message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + }); + + test('bindQueue binds a queue to the default exchange with a binding key', + () { + defaultExchange.bindQueue(queue: queue, bindingKey: 'my_routing_key'); + expect(defaultExchange.bindings.has('my_routing_key'), isTrue); + }); + + test( + 'unbindQueue throws an exception when attempting to unbind from the ' + 'default exchange', () { + expect( + () => defaultExchange.unbindQueue( + queueId: 'my_queue_id', + bindingKey: 'my_routing_key', + ), + throwsA(isA()), + ); + }); + + test('unbindQueue unbinds a queue from the default exchange', () { + defaultExchange + ..bindQueue(queue: queue, bindingKey: 'my_routing_key') + ..unbindQueue( + queueId: queue.id, + bindingKey: 'my_routing_key', + ); + expect(defaultExchange.bindings.has('my_routing_key'), isFalse); + }); + + test( + 'forwardMessage forwards a message to the default exchange using a ' + 'routing key', () { + defaultExchange + ..bindQueue(queue: queue, bindingKey: 'my_routing_key') + ..forwardMessage(message: message, routingKey: 'my_routing_key'); + expect(queue.latestMessage, equals(message)); + }); + + test( + 'forwardMessage throws BindingKeyNotFoundException when routing key is ' + 'not found', () { + expect( + () => defaultExchange.forwardMessage( + message: message, + routingKey: 'non_existent_routing_key', + ), + throwsA(isA()), + ); + }); + + test( + 'forwardMessage throws RoutingKeyRequiredException when routing key is ' + 'null', () { + expect( + () => defaultExchange.forwardMessage(message: message), + throwsA(isA()), + ); + }); +} diff --git a/core/mqueue/test/exchange/direct_exchange_test.dart b/core/mqueue/test/exchange/direct_exchange_test.dart new file mode 100644 index 00000000..0f0de29c --- /dev/null +++ b/core/mqueue/test/exchange/direct_exchange_test.dart @@ -0,0 +1,88 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:angel3_mq/src/exchange/direct_exchange.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; +import 'package:test/test.dart'; + +void main() { + late DirectExchange directExchange; + late Queue queue1; + late Queue queue2; + late Message message; + + setUp(() { + directExchange = DirectExchange('my_direct_exchange'); + queue1 = Queue('queue_1'); + queue2 = Queue('queue_2'); + message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + }); + + test('bindQueue binds a queue to the direct exchange with a binding key', () { + directExchange.bindQueue(queue: queue1, bindingKey: 'routing_key_1'); + expect(directExchange.bindings.has('routing_key_1'), isTrue); + }); + + test( + 'bindQueue binds a queue to the direct exchange with a binding key that ' + 'already exists.', () { + directExchange + ..bindQueue(queue: queue1, bindingKey: 'routing_key_1') + ..bindQueue(queue: queue2, bindingKey: 'routing_key_1'); + expect(directExchange.bindings.has('routing_key_1'), isTrue); + }); + + test( + 'unbindQueue unbinds a queue from the direct exchange with a binding key', + () { + directExchange + ..bindQueue(queue: queue1, bindingKey: 'routing_key_1') + ..unbindQueue(queueId: queue1.id, bindingKey: 'routing_key_1'); + expect(directExchange.bindings.has('routing_key_1'), isFalse); + }); + + test( + 'forwardMessage forwards a message to the direct exchange using a ' + 'routing key', () { + directExchange + ..bindQueue(queue: queue1, bindingKey: 'routing_key_1') + ..forwardMessage(message: message, routingKey: 'routing_key_1'); + expect(queue1.latestMessage, equals(message)); + }); + + test( + 'forwardMessage throws BindingKeyNotFoundException when routing key is ' + 'not found', () { + expect( + () => directExchange.forwardMessage( + message: message, + routingKey: 'non_existent_routing_key', + ), + throwsA(isA()), + ); + }); + + test( + 'forwardMessage throws RoutingKeyRequiredException when routing key is ' + 'null', () { + expect( + () => directExchange.forwardMessage(message: message), + throwsA(isA()), + ); + }); + + test( + 'unbindQueue throws BindingKeyNotFoundException when attempting to ' + 'unbind with an invalid binding key', () { + expect( + () => directExchange.unbindQueue( + queueId: 'queue_id', + bindingKey: 'invalid_binding_key', + ), + throwsA(isA()), + ); + }); +} diff --git a/core/mqueue/test/exchange/fanout_exchange_test.dart b/core/mqueue/test/exchange/fanout_exchange_test.dart new file mode 100644 index 00000000..3332216e --- /dev/null +++ b/core/mqueue/test/exchange/fanout_exchange_test.dart @@ -0,0 +1,69 @@ +import 'package:angel3_mq/src/exchange/fanout_exchange.dart'; +import 'package:angel3_mq/src/message/message.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; +import 'package:test/test.dart'; + +void main() { + group('FanoutExchange', () { + test('bindQueue should add a queue to the exchange', () { + final fanoutExchange = FanoutExchange('my_fanout_exchange'); + final queue1 = Queue('queue_1'); + final queue2 = Queue('queue_2'); + + fanoutExchange + ..bindQueue(queue: queue1, bindingKey: 'binding_key_1') + ..bindQueue(queue: queue2, bindingKey: 'binding_key_2'); + + expect(fanoutExchange.bindings.get('').hasQueues(), isTrue); + }); + + test('unbindQueue should remove a queue from the exchange', () { + final fanoutExchange = FanoutExchange('my_fanout_exchange'); + final queue1 = Queue('queue_1'); + final queue2 = Queue('queue_2'); + + fanoutExchange + ..bindQueue(queue: queue1, bindingKey: 'binding_key_1') + ..bindQueue(queue: queue2, bindingKey: 'binding_key_2') + ..unbindQueue(queueId: 'queue_1', bindingKey: 'binding_key_1') + ..unbindQueue(queueId: 'queue_2', bindingKey: 'binding_key_2'); + + expect(fanoutExchange.bindings.get('').hasQueues(), isFalse); + }); + + test('forwardMessage should forward a message to all associated queues', + () { + final fanoutExchange = FanoutExchange('my_fanout_exchange'); + final queue1 = Queue('queue_1'); + final queue2 = Queue('queue_2'); + + fanoutExchange + ..bindQueue(queue: queue1, bindingKey: 'binding_key_1') + ..bindQueue(queue: queue2, bindingKey: 'binding_key_2'); + + final message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + + fanoutExchange.forwardMessage(message: message); + + expect(queue1.latestMessage, equals(message)); + expect(queue2.latestMessage, equals(message)); + }); + + test('removeQueue removes a queue from all bindings', () { + final queue1 = Queue('queue_1'); + + final fanoutExchange = FanoutExchange('my_fanout_exchange') + ..bindQueue(queue: queue1, bindingKey: '') + ..unbindQueue( + queueId: queue1.id, + bindingKey: '', + ); + + expect(fanoutExchange.bindings.get('').hasQueues(), isFalse); + }); + }); +} diff --git a/core/mqueue/test/message/message.base_test.dart b/core/mqueue/test/message/message.base_test.dart new file mode 100644 index 00000000..86cc6589 --- /dev/null +++ b/core/mqueue/test/message/message.base_test.dart @@ -0,0 +1,59 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:test/test.dart'; + +void main() { + group('BaseMessage', () { + test('Creating a BaseMessage', () { + // Arrange + final headers = {'content-type': 'text/plain'}; + const payload = 'Hello, World!'; + const timestamp = '2023-09-07T12:00:002'; + + // Act + final baseMessage = + Message(payload: payload, headers: headers, timestamp: timestamp); + + // Assert + expect(baseMessage.headers, equals(headers)); + expect(baseMessage.payload, equals(payload)); + expect(baseMessage.timestamp, equals(timestamp)); + }); + + test('Creating a BaseMessage without headers and timestamp', () { + // Arrange + const payload = 'Hello, World!'; + + // Act + final baseMessage = Message( + payload: payload, + ); + + // Assert + expect(baseMessage.headers, isEmpty); + expect(baseMessage.payload, equals(payload)); + expect(baseMessage.timestamp, isNotNull); + }); + + test('toString function.', () { + // Arrange + final headers = {'content-type': 'text/plain'}; + const payload = 'Hello, World!'; + const timestamp = '2023-09-07T12:00:002'; + + // Act + final baseMessage = + Message(payload: payload, headers: headers, timestamp: timestamp); + + // Assert + expect( + baseMessage.toString(), + equals(''' +Message{ + headers: $headers, + payload: $payload, + timestamp: $timestamp, + }'''), + ); + }); + }); +} diff --git a/core/mqueue/test/mq/mq_test.dart b/core/mqueue/test/mq/mq_test.dart new file mode 100644 index 00000000..0c81eea9 --- /dev/null +++ b/core/mqueue/test/mq/mq_test.dart @@ -0,0 +1,342 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/core/exceptions/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('Initialization', () { + test( + 'MQClient instance should throw MQClientNotInitializedException if ' + 'not initialized', () { + expect( + () => MQClient.instance, + throwsA(isA()), + ); + }); + + test('MQClient initialize should create a singleton instance', () { + MQClient.initialize(); + final initializedInstance = MQClient.instance; + expect(initializedInstance, isA()); + expect(MQClient.instance, equals(initializedInstance)); + }); + }); + + group('Queue Operations', () { + setUpAll(MQClient.initialize); + + test('listQueues should return a list of all registered queues', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + final queues = MQClient.instance.listQueues(); + expect(queues, isA>()); + expect(queues, contains(queueId)); + MQClient.instance.deleteQueue(queueId); + }); + test( + 'declareQueue should declare a new queue and bind it to the default ' + 'exchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + final queue = MQClient.instance.fetchQueue(queueId); + expect(queue, isNotNull); + MQClient.instance.deleteQueue(queueId); + }); + + test('declareQueue should declare a new queue with the specified name', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + final queue = MQClient.instance.fetchQueue(queueId); + expect(queue, isNotNull); + expect(MQClient.instance.fetchQueue(queueId), isA>()); + MQClient.instance.deleteQueue(queueId); + }); + + test( + "declareQueue should return name of queue even if it's already " + 'registered', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + + final queueId2 = MQClient.instance.declareQueue('test-queue'); + + expect(queueId, equals(queueId2)); + + MQClient.instance.deleteQueue(queueId); + }); + + test( + 'fetchQueue should throw QueueNotRegisteredException if the queue does ' + 'not exist.', () { + expect( + () => MQClient.instance.fetchQueue('test-queue'), + throwsA(isA()), + ); + }); + + test( + 'getLatestMessage should throw QueueNotRegisteredException if the ' + 'queue does not exist.', () { + expect( + () => MQClient.instance.getLatestMessage('test-queue'), + throwsA(isA()), + ); + }); + + test('deleteQueue should delete a queue', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + MQClient.instance.deleteQueue(queueId); + expect( + () => MQClient.instance.fetchQueue(queueId), + throwsA(isA()), + ); + }); + + test( + 'deleteQueue should throw QueueNotRegisteredException if the queue ' + 'does not exist.', () { + expect( + () => MQClient.instance.deleteQueue('test-queue'), + throwsA(isA()), + ); + }); + + test( + 'deleteQueue should throw QueueHasSubscribersException if there are ' + 'any consumers consuming that queue.', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + final queueStream = MQClient.instance.fetchQueue(queueId); + final sub = queueStream.listen((_) {}); + + expect( + () => MQClient.instance.deleteQueue(queueId), + throwsA(isA()), + ); + + sub.cancel(); + MQClient.instance.deleteQueue(queueId); + }); + }); + + group('Exchange Operations', () { + setUpAll(() => MQClient); + setUp(() { + MQClient.initialize(); + }); + test('declareExchange should declare a new exchange of the specified type', + () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.direct, + ); + + MQClient.instance.bindQueue( + queueId: queueId, + exchangeName: exchangeName, + bindingKey: 'test-binding', + ); + + MQClient.instance.sendMessage( + message: Message(payload: 'test'), + exchangeName: exchangeName, + routingKey: 'test-binding', + ); + + expect( + MQClient.instance.getLatestMessage(queueId)?.payload, + equals('test'), + ); + + MQClient.instance.deleteExchange(exchangeName); + MQClient.instance.deleteQueue(queueId); + }); + + test( + 'sendMessage to unregister exchange should throw ' + 'ExchangeNotRegisteredException', () { + const exchangeName = 'exchange'; + expect( + () => MQClient.instance.sendMessage( + message: Message(payload: 'test'), + exchangeName: exchangeName, + routingKey: 'test-binding', + ), + throwsA(isA()), + ); + }); + + test( + 'declareExchange should throw InvalidExchangeTypeException if the ' + 'exchange type is invalid', () { + const exchangeName = 'exchange'; + expect( + () => MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.base, + ), + throwsA(isA()), + ); + MQClient.instance.deleteExchange(exchangeName); + }); + + test('deleteExchange should delete an exchange', () { + const exchangeName = 'exchange'; + expect( + () => MQClient.instance.bindQueue( + queueId: 'test-queue', + exchangeName: exchangeName, + ), + throwsA(isA()), + ); + }); + + test('deleteExchange should do nothing if the exchange does not exist', () { + expect( + () => MQClient.instance.deleteExchange('nonexistent_exchange'), + returnsNormally, + ); + }); + + test('bindQueue should bind a queue to direct exchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.direct, + ); + MQClient.instance.bindQueue( + queueId: queueId, + exchangeName: exchangeName, + bindingKey: 'key', + ); + + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + + test('bindQueue should bind a queue to fanout exchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.fanout, + ); + MQClient.instance.bindQueue(queueId: queueId, exchangeName: exchangeName); + + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + + test( + 'bindQueue should throw BindingKeyRequiredException if bindingKey is ' + 'not provided for DirectExchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.direct, + ); + expect( + () => MQClient.instance.bindQueue( + queueId: queueId, + exchangeName: exchangeName, + ), + throwsA(isA()), + ); + + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + + test('unbindQueue should unbind a queue from an exchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.direct, + ); + expect( + () => MQClient.instance.bindQueue( + queueId: queueId, + exchangeName: exchangeName, + bindingKey: 'test-binding-key', + ), + returnsNormally, + ); + MQClient.instance.unbindQueue( + queueId: queueId, + exchangeName: exchangeName, + bindingKey: 'test-binding-key', + ); + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + + test( + 'unbindQueue should throw BindingKeyRequiredException if ' + 'bindingKey is not provided for DirectExchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.direct, + ); + expect( + () => MQClient.instance.unbindQueue( + queueId: queueId, + exchangeName: exchangeName, + ), + throwsA(isA()), + ); + + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + test( + 'unbindQueue should not throw BindingKeyRequiredException if ' + 'bindingKey is not provided for FanoutExchange', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + MQClient.instance.declareExchange( + exchangeName: exchangeName, + exchangeType: ExchangeType.fanout, + ); + expect( + () => MQClient.instance + .unbindQueue(queueId: queueId, exchangeName: exchangeName), + returnsNormally, + ); + + MQClient.instance.deleteQueue(queueId); + MQClient.instance.deleteExchange(exchangeName); + }); + + test( + 'unbindQueue should throw ExchangeNotRegisteredException ' + 'if exchange does not exist', () { + final queueId = MQClient.instance.declareQueue('test-queue'); + const exchangeName = 'exchange'; + expect( + () => MQClient.instance.unbindQueue( + queueId: queueId, + exchangeName: exchangeName, + ), + throwsA(isA()), + ); + + MQClient.instance.deleteQueue(queueId); + }); + }); + + group('Close Operations.', () { + setUpAll(() => MQClient); + setUp(() { + MQClient.initialize(); + }); + test('close should close the MQClient', () { + MQClient.instance.declareQueue('test-queue'); + MQClient.instance.close(); + expect( + () => MQClient.instance, + throwsA(isA()), + ); + }); + }); +} diff --git a/core/mqueue/test/producer/producer_test.dart b/core/mqueue/test/producer/producer_test.dart new file mode 100644 index 00000000..1b3494ce --- /dev/null +++ b/core/mqueue/test/producer/producer_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'package:angel3_mq/mq.dart'; +import 'package:test/test.dart'; + +class MyMessageProducer with ProducerMixin { + // Custom implementation of the message producer. +} + +void main() { + group('Producer', () { + final producer = MyMessageProducer(); + + setUpAll(() { + MQClient.initialize(); + + MQClient.instance.declareQueue('test-queue'); + }); + + test( + 'sendMessage should send a message to an exchange and call the ' + 'callback', () { + final message = Message( + payload: 'Test Message', + timestamp: '2023-09-07T12:00:002', + ); + var callbackCalled = false; + producer + ..setPushCallback((message) { + callbackCalled = true; + }) + ..sendMessage( + payload: 'Test Message', + exchangeName: '', + routingKey: 'test-queue', + timestamp: '2023-09-07T12:00:002', + ); + + expect( + MQClient.instance.getLatestMessage('test-queue')?.headers, + equals(message.headers), + ); + expect( + MQClient.instance.getLatestMessage('test-queue')?.payload, + equals(message.payload), + ); + expect( + MQClient.instance.getLatestMessage('test-queue')?.timestamp, + equals(message.timestamp), + ); + expect(callbackCalled, isTrue); + }); + + test( + 'sendRPCMessage should send an RPC message to an exchange and call the ' + 'callback', () async { + var callbackCalled = false; + + producer.setPushCallback((message) { + callbackCalled = true; + }); + + final sub = MQClient.instance.fetchQueue('test-queue').listen((message) { + if (message.headers['type'] == 'RPC') { + (message.headers['completer'] as Completer).complete('Response'); + return; + } + }); + + final res = await producer.sendRPCMessage( + processId: 'foo', + args: {'key': 'value'}, + exchangeName: '', + routingKey: 'test-queue', + ); + + expect(callbackCalled, isTrue); + + expect(res, equals('Response')); + + await sub.cancel(); + }); + + test('sendRPCMessage with non-null mapper', () async { + var callbackCalled = false; + producer.setPushCallback((message) { + callbackCalled = true; + }); + + final sub = + MQClient.instance.fetchQueue('test-queue').listen((message) async { + if (message.headers['type'] == 'RPC') { + (message.headers['completer'] as Completer).complete('Response'); + return; + } + }); + + final response = await producer.sendRPCMessage( + processId: 'foo', + args: {'key': 'value'}, + exchangeName: '', + routingKey: 'test-queue', + mapper: (data) => '$data-new', + ); + + expect(callbackCalled, isTrue); + expect(response, equals('Response-new')); + + await sub.cancel(); + }); + }); +} diff --git a/core/mqueue/test/queue/queue_test.dart b/core/mqueue/test/queue/queue_test.dart new file mode 100644 index 00000000..0b80082e --- /dev/null +++ b/core/mqueue/test/queue/queue_test.dart @@ -0,0 +1,98 @@ +import 'package:angel3_mq/mq.dart'; +import 'package:angel3_mq/src/queue/queue.dart'; +import 'package:test/test.dart'; + +void main() { + group('Queue', () { + test('Creating a Queue', () { + // Arrange + const queueId = 'my_queue_id'; + + // Act + final myQueue = Queue(queueId); + + // Assert + expect(myQueue.id, equals(queueId)); + expect(myQueue.latestMessage, isNull); + }); + + test('Get dataStream from Queue', () { + // Arrange + const queueId = 'my_queue_id'; + final myQueue = Queue(queueId); + + // Act + final dataStream = myQueue.dataStream; + + // Assert + expect(dataStream, isNotNull); + }); + + test('Enqueue and Check Has Listeners', () { + // Arrange + const queueId = 'my_queue_id'; + final myQueue = Queue(queueId); + final message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + + // Act + myQueue.enqueue(message); + final hasListeners = myQueue.hasListeners(); + + // Assert + expect(myQueue.id, equals(queueId)); + expect(myQueue.latestMessage, equals(message)); + expect(hasListeners, isFalse); // No listeners by default + }); + + test('Queue equality', () { + // Arrange + final queue1 = Queue('queue_id_1'); + final queue2 = Queue('queue_id_2'); + final queue3 = Queue('queue_id_1'); // Same ID as queue1 + + // Act & Assert + expect(queue1, equals(queue3)); // Should be equal based on ID + expect( + queue1, + isNot(equals(queue2)), + ); // Should not be equal due to different IDs + }); + + test('Queue hashCode', () { + // Arrange + final queue1 = Queue('queue_id_1'); + final queue2 = Queue('queue_id_2'); + final queue3 = Queue('queue_id_1'); // Same ID as queue1 + + // Act & Assert + expect(queue1.hashCode, equals(queue3.hashCode)); + expect(queue1.hashCode, isNot(equals(queue2.hashCode))); + }); + + test('Queue dispose', () { + // Arrange + const queueId = 'my_queue_id'; + final myQueue = Queue(queueId); + final message = Message( + headers: {'contentType': 'json', 'sender': 'Alice'}, + payload: {'text': 'Hello, World!'}, + timestamp: '2023-09-07T12:00:002', + ); + + // Act + myQueue + ..enqueue(message) + ..dispose(); + final hasListeners = myQueue.hasListeners(); + + // Assert + expect(myQueue.id, equals(queueId)); + expect(myQueue.latestMessage, equals(message)); + expect(hasListeners, isFalse); + }); + }); +} diff --git a/core/reactivex/.gitignore b/core/reactivex/.gitignore new file mode 100644 index 00000000..454fea26 --- /dev/null +++ b/core/reactivex/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ +coverage/ \ No newline at end of file diff --git a/core/reactivex/CHANGELOG.md b/core/reactivex/CHANGELOG.md new file mode 100644 index 00000000..9c6e7a0c --- /dev/null +++ b/core/reactivex/CHANGELOG.md @@ -0,0 +1,775 @@ +# Changelog + +## [0.28.0] (2024-06-14) + +### New + +* ValueStream: + * Add `lastEventOrNull` getter to `ValueStream`, + which returns the last emitted event (either data/value or error event), or `null`. + * Add `isLastEventValue`, `isLastEventError` and `errorAndStackTraceOrNull` + extension getters to `ValueStream`, to check the kind of the last emitted event is data/value or error. + * Update documentation. + +* ReplayStream: + * Add `errorAndStackTraces` to `ReplayStream`, which returns a list of emitted `ErrorAndStackTrace`s. + +* Rename `Notification` and `Kind` to better reflect their purpose, + and to avoid confusion with [Flutter's Notification class](https://api.flutter.dev/flutter/widgets/Notification-class.html). + * Rename `Notification` to `StreamNotification` + * `Notification.onData` to `StreamNotification.data`. + * `Notification.onDone` to `StreamNotification.done`. + * `Notification.onError` to `StreamNotification.error`. + * Rename `Kind` to `NotificationKind` + * `Kind.onData` to `NotificationKind.data`. + * `Kind.onError` to `NotificationKind.error`. + * `Kind.onDone` to `NotificationKind.done`. + * Introduce `DataNotification`, `ErrorNotification` and `DoneNotification` as the subclasses of `StreamNotification`. + * Convert `isOnData`, `isOnError`, `isOnDone`, `requireData` to extension getters on `StreamNotification`, + they are now named `isData`, `isError`, `isDone` and `requireDataValue`. + * Add extensions on `StreamNotification`: `dataValueOrNull`, `requireErrorAndStackTrace`, `errorAndStackTraceOrNull` getters and `when` method. + +### Changed + +* Accept Dart SDK versions above 3.0. +* `switchMap`: when cancelling the previous inner subscription, + `switchMap` will pause the outer subscription and and wait for the inner subscription to be completely canceled. + It will then resume the outer subscription, and listen to the next inner Stream. + Any errors from canceling the previous inner subscription will now be forwarded to the resulting Stream. + +* **Breaking**: Rename `ForkJoinStream.combine2`..`combine9` to `ForkJoinStream.join2`..`join9`. +* **Breaking**: `Rx.using`/`UsingStream` + * Convert all _required positional_ parameters to _required named_ parameters. + * The `disposer` is now called after the future returned from `StreamSubscription.cancel` completes. + +### Documentation + +* Update and fix documentation. +* Fix README example (thanks to [@wurikiji](https://github.com/wurikiji)). +* Update Flutter example (thanks to [@hoangchungk53qx1](https://github.com/hoangchungk53qx1)). +* Replace deprecated "dart pub run" with "dart run" (thanks to [@tatsuyafujisaki](https://github.com/tatsuyafujisaki)). + +## [0.28.0-dev.2] (2024-03-30) + +Feedback on this change appreciated as this is a dev release before 0.28.0 stable! + +### Changed + +* **Breaking**: Rename `ForkJoinStream.combine2`..`combine9` to `ForkJoinStream.join2`..`join9`. +* **Breaking**: `Rx.using`/`UsingStream` + * Convert all _required positional_ parameters to _required named_ parameters. + * The `disposer` is now called after the future returned from `StreamSubscription.cancel` completes. + +## [0.28.0-dev.1] (2024-01-27) + +Feedback on this change appreciated as this is a dev release before 0.28.0 stable! + +### Changed + +* `switchMap`: when cancelling the previous inner subscription, + `switchMap` will pause the outer subscription and and wait for the inner subscription to be completely canceled. + It will then resume the outer subscription, and listen to the next inner Stream. + Any errors from canceling the previous inner subscription will now be forwarded to the resulting Stream. + +### Documentation + +* Replace deprecated "dart pub run" with "dart run" (thanks to [@tatsuyafujisaki](https://github.com/tatsuyafujisaki)). + +## [0.28.0-dev.0] (2023-07-26) + +Feedback on this change appreciated as this is a dev release before 0.28.0 stable! + +### New + +* ValueStream: + * Add `lastEventOrNull` getter to `ValueStream`, + which returns the last emitted event (either data/value or error event), or `null`. + * Add `isLastEventValue`, `isLastEventError` and `errorAndStackTraceOrNull` + extension getters to `ValueStream`, to check the kind of the last emitted event is data/value or error. + * Update documentation. + +* ReplayStream: + * Add `errorAndStackTraces` to `ReplayStream`, which returns a list of emitted `ErrorAndStackTrace`s. + +* Rename `Notification` and `Kind` to better reflect their purpose, + and to avoid confusion with [Flutter's Notification class](https://api.flutter.dev/flutter/widgets/Notification-class.html). + * Rename `Notification` to `StreamNotification` + * `Notification.onData` to `StreamNotification.data`. + * `Notification.onDone` to `StreamNotification.done`. + * `Notification.onError` to `StreamNotification.error`. + * Rename `Kind` to `NotificationKind` + * `Kind.onData` to `NotificationKind.data`. + * `Kind.onError` to `NotificationKind.error`. + * `Kind.onDone` to `NotificationKind.done`. + * Introduce `DataNotification`, `ErrorNotification` and `DoneNotification` as the subclasses of `StreamNotification`. + * Convert `isOnData`, `isOnError`, `isOnDone`, `requireData` to extension getters on `StreamNotification`, + they are now named `isData`, `isError`, `isDone` and `requireDataValue`. + * Add extensions on `StreamNotification`: `dataValueOrNull`, `requireErrorAndStackTrace`, `errorAndStackTraceOrNull` getters and `when` method. + +### Changed + +* Accept Dart SDK versions above 3.0. + +### Documentation + +* Update and fix documentation. +* Fix README example (thanks to [@wurikiji](https://github.com/wurikiji)). +* Update Flutter example (thanks to [@hoangchungk53qx1](https://github.com/hoangchungk53qx1)). + +## 0.27.7 (2022-11-16) + +### Fixed + +* `Subject` + * Only call `onAdd` and `onError` if the subject is not closed. + This ensures `BehaviorSubject` and `ReplaySubject` do not update their values after they have been closed. + + * `Subject.stream` now returns a **read-only** `Stream`. + Previously, `Subject.stream` was identical to the `Subject`, so we could add events to it, for example: `(subject.stream as Sink).add(event)`. + This behavior is now disallowed, and will throw a `TypeError` if attempted. Use `Subject.sink`/`Subject` itself for adding events. + + * Change return type of `ReplaySubject.stream` to `ReplayStream`. + * Internal refactoring of `Subject.addStream`. + +## 0.27.6 (2022-11-11) + +* `Rx.using`/`UsingStream`: `resourceFactory` can now return a `Future`. + This allows for asynchronous resource creation. + +* `Rx.range`/`RangeStream`: ensure `RangeStream` is only listened to once. + +## 0.27.5 (2022-07-16) + +### Bug fixes + +* Fix issue [#683](https://github.com/ReactiveX/rxdart/issues/683): Throws runtime type error when using extension + methods on a `Stream` but its type annotation is `Stream`, `R` is a subtype of `T` + (covariance issue with `StreamTransformer`). + ```Dart + Stream s1 = Stream.fromIterable([1, 2, 3]); + // throws "type 'SwitchMapStreamTransformer' is not a subtype of type 'StreamTransformer' of 'streamTransformer'" + s1.switchMap((v) => Stream.value(v)); + + Stream s2 = Stream.fromIterable([1, 2, 3]); + // throws "type 'SwitchMapStreamTransformer' is not a subtype of type 'StreamTransformer' of 'streamTransformer'" + s2.switchMap((v) => Stream.value(v)); + ``` + Extension methods were previously implemented via `stream.transform(streamTransformer)`, now + via `streamTransformer.bind(stream)` to avoid this issue. + +* Fix `concatEager`: `activeSubscription` should be changed to next subscription. + +### Code refactoring + +* Change return type of `pairwise` to `Stream>`. + +## 0.27.4 (2022-05-29) + +### Bug fixes + +* `withLatestFrom` should iterate over `Iterable` only once when the stream is listened to. +* Fix analyzer warnings when using `Dart 2.16.0`. + +### Features + +* Add `mapNotNull`/`MapNotNullStreamTransformer`. +* Add `whereNotNull`/`WhereNotNullStreamTransformer`. + +### Documentation + +* Fix grammar errors in code examples (thanks to [@fzyzcjy](https://github.com/fzyzcjy)). +* Update RxMarbles URL for `RaceStream` (thanks to [@Pรฉter Ferenc Gyarmati](https://github.com/peter-gy)). + +## 0.27.3 (2021-11-21) + +### Bug fixes + +* `flatMap` now creates inner `Stream`s lazily. +* `combineLatest`, `concat`, `concatEager`, `forkJoin`, `merge`, `race`, `zip` iterate over `Iterable`s only once + when the stream is listened to. +* Disallow mixing `autoConnect`, `connect` and `refCount` together, only one of them should be used. + +### Features + +* Introduce `AbstractConnectableStream`, base class for the `ConnectableStream` implementations. +* Improve `CompositeSubscription` (thanks to [@BreX900](https://github.com/BreX900)) + * CompositeSubscription's `dispose`, `clear`, and `remove` methods now return a completion future. + * Fixed an issue where a stream not present in CompositeSubscription was canceled. + * Added the ability not to cancel the stream when it is removed from CompositeSubscription. + * CompositeSubscription implements `StreamSubscription`. + * `CompositeSubscription.add` will throw a `StateError` instead of a `String` if this composite was disposed. + +### Documentation + +* Fix `Connectable` examples. +* Update Web example to null safety. +* Fix `Flutter` example: `SearchResultItem.fromJson` type error (thanks to [@WenYeh](https://github.com/wayne900204)) + +### Code refactoring + +* Simplify `takeLast` implementation. +* Migrate from `pedantic` to `lints` and `flutter_lints`. +* Refactor `BehaviorSubject`, `ReplaySubject` implementations by using "`Sentinel object`"s instead of `ValueWrapper`s. + +## 0.27.2 (2021-09-03) + +### Bug fixes + +* `onErrorReturnWith` now does not drop the remaining data events after the first error. +* Disallow changing handlers of `ConnectableStreamSubscription`. + +### Features + +* Add `delayWhen` operator. +* Add optional parameter `maxConcurrent` to `flatMap`. +* `groupBy` + * Rename `GroupByStream` to `GroupedStream`. + * Add optional parameter `durationSelector`, which used to determine how long each group should exist. +* `ignoreElements` + * Remove `@deprecated` annotation (`ignoreElements` should not be marked as deprecated). + * Change return type to `Stream`. + +### Documentation + +* Update to `PublishSubject`'s docs (thanks to [@AlexanderJohr](https://github.com/AlexanderJohr)). + +### Code refactoring + +* Refactoring Stream Transformers, using `Stream.multi` internally. + +## 0.27.1 + +* Bugfix: `ForkJoinStream` throws `Null check operator used on a null value` when using nullable-type. +* Bugfix: `delay` operator + * Pause and resume properly. + * Cancel all timers after it has been cancelled. + +## 0.27.0 + * **BREAKING: ValueStream** + * Remove `ValueStreamExtensions`. + * `ValueStream.valueWrapper` becomes + - `value`. + - `valueOrNull`. + - `hasValue`. + * `ValueStream.errorAndStackTrace` becomes + - `error`. + - `errorOrNull`. + - `hasError`. + - `stackTrace`. + * Add `skipLast`/`SkipLastStreamTransformer` (thanks [@HannibalKcc](https://github.com/HannibalKcc)). + * Update `scan`: change `seed` to required param. + * Add `StackTrace` param to `recoveryFn` when using `OnErrorResumeStreamTransformer`/`onErrorResume`/`onErrorReturnWith`. + * Internal refactoring `ConnectableStream`. + +## 0.26.0 + * Stable, null-safe release. + * Add `takeLast` (thanks [@ThomasKliszowski](https://github.com/ThomasKliszowski)). + * Rework for `retry`/`retryWhen`: + * Removed `RetryError`. + * `retry`: emits all errors if retry fails. + * `retryWhen`: emits original error, and error from factory if they are not identical. + * `streamFactory` now accepts non-nullable `StackTrace` argument. + * Update `ValueStream.requireValue` and `ValueStream.requireError`: throws actual error or a `StateError`, + instead of throwing `"Null check operator used on a null value"` error. + +## 0.26.0-nullsafety.1 + * Breaking change: `ValueStream` + - Add `valueWrapper` to `ValueStream`. + - Change `value`, `hasValue`, `error` and `hasError` to extension getters. + * Fixed some API example documentation (thanks [@HannibalKcc](https://github.com/HannibalKcc)). + * `throttle`/`throttleTime` have been optimised for performance. + * Updated Flutter example to work with the latest Flutter stable. + +## 0.26.0-nullsafety.0 + * Migrate this package to null safety. + * Sdk constraints: `>=2.12.0-0 <3.0.0` based on beta release guidelines. + +## 0.25.0 + * Sync behavior when using `publishValueSeeded`. + * `ValueStream`, `ReplayStream`: exposes `stackTrace` along with the `error`: + * Change `ValueStream.error` to `ValueStream.errorAndStackTrace`. + * Change `ReplayStream.errors` to `ReplayStream.errorAndStackTraces`. + * Merge `Notification.error` and `Notification.stackTrace` into `Notification.errorAndStackTrace`. + * Bugfix: `debounce`/`debounceTime` unnecessarily kept too many elements in queue. + +## 0.25.0-beta3 + * Bugfix: `switchMap` doesn't close after the last inner Stream closes. + * Docs: updated URL for "Single-Subscription vs. Broadcast Streams" doc (thanks [Aman Gupta](https://github.com/Aman9026)). + * Add `FromCallableStream`/`Rx.fromCallable`: allows you to create a `Stream` from a callable function. + * Override `BehaviorSubject`'s built-in operators to correct replaying the latest value of `BehaviorSubject`. + * Bugfix: Source `StreamSubscription` doesn't cancel when cancelling `refCount`, `zip`, `merge`, `concat` StreamSubscription. + * Forward done event of upstream to `ConnectableStream`. + +## 0.25.0-beta2 + * Internal refactoring Stream Transformers. + * Fixed `RetryStream` example documentation. + * Error thrown from `DeferStream` factory will now be caught and converted to `Stream.error`. + * `doOnError` now have strong type signature: `Stream doOnError(void Function(Object, StackTrace) onError)`. + * Updated `ForkJoinStream`: + * When any Stream emits an error, listening still continues unless `cancelOnError: true` on the downstream. + * Pause and resume Streams properly. + * Added `UsingStream`. + * Updated `TimerStream`: Pause and resume Timer when pausing and resuming StreamSubscription. + +## 0.25.0-beta + * stream transformations on a ValueStream will also return a ValueStream, instead of + a standard broadcast Stream + * throttle can now be both leading and trailing + * better handling of empty Lists when using operators that accept a List as input + * error & hasError added to BehaviorSubject + * various docs updates + * note that this is a beta release, mainly because the behavior of transform has been adjusted (see first bullet) + if all goes well, we'll release a proper 0.25.0 release soon + +## 0.24.1 + * Fix for BehaviorSubject, no longer emits null when using addStream and expecting an Error as first event (thanks [yuvalr1](https://github.com/yuvalr1)) + * min/max have been optimised for performance + * Further refactors on our Transformers + +## 0.24.0 + * Fix throttle no longer outputting the current buffer onDone + * Adds endWith and endWithMany + * Fix when using pipe and an Error, Subjects would throw an Exception that couldn't be caught using onError + * Updates links for docs (thanks [@renefloor](https://github.com/renefloor)) + * Fix links to correct marbles diagram for debounceTime (thanks [@wheater](https://github.com/Wheater)) + * Fix flakiness of withLatestFrom test Streams + * Update to docs ([@wheater](https://github.com/Wheater)) + * Fix withLatestFrom not pause/resume/cancelling underlying Streams + * Support sync behavior for Subjects + * Add addTo extension for StreamSubscription, use it to easily add a subscription to a CompositeSubscription + * Fix mergeWith and zipWith will return a broadcast Stream, if the source Stream is also broadcast + * Fix concatWith will return a broadcast Stream, if the source Stream is also broadcast (thanks [@jarekb123](https://github.com/jarekb123)) + * Adds pauseAll, resumeAll, ... to CompositeSubscription + * Additionally, fixes some issues introduced with 0.24.0-dev.1 + +## 0.24.0-dev.1 + * Breaking: as of this release, we've refactored the way Stream transformers are set up. + Previous releases had some incorrect behavior when using certain operators, for example: + - startWith (startWithMany, startWithError) + would incorrectly replay the starting event(s) when using a + broadcast Stream at subscription time. + - doOnX was not always producing the expected results: + * doOnData did not output correct sequences on streams that were transformed + multiple times in sequence. + * doOnCancel now acts in the same manner onCancel works on + regular subscriptions, i.e. it will now be called when all + active subscriptions on a Stream are cancelled. + * doOnListen will now call the first time the Stream is + subscribed to, and will only call again after all subscribers + have cancelled, before a new subscription starts. + + To properly fix this up, a new way of transforming Streams was introduced. + Operators as of now use Stream.eventTransformed and we've refactored all + operators to implement Sink instead. + * Adds takeWileInclusive operator (thanks to [@hoc081098](https://github.com/hoc081098)) + + We encourage everyone to give the dev release(s) a spin and report back if + anything breaks. If needed, a guide will be written to help migrate from + the old behavior to the new behavior in certain common use cases. + + Keep in mind that we tend to stick as close as we can to how normal + Dart Streams work! + +## 0.23.1 + + * Fix API doc links in README + +## 0.23.0 + + * Extension Methods replace `Observable` class! + * Please upgrade existing code by using the rxdart_codemod package + * Remove the Observable class. With extensions, you no longer need to wrap Streams in a [Stream]! + * Convert all factories to static constructors to aid in discoverability of Stream classes + * Move all factories to an `Rx` class. + * Remove `Observable.just`, use `Stream.value` + * Remove `Observable.error`, use `Stream.error` + * Remove all tests that check base Stream methods + * Subjects and *Observable classes extend Stream instead of base Observable + * Rename *Observable to *Stream to reflect the fact they're just Streams. + * `ValueObservable` -> `ValueStream` + * `ReplayObservable` -> `ReplayStream` + * `ConnectableObservable` -> `ConnectableStream` + * `ValueConnectableObservable` -> `ValueConnectableStream` + * `ReplayConnectableObservable` -> `ReplayConnectableStream` + * All transformation methods removed from Observable class + * Transformation methods are now Extensions of the Stream class + * Any Stream can make use of the transformation methods provided by RxDart + * Observable class remains in place with factory methods to create different types of Streams + * Removed deprecated `ofType` method, use `whereType` instead + * Deprecated `concatMap`, use standard Stream `asyncExpand`. + * Removed `AsObservableFuture`, `MinFuture`, `MaxFuture`, and `WrappedFuture` + * This removes `asObservable` method in chains + * Use default `asStream` method from the base `Future` class instead. + * `min` and `max` now implemented directly on the Stream class + +## 0.23.0-dev.3 + + * Fix missing exports: + - `ValueStream` + - `ReplayStream` + - `ConnectableStream` + - `ValueConnectableStream` + - `ReplayConnectableStream` + +## 0.23.0-dev.2 + * Remove the Observable class. With extensions, you no longer need to wrap Streams in a [Stream]! + * Convert all factories to static constructors to aid in discoverability of Stream classes + * Move all factories to an `Rx` class. + * Remove `Observable.just`, use `Stream.value` + * Remove `Observable.error`, use `Stream.error` + * Remove all tests that check base Stream methods + * Subjects and *Observable classes extend Stream instead of base Observable + * Rename *Observable to *Stream to reflect the fact they're just Streams. + * `ValueObservable` -> `ValueStream` + * `ReplayObservable` -> `ReplayStream` + * `ConnectableObservable` -> `ConnectableStream` + * `ValueConnectableObservable` -> `ValueConnectableStream` + * `ReplayConnectableObservable` -> `ReplayConnectableStream` + +## 0.23.0-dev.1 + * Feedback on this change appreciated as this is a dev release before 0.23.0 stable! + * All transformation methods removed from Observable class + * Transformation methods are now Extensions of the Stream class + * Any Stream can make use of the transformation methods provided by RxDart + * Observable class remains in place with factory methods to create different types of Streams + * Removed deprecated `ofType` method, use `whereType` instead + * Deprecated `concatMap`, use standard Stream `asyncExpand`. + * Removed `AsObservableFuture`, `MinFuture`, `MaxFuture`, and `WrappedFuture` + * This removes `asObservable` method in chains + * Use default `asStream` method from the base `Future` class instead. + * `min` and `max` now implemented directly on the Stream class + +## 0.22.6 + * Bugfix: When listening multiple times to a`BehaviorSubject` that starts with an Error, + it emits duplicate events. + * Linter: public_member_api_docs is now used, we have added extra documentation + where required. + +## 0.22.5 + * Bugfix: DeferStream created Stream too early + * Bugfix: TimerStream created Timer too early + +## 0.22.4 + * Bugfix: switchMap controller no longer closes prematurely + +## 0.22.3 + * Bugfix: whereType failing in Flutter production builds only + +## 0.22.2 + * Bugfix: When using a seeded `BehaviorSubject` and adding an `Error`, + upon listening, the `BehaviorSubject` emits `null` instead of the last `Error`. + * Bugfix: calling cancel after a `switchMap` can cause a `NoSuchMethodError`. + * Updated Flutter example to match the latest Flutter release + * `Observable.withLatestFrom` is now expanded to accept 2 or more `Stream`s + thanks to Petrus Nguyแป…n Thรกi Hแปc (@hoc081098)! + * Deprecates `ofType` in favor of `whereType`, drop `TypeToken`. + +## 0.22.1 + Fixes following issues: + * Erroneous behavior with scan and `BehaviorSubject`. + * Bug where `flatMap` would cancel inner subscriptions in `pause`/`resume`. + * Updates to make the current "pedantic" analyzer happy. + +## 0.22.0 + This version includes refactoring for the backpressure operators: + * Breaking Change: `debounce` is now split into `debounce` and `debounceTime`. + * Breaking Change: `sample` is now split into `sample` and `sampleTime`. + * Breaking Change: `throttle` is now split into `throttle` and `throttleTime`. + +## 0.21.0 + * Breaking Change: `BehaviorSubject` now has a separate factory constructor `seeded()` + This allows you to seed this Subject with a `null` value. + * Breaking Change: `BehaviorSubject` will now emit an `Error`, if the last event was also an `Error`. + Before, when an `Error` occurred before a `listen`, the subscriber would not be notified of that `Error`. + To refactor, simply change all occurences of `BehaviorSubject(seedValue: value)` to `BehaviorSubject.seeded(value)` + * Added the `groupBy` operator + * Bugix: `doOnCancel`: will now await the cancel result, if it is a `Future`. + * Removed: `bufferWithCount`, `windowWithCount`, `tween` + Please use `bufferCount` and `windowCount`, `tween` is removed, because it never was an official Rx spec. + * Updated Flutter example to work with the latest Flutter stable. + +## 0.20.0 + * Breaking Change: bufferCount had buggy behavior when using `startBufferEvery` (was `skip` previously) + If you were relying on bufferCount with `skip` greater than 1 before, then you may have noticed + erroneous behavior. + * Breaking Change: `repeat` is no longer an operator which simply repeats the last emitted event n-times, + instead this is now an Observable factory method which takes a StreamFactory and a count parameter. + This will cause each repeat cycle to create a fresh Observable sequence. + * `mapTo` is a new operator, which works just like `map`, but instead of taking a mapper Function, it takes + a single value where each event is mapped to. + * Bugfix: switchIfEmpty now correctly calls onDone + * combineLatest and zip can now take any amount of Streams: + * combineLatest2-9 & zip2-9 functionality unchanged, but now use a new path for construction. + * adds combineLatest and zipLatest which allows you to pass through an Iterable> and a combiner that takes a List when any source emits a change. + * adds combineLatestList / zipList which allows you to take in an Iterable> and emit a Observable> with the values. Just a convenience factory if all you want is the list! + * Constructors are provided by the Stream implementation directly + * Bugfix: Subjects that are transformed will now correctly return a new Observable where isBroadcast is true (was false before) + * Remove deprecated operators which were replaced long ago: `bufferWithCount`, `windowWithCount`, `amb`, `flatMapLatest` + +## 0.19.0 + + * Breaking Change: Subjects `onCancel` function now returns `void` instead of `Future` to properly comply with the `StreamController` signature. + * Bugfix: FlatMap operator properly calls onDone for all cases + * Connectable Observable: An observable that can be listened to multiple times, and does not begin emitting values until the `connect` method is called + * ValueObservable: A new interface that allows you to get the latest value emitted by an Observable. + * Implemented by BehaviorSubject + * Convert normal observables into ValueObservables via `publishValue` or `shareValue` + * ReplayObservable: A new interface that allows you to get the values emitted by an Observable. + * Implemented by ReplaySubject + * Convert normal observables into ReplayObservables via `publishReplay` or `shareReplay` + +## 0.18.1 + +* Add `retryWhen` operator. Thanks to Razvan Lung (@long1eu)! This can be used for custom retry logic. + +## 0.18.0 + +* Breaking Change: remove `retype` method, deprecated as part of Dart 2. +* Add `flatMapIterable` + +## 0.17.0 + +* Breaking Change: `stream` property on Observable is now private. + * Avoids API confusion + * Simplifies Subject implementation + * Require folks who are overriding the `stream` property to use a `super` constructor instead +* Adds proper onPause and onResume handling for `amb`/`race`, `combineLatest`, `concat`, `concat_eager`, `merge` and `zip` +* Add `switchLatest` operator +* Add errors and stacktraces to RetryError class +* Add `onErrorResume` and `onErrorRetryWith` operators. These allow folks to return a specific stream or value depending on the error that occurred. + +## 0.16.7 + +* Fix new buffer and window implementation for Flutter + Dart 2 +* Subject now implements the Observable interface + +## 0.16.6 + +* Rework for `buffer` and `window`, allow to schedule using a sampler +* added `buffer` +* added `bufferFuture` +* added `bufferTest` +* added `bufferTime` +* added `bufferWhen` +* added `window` +* added `windowFuture` +* added `windowTest` +* added `windowTime` +* added `windowWhen` +* added `onCount` sampler for `buffer` and `window` +* added `onFuture` sampler for `buffer` and `window` +* added `onTest` sampler for `buffer` and `window` +* added `onTime` sampler for `buffer` and `window` +* added `onStream` sampler for `buffer` and `window` + +## 0.16.5 + +* Renames `amb` to `race` +* Renames `flatMapLatest` to `switchMap` +* Renames `bufferWithCount` to `bufferCount` +* Renames `windowWithCount` to `windowCount` + +## 0.16.4 + +* Adds `bufferTime` transformer. +* Adds `windowTime` transformer. + +## 0.16.3 + +* Adds `delay` transformer. + +## 0.16.2 + +* Fix added events to `sink` are not processed correctly by `Subjects`. + +## 0.16.1 + +* Fix `dematerialize` method for Dart 2. + +## 0.16.0+2 + +* Add `value` to `BehaviorSubject`. Allows you to get the latest value emitted by the subject if it exists. +* Add `values` to `ReplayrSubject`. Allows you to get the values stored by the subject if any exists. + +## 0.16.0+1 + +* Update Changelog + +## 0.16.0 + +* **breaks backwards compatibility**, this release only works with Dart SDK >=2.0.0. +* Removed old `cast` in favour of the now native Stream cast method. +* Override `retype` to return an `Observable`. + +## 0.15.1 + +* Add `exhaustMap` map to inner observable, ignore other values until that observable completes. +* Improved code to be dartdevc compatible. +* Add upper SDK version limit in pubspec + +## 0.15.0 + +* Change `debounce` to emit the last item of the source stream as soon as the source stream completes. +* Ensure `debounce` does not keep open any addition async timers after it has been cancelled. + +## 0.14.0+1 + +* Change `DoStreamTransformer` to return a `Future` on cancel for api compatibility. + +## 0.14.0 + +* Add `PublishSubject` (thanks to @pauldemarco) +* Fix bug with `doOnX` operators where callbacks were fired too often + +## 0.13.1 + +* Fix error with FlatMapLatest where it was not properly cancelled in some scenarios +* Remove additional async methods on Stream handlers unless they're shown to solve a problem + +## 0.13.0 + +* Remove `call` operator / `StreamTransformer` entirely +* Important bug fix: Errors thrown within any Stream or Operator will now be properly sent to the `StreamSubscription`. +* Improve overall handling of errors throughout the library to ensure they're handled correctly + +## 0.12.0 + +* Added doOn* operators in place of `call`. +* Added `DoStreamTransformer` as a replacement for `CallStreamTransformer` +* Deprecated `call` and `CallStreamTransformer`. Please use the appropriate `doOnX` operator / transformer. +* Added `distinctUnique`. Emits items if they've never been emitted before. Same as to Rx#distinct. + +## 0.11.0 + +* !!!Breaking Api Change!!! + * Observable.groupBy has been removed in order to be compatible with the next version of the `Stream` class in Dart 1.24.0, which includes this method + +## 0.10.2 + +* BugFix: The new Subject implementation no longer causes infinite loops when used with ng2 async pipes. + +## 0.10.1 + +* Documentation fixes + +## 0.10.0 + +* Api Changes + * Observable + * Remove all deprecated methods, including: + * `observable` factory -- replaced by the constructor `new Observable()` + * `combineLatest` -- replaced by Strong-Mode versions `combineLatest2` - `combineLatest9` + * `zip` -- replaced by Strong-Mode versions `zip2` - `zip9` + * Support `asObservable` conversion from Future-returning methods. e.g. `new Observable.fromIterable([1, 2]).first.asObservable()` + * Max and Min now return a Future of the Max or Min value, rather than a stream of increasing or decreasing values. + * Add `cast` operator + * Remove `ConcatMapStreamTransformer` -- functionality is already supported by `asyncExpand`. Keep the `concatMap` method as an alias. + * Subjects + * BehaviourSubject has been renamed to BehaviorSubject + * The subjects have been rewritten and include far more testing + * In keeping with the Rx idea of Subjects, they are broadcast-only +* Documentation -- extensive documentation has been added to the library with explanations and examples for each Future, Stream & Transformer. + * Docs detailing the differences between RxDart and raw Observables. + +## 0.9.0 + +* Api Changes: + * Convert all StreamTransformer factories to proper classes + * Ensure these classes can be re-used multiple times + * Retry has moved from an operator to a constructor. This is to ensure the stream can be properly re-constructed every time in the correct way. + * Streams now properly enforce the single-subscription contract +* Include example Flutter app. To run it, please follow the instructions in the README. + +## 0.8.3+1 +* rename examples map to example + +## 0.8.3 +* added concatWith, zipWith, mergeWith, skipUntil +* cleanup of the examples folder +* cleanup of examples code +* added fibonacci example +* added search GitHub example + +## 0.8.2+1 +* moved repo into ReactiveX +* update readme badges accordingly + +## 0.8.2 +* added materialize/dematerialize +* added range (factory) +* added timer (factory) +* added timestamp +* added concatMap + +## 0.8.1 +* added never constructor +* added error constructor +* moved code coverage to [codecov.io](https://codecov.io/gh/frankpepermans/rxdart) + +## 0.8.0 +* BREAKING: tap is replaced by call(onData) +* added call, which can take any combination of the following event methods: +onCancel, onData, onDone, onError, onListen, onPause, onResume + +## 0.7.1+1 +* improved the README file + +## 0.7.1 +* added ignoreElements +* added onErrorResumeNext +* added onErrorReturn +* added switchIfEmpty +* added empty factory constructor + +## 0.7.0 +* BREAKING: rename combineXXXLatest and zipXXX to a numbered equivalent, +for example: combineThreeLatest becomes combineLatest3 +* internal refactoring, expose streams/stream transformers as a separate library + +## 0.6.3+4 +* changed ofType to use TypeToken + +## 0.6.3+3 +* added ofType + +## 0.6.3+2 +* added defaultIfEmpty + +## 0.6.3+1 +* changed concat, old concat is now concatEager, new concat behaves as expected + +## 0.6.3 +* Added withLatestFrom +* Added defer ctr +(both thanks to [brianegan](https://github.com/brianegan "GitHub link")) + +## 0.6.2 +* Added just (thanks to [brianegan](https://github.com/brianegan "GitHub link")) +* Added groupBy +* Added amb + +## 0.6.1 +* Added concat + +## 0.6.0 +* BREAKING: startWith now takes just one parameter instead of an Iterable. To add multiple starting events, please use startWithMany. +* Added BehaviourSubject and ReplaySubject. These implement StreamController. +* BehaviourSubject will notify the last added event upon listening. +* ReplaySubject will notify all past events upon listening. +* DEPRECATED: zip and combineLatest, use their strong-type-friendly alternatives instead (available as static methods on the Observable class, i.e. Observable.combineThreeLatest, Observable.zipFour, ...) + +## 0.5.1 + +* Added documentation (thanks to [dustinlessard-wf](https://github.com/dustinlessard-wf "GitHub link")) +* Fix tests breaking due to deprecation of expectAsync +* Fix tests to satisfy strong mode requirements + +## 0.5.0 + +* As of this version, rxdart depends on SDK v1.21.0, to support the newly added generic method type syntax + +[Unreleased]: https://github.com/ReactiveX/rxdart/compare/0.28.0...HEAD +[0.28.0]: https://github.com/ReactiveX/rxdart/releases/tag/0.28.0 +[0.28.0-dev.2]: https://github.com/ReactiveX/rxdart/releases/tag/0.28.0-dev.2 +[0.28.0-dev.1]: https://github.com/ReactiveX/rxdart/releases/tag/0.28.0-dev.1 +[0.28.0-dev.0]: https://github.com/ReactiveX/rxdart/releases/tag/0.28.0-dev.0 \ No newline at end of file diff --git a/core/reactivex/LICENSE b/core/reactivex/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/core/reactivex/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/core/reactivex/README.md b/core/reactivex/README.md new file mode 100644 index 00000000..4f6f0da6 --- /dev/null +++ b/core/reactivex/README.md @@ -0,0 +1,277 @@ +# RxDart + +

+build +

+ +

+RxDart +

+ +[![Build Status](https://github.com/ReactiveX/rxdart/workflows/Dart%20CI/badge.svg)](https://github.com/ReactiveX/rxdart/actions) +[![codecov](https://codecov.io/gh/ReactiveX/rxdart/branch/master/graph/badge.svg)](https://codecov.io/gh/ReactiveX/rxdart) +[![Pub](https://img.shields.io/pub/v/rxdart.svg)](https://pub.dartlang.org/packages/rxdart) +[![Pub Version (including pre-releases)](https://img.shields.io/pub/v/rxdart?include_prereleases&color=%23A0147B)](https://pub.dartlang.org/packages/rxdart) +[![Gitter](https://img.shields.io/gitter/room/ReactiveX/rxdart.svg)](https://gitter.im/ReactiveX/rxdart) +[![Flutter website](https://img.shields.io/badge/flutter-website-deepskyblue.svg)](https://docs.flutter.dev/data-and-backend/state-mgmt/options#bloc--rx) +[![Build Flutter example](https://github.com/ReactiveX/rxdart/actions/workflows/flutter-example.yml/badge.svg)](https://github.com/ReactiveX/rxdart/actions/workflows/flutter-example.yml) +[![License](https://img.shields.io/github/license/ReactiveX/rxdart)](https://www.apache.org/licenses/LICENSE-2.0) +[![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2FReactiveX%2Frxdart&count_bg=%23D71092&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) + +## About + +RxDart extends the capabilities of Dart +[Streams](https://api.dart.dev/stable/dart-async/Stream-class.html) and +[StreamControllers](https://api.dart.dev/stable/dart-async/StreamController-class.html). + +Dart comes with a very decent +[Streams](https://api.dart.dev/stable/dart-async/Stream-class.html) API +out-of-the-box; rather than attempting to provide an alternative to this API, +RxDart adds functionality from the reactive extensions specification on top of +it. + +RxDart does not provide its Observable class as a replacement for Dart +Streams. Instead, it offers several additional Stream classes, operators +(extension methods on the Stream class), and Subjects. + +If you are familiar with Observables from other languages, please see [the Rx +Observables vs. Dart Streams comparison chart](#rx-observables-vs-dart-streams) +for notable distinctions between the two. + +## Upgrading from RxDart 0.22.x to 0.23.x + +RxDart 0.23.x moves away from the Observable class, utilizing Dart 2.6's new +extension methods instead. This requires several small refactors that can be +easily automated -- which is just what we've done! + +Please follow the instructions on the +[rxdart_codemod](https://pub.dev/packages/rxdart_codemod) package to +automatically upgrade your code to support RxDart 0.23.x. + +## How To Use RxDart + +### For Example: Reading the Konami Code + +```dart +import 'package:rxdart/rxdart.dart'; + +void main() { + const konamiKeyCodes = [ + KeyCode.UP, + KeyCode.UP, + KeyCode.DOWN, + KeyCode.DOWN, + KeyCode.LEFT, + KeyCode.RIGHT, + KeyCode.LEFT, + KeyCode.RIGHT, + KeyCode.B, + KeyCode.A, + ]; + + final result = querySelector('#result')!; + + document.onKeyUp + .map((event) => event.keyCode) + .bufferCount(10, 1) // An extension method provided by rxdart + .where((lastTenKeyCodes) => const IterableEquality().equals(lastTenKeyCodes, konamiKeyCodes)) + .listen((_) => result.innerHtml = 'KONAMI!'); +} +``` + +## API Overview + +RxDart adds functionality to Dart Streams in three ways: + + * [Stream Classes](#stream-classes) - create Streams with specific capabilities, such as combining or merging many Streams. + * [Extension Methods](#extension-methods) - transform a source Stream into a new Stream with different capabilities, such as throttling or buffering events. + * [Subjects](#subjects) - StreamControllers with additional powers + +### Stream Classes + +The Stream class provides different ways to create a Stream: `Stream.fromIterable` or `Stream.periodic`. RxDart provides additional Stream classes for a variety of tasks, such as combining or merging Streams! + +You can construct the Streams provided by RxDart in two ways. The following examples are equivalent in terms of functionality: + + - Instantiating the Stream class directly. + - Example: `final mergedStream = MergeStream([myFirstStream, mySecondStream]);` + - Using static factories from the Rx class, which are useful for discovering which types of Streams are provided by RxDart. Under the hood, these factories call the corresponding Stream constructor. + - Example: `final mergedStream = Rx.merge([myFirstStream, mySecondStream]);` + +#### List of Classes / Static Factories + +- [CombineLatestStream](https://pub.dev/documentation/rxdart/latest/rx/CombineLatestStream-class.html) (combine2, combine3... combine9) / [Rx.combineLatest2](https://pub.dev/documentation/rxdart/latest/rx/Rx/combineLatest2.html)...[Rx.combineLatest9](https://pub.dev/documentation/rxdart/latest/rx/Rx/combineLatest9.html) +- [ConcatStream](https://pub.dev/documentation/rxdart/latest/rx/ConcatStream-class.html) / [Rx.concat](https://pub.dev/documentation/rxdart/latest/rx/Rx/concat.html) +- [ConcatEagerStream](https://pub.dev/documentation/rxdart/latest/rx/ConcatEagerStream-class.html) / [Rx.concatEager](https://pub.dev/documentation/rxdart/latest/rx/Rx/concatEager.html) +- [DeferStream](https://pub.dev/documentation/rxdart/latest/rx/DeferStream-class.html) / [Rx.defer](https://pub.dev/documentation/rxdart/latest/rx/Rx/defer.html) +- [ForkJoinStream](https://pub.dev/documentation/rxdart/latest/rx/ForkJoinStream-class.html) (join2, join3... join9) / [Rx.forkJoin2](https://pub.dev/documentation/rxdart/latest/rx/Rx/forkJoin2.html)...[Rx.forkJoin9](https://pub.dev/documentation/rxdart/latest/rx/Rx/forkJoin9.html) +- [FromCallableStream](https://pub.dev/documentation/rxdart/latest/rx/FromCallableStream-class.html) / [Rx.fromCallable](https://pub.dev/documentation/rxdart/latest/rx/Rx/fromCallable.html) +- [MergeStream](https://pub.dev/documentation/rxdart/latest/rx/MergeStream-class.html) / [Rx.merge](https://pub.dev/documentation/rxdart/latest/rx/Rx/merge.html) +- [NeverStream](https://pub.dev/documentation/rxdart/latest/rx/NeverStream-class.html) / [Rx.never](https://pub.dev/documentation/rxdart/latest/rx/Rx/never.html) +- [RaceStream](https://pub.dev/documentation/rxdart/latest/rx/RaceStream-class.html) / [Rx.race](https://pub.dev/documentation/rxdart/latest/rx/Rx/race.html) +- [RangeStream](https://pub.dev/documentation/rxdart/latest/rx/RangeStream-class.html) / [Rx.range](https://pub.dev/documentation/rxdart/latest/rx/Rx/range.html) +- [RepeatStream](https://pub.dev/documentation/rxdart/latest/rx/RepeatStream-class.html) / [Rx.repeat](https://pub.dev/documentation/rxdart/latest/rx/Rx/repeat.html) +- [RetryStream](https://pub.dev/documentation/rxdart/latest/rx/RetryStream-class.html) / [Rx.retry](https://pub.dev/documentation/rxdart/latest/rx/Rx/retry.html) +- [RetryWhenStream](https://pub.dev/documentation/rxdart/latest/rx/RetryWhenStream-class.html) / [Rx.retryWhen](https://pub.dev/documentation/rxdart/latest/rx/Rx/retryWhen.html) +- [SequenceEqualStream](https://pub.dev/documentation/rxdart/latest/rx/SequenceEqualStream-class.html) / [Rx.sequenceEqual](https://pub.dev/documentation/rxdart/latest/rx/Rx/sequenceEqual.html) +- [SwitchLatestStream](https://pub.dev/documentation/rxdart/latest/rx/SwitchLatestStream-class.html) / [Rx.switchLatest](https://pub.dev/documentation/rxdart/latest/rx/Rx/switchLatest.html) +- [TimerStream](https://pub.dev/documentation/rxdart/latest/rx/TimerStream-class.html) / [Rx.timer](https://pub.dev/documentation/rxdart/latest/rx/Rx/timer.html) +- [UsingStream](https://pub.dev/documentation/rxdart/latest/rx/UsingStream-class.html) / [Rx.using](https://pub.dev/documentation/rxdart/latest/rx/Rx/using.html) +- [ZipStream](https://pub.dev/documentation/rxdart/latest/rx/ZipStream-class.html) (zip2, zip3, zip4, ..., zip9) / [Rx.zip](https://pub.dev/documentation/rxdart/latest/rx/Rx/zip2.html)...[Rx.zip9](https://pub.dev/documentation/rxdart/latest/rx/Rx/zip9.html) +- If you're looking for an [Interval](https://reactivex.io/documentation/operators/interval.html) equivalent, check out Dart's [Stream.periodic](https://api.dart.dev/stable/2.7.2/dart-async/Stream/Stream.periodic.html) for similar behavior. + +### Extension Methods + +The extension methods provided by RxDart can be used on any `Stream`. They convert a source Stream into a new Stream with additional capabilities, such as buffering or throttling events. + +#### Example + +```dart +Stream.fromIterable([1, 2, 3]) + .throttleTime(Duration(seconds: 1)) + .listen(print); // prints 1 +``` + +#### List of Extension Methods + +- [buffer](https://pub.dev/documentation/rxdart/latest/rx/BufferExtensions/buffer.html) +- [bufferCount](https://pub.dev/documentation/rxdart/latest/rx/BufferExtensions/bufferCount.html) +- [bufferTest](https://pub.dev/documentation/rxdart/latest/rx/BufferExtensions/bufferTest.html) +- [bufferTime](https://pub.dev/documentation/rxdart/latest/rx/BufferExtensions/bufferTime.html) +- [concatWith](https://pub.dev/documentation/rxdart/latest/rx/ConcatExtensions/concatWith.html) +- [debounce](https://pub.dev/documentation/rxdart/latest/rx/DebounceExtensions/debounce.html) +- [debounceTime](https://pub.dev/documentation/rxdart/latest/rx/DebounceExtensions/debounceTime.html) +- [defaultIfEmpty](https://pub.dev/documentation/rxdart/latest/rx/DefaultIfEmptyExtension/defaultIfEmpty.html) +- [delay](https://pub.dev/documentation/rxdart/latest/rx/DelayExtension/delay.html) +- [delayWhen](https://pub.dev/documentation/rxdart/latest/rx/DelayWhenExtension/delayWhen.html) +- [dematerialize](https://pub.dev/documentation/rxdart/latest/rx/DematerializeExtension/dematerialize.html) +- [distinctUnique](https://pub.dev/documentation/rxdart/latest/rx/DistinctUniqueExtension/distinctUnique.html) +- [doOnCancel](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnCancel.html) +- [doOnData](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnData.html) +- [doOnDone](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnDone.html) +- [doOnEach](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnEach.html) +- [doOnError](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnError.html) +- [doOnListen](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnListen.html) +- [doOnPause](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnPause.html) +- [doOnResume](https://pub.dev/documentation/rxdart/latest/rx/DoExtensions/doOnResume.html) +- [endWith](https://pub.dev/documentation/rxdart/latest/rx/EndWithExtension/endWith.html) +- [endWithMany](https://pub.dev/documentation/rxdart/latest/rx/EndWithManyExtension/endWithMany.html) +- [exhaustMap](https://pub.dev/documentation/rxdart/latest/rx/ExhaustMapExtension/exhaustMap.html) +- [flatMap](https://pub.dev/documentation/rxdart/latest/rx/FlatMapExtension/flatMap.html) +- [flatMapIterable](https://pub.dev/documentation/rxdart/latest/rx/FlatMapExtension/flatMapIterable.html) +- [groupBy](https://pub.dev/documentation/rxdart/latest/rx/GroupByExtension/groupBy.html) +- [interval](https://pub.dev/documentation/rxdart/latest/rx/IntervalExtension/interval.html) +- [mapNotNull](https://pub.dev/documentation/rxdart/latest/rx/MapNotNullExtension/mapNotNull.html) +- [mapTo](https://pub.dev/documentation/rxdart/latest/rx/MapToExtension/mapTo.html) +- [materialize](https://pub.dev/documentation/rxdart/latest/rx/MaterializeExtension/materialize.html) +- [max](https://pub.dev/documentation/rxdart/latest/rx/MaxExtension/max.html) +- [mergeWith](https://pub.dev/documentation/rxdart/latest/rx/MergeExtension/mergeWith.html) +- [min](https://pub.dev/documentation/rxdart/latest/rx/MinExtension/min.html) +- [onErrorResume](https://pub.dev/documentation/rxdart/latest/rx/OnErrorExtensions/onErrorResume.html) +- [onErrorResumeNext](https://pub.dev/documentation/rxdart/latest/rx/OnErrorExtensions/onErrorResumeNext.html) +- [onErrorReturn](https://pub.dev/documentation/rxdart/latest/rx/OnErrorExtensions/onErrorReturn.html) +- [onErrorReturnWith](https://pub.dev/documentation/rxdart/latest/rx/OnErrorExtensions/onErrorReturnWith.html) +- [pairwise](https://pub.dev/documentation/rxdart/latest/rx/PairwiseExtension/pairwise.html) +- [sample](https://pub.dev/documentation/rxdart/latest/rx/SampleExtensions/sample.html) +- [sampleTime](https://pub.dev/documentation/rxdart/latest/rx/SampleExtensions/sampleTime.html) +- [scan](https://pub.dev/documentation/rxdart/latest/rx/ScanExtension/scan.html) +- [skipLast](https://pub.dev/documentation/rxdart/latest/rx/SkipLastExtension/skipLast.html) +- [skipUntil](https://pub.dev/documentation/rxdart/latest/rx/SkipUntilExtension/skipUntil.html) +- [startWith](https://pub.dev/documentation/rxdart/latest/rx/StartWithExtension/startWith.html) +- [startWithMany](https://pub.dev/documentation/rxdart/latest/rx/StartWithManyExtension/startWithMany.html) +- [switchIfEmpty](https://pub.dev/documentation/rxdart/latest/rx/SwitchIfEmptyExtension/switchIfEmpty.html) +- [switchMap](https://pub.dev/documentation/rxdart/latest/rx/SwitchMapExtension/switchMap.html) +- [takeLast](https://pub.dev/documentation/rxdart/latest/rx/TakeLastExtension/takeLast.html) +- [takeUntil](https://pub.dev/documentation/rxdart/latest/rx/TakeUntilExtension/takeUntil.html) +- [takeWhileInclusive](https://pub.dev/documentation/rxdart/latest/rx/TakeWhileInclusiveExtension/takeWhileInclusive.html) +- [throttle](https://pub.dev/documentation/rxdart/latest/rx/ThrottleExtensions/throttle.html) +- [throttleTime](https://pub.dev/documentation/rxdart/latest/rx/ThrottleExtensions/throttleTime.html) +- [timeInterval](https://pub.dev/documentation/rxdart/latest/rx/TimeIntervalExtension/timeInterval.html) +- [timestamp](https://pub.dev/documentation/rxdart/latest/rx/TimeStampExtension/timestamp.html) +- [whereNotNull](https://pub.dev/documentation/rxdart/latest/rx/WhereNotNullExtension/whereNotNull.html) +- [whereType](https://pub.dev/documentation/rxdart/latest/rx/WhereTypeExtension/whereType.html) +- [window](https://pub.dev/documentation/rxdart/latest/rx/WindowExtensions/window.html) +- [windowCount](https://pub.dev/documentation/rxdart/latest/rx/WindowExtensions/windowCount.html) +- [windowTest](https://pub.dev/documentation/rxdart/latest/rx/WindowExtensions/windowTest.html) +- [windowTime](https://pub.dev/documentation/rxdart/latest/rx/WindowExtensions/windowTime.html) +- [withLatestFrom](https://pub.dev/documentation/rxdart/latest/rx/WithLatestFromExtensions.html) +- [zipWith](https://pub.dev/documentation/rxdart/latest/rx/ZipWithExtension/zipWith.html) + +### Subjects + +Dart provides the [StreamController](https://api.dart.dev/stable/dart-async/StreamController-class.html) class to create and manage a Stream. RxDart offers two additional StreamControllers with additional capabilities, known as Subjects: + +- [BehaviorSubject](https://pub.dev/documentation/rxdart/latest/rx/BehaviorSubject-class.html) - A broadcast StreamController that caches the latest added value or error. When a new listener subscribes to the Stream, the latest value or error will be emitted to the listener. Furthermore, you can synchronously read the last emitted value. +- [ReplaySubject](https://pub.dev/documentation/rxdart/latest/rx/ReplaySubject-class.html) - A broadcast StreamController that caches the added values. When a new listener subscribes to the Stream, the cached values will be emitted to the listener. + +## Rx Observables vs Dart Streams + +In many situations, Streams and Observables work the same way. However, if you're used to standard Rx Observables, some features of the Stream API may surprise you. We've included a table below to help folks understand the differences. + +Additional information about the following situations can be found by reading the [Rx class documentation](https://pub.dev/documentation/rxdart/latest/rx/Rx-class.html). + +| Situation | Rx Observables | Dart Streams | +| ------------- |------------- | ------------- | +| An error is raised | Observable Terminates with Error | Error is emitted and Stream continues | +| Cold Observables | Multiple subscribers can listen to the same cold Observable, and each subscription will receive a unique Stream of data | Single subscriber only | +| Hot Observables | Yes | Yes, known as Broadcast Streams | +| Is {Publish, Behavior, Replay}Subject hot? | Yes | Yes | +| Single/Maybe/Completable ? | Yes | Yes, uses [rxdart_ext Single](https://pub.dev/documentation/rxdart_ext/latest/rxdart_ext/Single-class.html) (`Completable == Single` and `Maybe == Single`) | +| Support back pressure| Yes | Yes | +| Can emit null? | Yes, except RxJava | Yes | +| Sync by default | Yes | No | +| Can pause/resume a subscription*? | No | Yes | + +## Examples + +Web and command-line examples can be found in the `example` folder. + +### Web Examples + +In order to run the web examples, please follow these steps: + + 1. Clone this repo and enter the directory `examples/web` + 2. Run `dart pub get` + 3. Run `dart pub global activate webdev` + 4. Run `webdev serve` + 5. Navigate to http://localhost:8080/ in your browser + +### Command Line Examples + +In order to run the command line example, please follow these steps: + + 1. Clone this repo and enter the directory + 2. Run `pub get` + 3. Run `dart examples/fibonacci/lib/example.dart 10` + +### Flutter Example + +#### Install Flutter + +To run the flutter example, you must have Flutter installed. For installation instructions, view the online +[documentation](https://flutter.io/). + +#### Run the app + + 1. Open up an Android Emulator, the iOS Simulator, or connect an appropriate mobile device for debugging. + 2. Open up a terminal + 3. `cd` into the `examples/flutter/github_search` directory + 4. Run `flutter doctor` to ensure you have all Flutter dependencies working. + 5. Run `flutter packages get` + 6. Run `flutter run` + +## Notable References + +- [Documentation on the Dart Stream class](https://api.dart.dev/stable/dart-async/Stream-class.html) +- [Tutorial on working with Streams in Dart](https://www.dartlang.org/tutorials/language/streams) +- [ReactiveX (Rx)](https://reactivex.io/) + +## Changelog + +Refer to the [Changelog](https://github.com/ReactiveX/rxdart/blob/master/packages/rxdart/CHANGELOG.md) to get all release notes. + +## Extensions + +Check out [rxdart_ext](https://pub.dev/packages/rxdart_ext), which provides many extension methods and classes built on top of RxDart. + + diff --git a/core/reactivex/analysis_options.yaml b/core/reactivex/analysis_options.yaml new file mode 100644 index 00000000..6f808f61 --- /dev/null +++ b/core/reactivex/analysis_options.yaml @@ -0,0 +1,15 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-raw-types: true + strict-inference: true + +linter: + rules: + - public_member_api_docs + - always_declare_return_types # https://github.com/dart-lang/lints#migrating-from-packagepedantic + - prefer_single_quotes # https://github.com/dart-lang/lints#migrating-from-packagepedantic + - unawaited_futures # https://github.com/dart-lang/lints#migrating-from-packagepedantic + - unsafe_html # https://github.com/dart-lang/lints#migrating-from-packagepedantic diff --git a/core/reactivex/lib/angel3_reactivex.dart b/core/reactivex/lib/angel3_reactivex.dart new file mode 100644 index 00000000..2003c7aa --- /dev/null +++ b/core/reactivex/lib/angel3_reactivex.dart @@ -0,0 +1,7 @@ +library rx; + +export 'src/rx.dart'; +export 'streams.dart'; +export 'subjects.dart'; +export 'transformers.dart'; +export 'utils.dart'; diff --git a/core/reactivex/lib/src/rx.dart b/core/reactivex/lib/src/rx.dart new file mode 100644 index 00000000..113180ec --- /dev/null +++ b/core/reactivex/lib/src/rx.dart @@ -0,0 +1,1357 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; +import 'package:angel3_reactivex/streams.dart'; + +/// A utility class that provides static methods to create the various Streams +/// provided by angel3_reactivex. +/// +/// ### Example +/// +/// Rx.combineLatest([ +/// Stream.value('a'), +/// Stream.fromIterable(['b', 'c', 'd']) +/// ], (list) => list.join()) +/// .listen(print); // prints 'ab', 'ac', 'ad' +/// +/// ### Learning angel3_reactivex +/// +/// This library contains documentation and examples for each method. In +/// addition, more complex examples can be found in the +/// [angel3_reactivex github repo](https://github.com/ReactiveX/angel3_reactivex) demonstrating how +/// to use angel3_reactivex with web, command line, and Flutter applications. +/// +/// #### Additional Resources +/// +/// In addition to the angel3_reactivex documentation and examples, you can find many +/// more articles on Dart Streams that teach the fundamentals upon which +/// angel3_reactivex is built. +/// +/// - [Asynchronous Programming: Streams](https://www.dartlang.org/tutorials/language/streams) +/// - [Single-Subscription vs. Broadcast Streams](https://dart.dev/tutorials/language/streams#two-kinds-of-streams) +/// - [Creating Streams in Dart](https://www.dartlang.org/articles/libraries/creating-streams) +/// - [Testing Streams: Stream Matchers](https://pub.dartlang.org/packages/test#stream-matchers) +/// +/// ### Dart Streams vs Traditional Rx Observables +/// In ReactiveX, the Observable class is the heart of the ecosystem. +/// Observables represent data sources that emit 'items' or 'events' over time. +/// Dart already includes such a data source: Streams. +/// +/// In order to integrate fluently with the Dart ecosystem, Rx Dart does not +/// provide a [Stream] class, but rather adds functionality to Dart Streams. +/// This provides several advantages: +/// +/// - angel3_reactivex works with any API that expects a Dart Stream as an input. +/// - No need to implement or replace the many methods and properties from the core Stream API. +/// - Ability to create Streams with language-level syntax. +/// +/// Overall, we attempt to follow the ReactiveX spec as closely as we can, but +/// prioritize fitting in with the Dart ecosystem when a trade-off must be made. +/// Therefore, there are some important differences to note between Dart's +/// [Stream] class and standard Rx `Observable`. +/// +/// First, Cold Observables exist in Dart as normal Streams, but they are +/// single-subscription only. In other words, you can only listen a Stream +/// once, unless it is a hot (aka broadcast) Stream. If you attempt to listen to +/// a cold Stream twice, a StateError will be thrown. If you need to listen to a +/// stream multiple times, you can simply create a factory function that returns +/// a new instance of the stream. +/// +/// Second, many methods contained within, such as `first` and `last` do not +/// return a `Single` nor an `Observable`, but rather must return a Dart Future. +/// Luckily, Dart's `Future` class is conceptually similar to `Single`, and can +/// be easily converted back to a Stream using the `myFuture.asStream()` method +/// if needed. +/// +/// Third, Streams in Dart do not close by default when an error occurs. In Rx, +/// an Error causes the Observable to terminate unless it is intercepted by +/// an operator. Dart has mechanisms for creating streams that close when an +/// error occurs, but the majority of Streams do not exhibit this behavior. +/// +/// Fourth, Dart streams are asynchronous by default, whereas Observables are +/// synchronous by default, unless you schedule work on a different Scheduler. +/// You can create synchronous Streams with Dart, but please be aware the the +/// default is simply different. +/// +/// Finally, when using Dart Broadcast Streams (similar to Hot Observables), +/// please know that `onListen` will only be called the first time the +/// broadcast stream is listened to. +abstract class Rx { + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an item. + /// This is helpful when you need to combine a dynamic number of Streams. + /// + /// The Stream will not emit any lists of values until all of the source + /// streams have emitted at least one value. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items and without any calls to the combiner function. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest([ + /// Stream.value('a'), + /// Stream.fromIterable(['b', 'c', 'd']) + /// ], (list) => list.join()) + /// .listen(print); // prints 'ab', 'ac', 'ad' + static Stream combineLatest( + Iterable> streams, R Function(List values) combiner) => + CombineLatestStream(streams, combiner); + + /// Merges the given Streams into a single Stream that emits a List of the + /// values emitted by the source Stream. This is helpful when you need to + /// combine a dynamic number of Streams. + /// + /// The Stream will not emit any lists of values until all of the source + /// streams have emitted at least one value. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatestList([ + /// Stream.value(1), + /// Stream.fromIterable([0, 1, 2]), + /// ]) + /// .listen(print); // prints [1, 0], [1, 1], [1, 2] + static Stream> combineLatestList(Iterable> streams) => + CombineLatestStream.list(streams); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest2( + /// Stream.value(1), + /// Stream.fromIterable([0, 1, 2]), + /// (a, b) => a + b) + /// .listen(print); //prints 1, 2, 3 + static Stream combineLatest2(Stream streamA, Stream streamB, + T Function(A a, B b) combiner) => + CombineLatestStream.combine2(streamA, streamB, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest3( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.fromIterable(['c', 'c']), + /// (a, b, c) => a + b + c) + /// .listen(print); //prints 'abc', 'abc' + static Stream combineLatest3( + Stream streamA, + Stream streamB, + Stream streamC, + T Function(A a, B b, C c) combiner) => + CombineLatestStream.combine3(streamA, streamB, streamC, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest4( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.fromIterable(['d', 'd']), + /// (a, b, c, d) => a + b + c + d) + /// .listen(print); //prints 'abcd', 'abcd' + static Stream combineLatest4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + T Function(A a, B b, C c, D d) combiner) => + CombineLatestStream.combine4( + streamA, streamB, streamC, streamD, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest5( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.fromIterable(['e', 'e']), + /// (a, b, c, d, e) => a + b + c + d + e) + /// .listen(print); //prints 'abcde', 'abcde' + static Stream combineLatest5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + T Function(A a, B b, C c, D d, E e) combiner) => + CombineLatestStream.combine5( + streamA, streamB, streamC, streamD, streamE, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest6( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.fromIterable(['f', 'f']), + /// (a, b, c, d, e, f) => a + b + c + d + e + f) + /// .listen(print); //prints 'abcdef', 'abcdef' + static Stream combineLatest6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + T Function(A a, B b, C c, D d, E e, F f) combiner) => + CombineLatestStream.combine6( + streamA, streamB, streamC, streamD, streamE, streamF, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest7( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.fromIterable(['g', 'g']), + /// (a, b, c, d, e, f, g) => a + b + c + d + e + f + g) + /// .listen(print); //prints 'abcdefg', 'abcdefg' + static Stream combineLatest7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + T Function(A a, B b, C c, D d, E e, F f, G g) combiner) => + CombineLatestStream.combine7(streamA, streamB, streamC, streamD, streamE, + streamF, streamG, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest8( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.fromIterable(['h', 'h']), + /// (a, b, c, d, e, f, g, h) => a + b + c + d + e + f + g + h) + /// .listen(print); //prints 'abcdefgh', 'abcdefgh' + static Stream combineLatest8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + T Function(A a, B b, C c, D d, E e, F f, G g, H h) combiner) => + CombineLatestStream.combine8( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + combiner, + ); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function whenever any of the stream sequences emits an + /// item. + /// + /// The Stream will not emit until all streams have emitted at least one + /// item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) + /// + /// ### Example + /// + /// Rx.combineLatest9( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.value('h'), + /// Stream.fromIterable(['i', 'i']), + /// (a, b, c, d, e, f, g, h, i) => a + b + c + d + e + f + g + h + i) + /// .listen(print); //prints 'abcdefghi', 'abcdefghi' + static Stream combineLatest9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + T Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) combiner) => + CombineLatestStream.combine9( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI, + combiner, + ); + + /// Concatenates all of the specified stream sequences, as long as the + /// previous stream sequence terminated successfully. + /// + /// It does this by subscribing to each stream one by one, emitting all items + /// and completing before subscribing to the next stream. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#concat) + /// + /// ### Example + /// + /// Rx.concat([ + /// Stream.value(1), + /// Rx.timer(2, Duration(days: 1)), + /// Stream.value(3) + /// ]) + /// .listen(print); // prints 1, 2, 3 + static Stream concat(Iterable> streams) => + ConcatStream(streams); + + /// Concatenates all of the specified stream sequences, as long as the + /// previous stream sequence terminated successfully. + /// + /// In the case of concatEager, rather than subscribing to one stream after + /// the next, all streams are immediately subscribed to. The events are then + /// captured and emitted at the correct time, after the previous stream has + /// finished emitting items. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#concat) + /// + /// ### Example + /// + /// Rx.concatEager([ + /// Stream.value(1), + /// Rx.timer(2, Duration(days: 1)), + /// Stream.value(3) + /// ]) + /// .listen(print); // prints 1, 2, 3 + static Stream concatEager(Iterable> streams) => + ConcatEagerStream(streams); + + /// The defer factory waits until an observer subscribes to it, and then it + /// creates a [Stream] with the given factory function. + /// + /// In some circumstances, waiting until the last minute (that is, until + /// subscription time) to generate the Stream can ensure that this + /// Stream contains the freshest data. + /// + /// By default, DeferStreams are single-subscription. However, it's possible + /// to make them reusable. + /// + /// ### Example + /// + /// Rx.defer(() => Stream.value(1)) + /// .listen(print); //prints 1 + static Stream defer(Stream Function() streamFactory, + {bool reusable = false}) => + DeferStream(streamFactory, reusable: reusable); + + /// Creates a [Stream] where all last events of existing stream(s) are piped + /// through a sink-transformation. + /// + /// This operator is best used when you have a group of streams + /// and only care about the final emitted value of each. + /// One common use case for this is if you wish to issue multiple + /// requests on page load (or some other event) + /// and only want to take action when a response has been received for all. + /// + /// In this way it is similar to how you might use [Future.wait]. + /// + /// Be aware that if any of the inner streams supplied to forkJoin error + /// you will lose the value of any other streams that would or have already + /// completed if you do not catch the error correctly on the inner stream. + /// + /// If you are only concerned with all inner streams completing + /// successfully you can catch the error on the outside. + /// It's also worth noting that if you have an stream + /// that emits more than one item, and you are concerned with the previous + /// emissions forkJoin is not the correct choice. + /// + /// In these cases you may better off with an operator like combineLatest or zip. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items and without any calls to the combiner function. + /// + /// ### Example + /// + /// Rx.forkJoin([ + /// Stream.value('a'), + /// Stream.fromIterable(['b', 'c', 'd']) + /// ], (list) => list.join(', ')) + /// .listen(print); // prints 'a, d' + static Stream forkJoin( + Iterable> streams, R Function(List values) combiner) => + ForkJoinStream(streams, combiner); + + /// Merges the given Streams into a single Stream that emits a List of the + /// last values emitted by the source stream(s). This is helpful when you need to + /// forkJoin a dynamic number of Streams. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// ### Example + /// + /// Rx.forkJoinList([ + /// Stream.value(1), + /// Stream.fromIterable([0, 1, 2]), + /// ]) + /// .listen(print); // prints [1, 2] + static Stream> forkJoinList(Iterable> streams) => + ForkJoinStream.list(streams); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin2( + /// Stream.value(1), + /// Stream.fromIterable([0, 1, 2]), + /// (a, b) => a + b) + /// .listen(print); //prints 3 + static Stream forkJoin2(Stream streamA, Stream streamB, + T Function(A a, B b) combiner) => + ForkJoinStream.join2(streamA, streamB, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin3( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.fromIterable(['c', 'd']), + /// (a, b, c) => a + b + c) + /// .listen(print); //prints 'abd' + static Stream forkJoin3(Stream streamA, Stream streamB, + Stream streamC, T Function(A a, B b, C c) combiner) => + ForkJoinStream.join3(streamA, streamB, streamC, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin4( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.fromIterable(['d', 'e']), + /// (a, b, c, d) => a + b + c + d) + /// .listen(print); //prints 'abce' + static Stream forkJoin4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + T Function(A a, B b, C c, D d) combiner) => + ForkJoinStream.join4(streamA, streamB, streamC, streamD, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin5( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.fromIterable(['e', 'f']), + /// (a, b, c, d, e) => a + b + c + d + e) + /// .listen(print); //prints 'abcdf' + static Stream forkJoin5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + T Function(A a, B b, C c, D d, E e) combiner) => + ForkJoinStream.join5( + streamA, streamB, streamC, streamD, streamE, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin6( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.fromIterable(['f', 'g']), + /// (a, b, c, d, e, f) => a + b + c + d + e + f) + /// .listen(print); //prints 'abcdeg' + static Stream forkJoin6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + T Function(A a, B b, C c, D d, E e, F f) combiner) => + ForkJoinStream.join6( + streamA, streamB, streamC, streamD, streamE, streamF, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin7( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.fromIterable(['g', 'h']), + /// (a, b, c, d, e, f, g) => a + b + c + d + e + f + g) + /// .listen(print); //prints 'abcdefh' + static Stream forkJoin7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + T Function(A a, B b, C c, D d, E e, F f, G g) combiner) => + ForkJoinStream.join7(streamA, streamB, streamC, streamD, streamE, streamF, + streamG, combiner); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin8( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.fromIterable(['h', 'i']), + /// (a, b, c, d, e, f, g, h) => a + b + c + d + e + f + g + h) + /// .listen(print); //prints 'abcdefgi' + static Stream forkJoin8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + T Function(A a, B b, C c, D d, E e, F f, G g, H h) combiner) => + ForkJoinStream.join8( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + combiner, + ); + + /// Merges the given Streams into a single Stream sequence by using the + /// [combiner] function when all of the stream sequences emits their + /// last item. + /// + /// ### Example + /// + /// Rx.forkJoin9( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.value('h'), + /// Stream.fromIterable(['i', 'j']), + /// (a, b, c, d, e, f, g, h, i) => a + b + c + d + e + f + g + h + i) + /// .listen(print); //prints 'abcdefghj' + static Stream forkJoin9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + T Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) combiner) => + ForkJoinStream.join9( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI, + combiner, + ); + + /// Returns a Stream that, when listening to it, calls a function you specify + /// and then emits the value returned from that function. + /// + /// If result from invoking [callable] function: + /// - Is a [Future]: when the future completes, this stream will fire one event, either + /// data or error, and then close with a done-event. + /// - Is a [T]: this stream emits a single data event and then completes with a done event. + /// + /// By default, a [FromCallableStream] is a single-subscription Stream. However, it's possible + /// to make them reusable. + /// This Stream is effectively equivalent to one created by + /// `(() async* { yield await callable() }())` or `(() async* { yield callable(); }())`. + /// + /// [ReactiveX doc](http://reactivex.io/documentation/operators/from.html) + /// + /// ### Example + /// + /// Rx.fromCallable(() => 'Value').listen(print); // prints Value + /// + /// Rx.fromCallable(() async { + /// await Future.delayed(const Duration(seconds: 1)); + /// return 'Value'; + /// }).listen(print); // prints Value + static Stream fromCallable(FutureOr Function() callable, + {bool reusable = false}) => + FromCallableStream(callable, reusable: reusable); + + /// Flattens the items emitted by the given [streams] into a single Stream + /// sequence. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#merge) + /// + /// ### Example + /// + /// Rx.merge([ + /// Rx.timer(1, Duration(days: 10)), + /// Stream.value(2) + /// ]) + /// .listen(print); // prints 2, 1 + static Stream merge(Iterable> streams) => + MergeStream(streams); + + /// Returns a non-terminating stream sequence, which can be used to denote + /// an infinite duration. + /// + /// The never operator is one with very specific and limited behavior. These + /// are useful for testing purposes, and sometimes also for combining with + /// other Streams or as parameters to operators that expect other + /// Streams as parameters. + /// + /// ### Example + /// + /// Rx.never().listen(print); // Neither prints nor terminates + static Stream never() => NeverStream(); + + /// Given two or more source [streams], emit all of the items from only + /// the first of these [streams] to emit an item or notification. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#amb) + /// + /// ### Example + /// + /// Rx.race([ + /// Rx.timer(1, Duration(days: 1)), + /// Rx.timer(2, Duration(days: 2)), + /// Rx.timer(3, Duration(seconds: 1)) + /// ]).listen(print); // prints 3 + static Stream race(Iterable> streams) => + RaceStream(streams); + + /// Returns a [Stream] that emits a sequence of Integers within a specified + /// range. + /// + /// ### Example + /// + /// Rx.range(1, 3).listen((i) => print(i)); // Prints 1, 2, 3 + /// + /// Rx.range(3, 1).listen((i) => print(i)); // Prints 3, 2, 1 + static Stream range(int startInclusive, int endInclusive) => + RangeStream(startInclusive, endInclusive); + + /// Creates a [Stream] that will recreate and re-listen to the source + /// Stream the specified number of times until the [Stream] terminates + /// successfully. + /// + /// If [count] is not specified, it repeats indefinitely. + /// + /// ### Example + /// + /// RepeatStream((int repeatCount) => + /// Stream.value('repeat index: $repeatCount'), 3) + /// .listen((i) => print(i)); // Prints 'repeat index: 0, repeat index: 1, repeat index: 2' + static Stream repeat(Stream Function(int repeatIndex) streamFactory, + [int? count]) => + RepeatStream(streamFactory, count); + + /// Creates a [Stream] that will recreate and re-listen to the source + /// Stream the specified number of times until the Stream terminates + /// successfully. + /// + /// If the retry count is not specified, it retries indefinitely. If the retry + /// count is met, but the Stream has not terminated successfully, all of the errors + /// and StackTraces that caused the failure will be emitted. + /// + /// ### Example + /// + /// Rx.retry(() => Stream.value(1)) + /// .listen((i) => print(i)); // Prints 1 + /// + /// Rx.retry( + /// () => Stream.value(1).concatWith([Stream.error(Error())]), + /// 1, + /// ).listen( + /// print, + /// onError: (Object e, StackTrace s) => print(e), + /// ); // Prints 1, 1, Instance of 'Error', Instance of 'Error' + static Stream retry(Stream Function() streamFactory, [int? count]) => + RetryStream(streamFactory, count); + + /// Creates a Stream that will recreate and re-listen to the source + /// Stream when the notifier emits a new value. If the source Stream + /// emits an error or it completes, the Stream terminates. + /// + /// If the [retryWhenFactory] throws an error or returns a Stream that emits an error, + /// original error will be emitted. And then, the error from [retryWhenFactory] will be emitted + /// if it is not identical with original error. + /// + /// ### Basic Example + /// + /// ```dart + /// Rx.retryWhen( + /// () => Stream.fromIterable([1]), + /// (Object error, StackTrace s) => throw error, + /// ).listen(print); // Prints 1 + /// ``` + /// + /// ### Periodic Example + /// + /// ```dart + /// Rx.retryWhen( + /// () => Stream.periodic(const Duration(seconds: 1), (int i) => i) + /// .map((int i) => i == 2 ? throw 'exception' : i), + /// (Object e, StackTrace s) => + /// Rx.timer(null, const Duration(milliseconds: 200)), + /// ).take(4).listen(print); // Prints 0, 1, 0, 1 + /// ``` + /// + /// ### Complex Example + /// + /// ```dart + /// var errorHappened = false; + /// Rx.retryWhen( + /// () => Stream.periodic(const Duration(seconds: 1), (i) => i).map((i) { + /// if (i == 3 && !errorHappened) { + /// throw 'We can take this. Please restart.'; + /// } else if (i == 4) { + /// throw 'It\'s enough.'; + /// } else { + /// return i; + /// } + /// }), + /// (e, s) { + /// errorHappened = true; + /// if (e == 'We can take this. Please restart.') { + /// return Stream.value('Ok. Here you go!'); + /// } else { + /// return Stream.error(e, s); + /// } + /// }, + /// ).listen(print, onError: print); // Prints 0, 1, 2, 0, 1, 2, 3, It's enough. + /// ``` + static Stream retryWhen( + Stream Function() streamFactory, + Stream Function(Object error, StackTrace stackTrace) retryWhenFactory, + ) => + RetryWhenStream(streamFactory, retryWhenFactory); + + /// Determine whether two Streams emit the same sequence of items. + /// You can provide an optional [equals] handler to determine equality. + /// + /// [Interactive marble diagram](https://rxmarbles.com/#sequenceEqual) + /// + /// ### Example + /// + /// Rx.sequenceEqual([ + /// Stream.fromIterable([1, 2, 3, 4, 5]), + /// Stream.fromIterable([1, 2, 3, 4, 5]) + /// ]) + /// .listen(print); // prints true + static Stream sequenceEqual( + Stream stream, + Stream other, { + bool Function(A a, B b)? equals, + bool Function(ErrorAndStackTrace, ErrorAndStackTrace)? errorEquals, + }) => + SequenceEqualStream( + stream, + other, + dataEquals: equals, + errorEquals: errorEquals, + ); + + /// Convert a Stream that emits Streams (aka a 'Higher Order Stream') into a + /// single Stream that emits the items emitted by the most-recently-emitted of + /// those Streams. + /// + /// This Stream will unsubscribe from the previously-emitted Stream when + /// a new Stream is emitted from the source Stream and subscribe to the new + /// Stream. + /// + /// ### Example + /// + /// ```dart + /// final switchLatestStream = SwitchLatestStream( + /// Stream.fromIterable(>[ + /// Rx.timer('A', Duration(seconds: 2)), + /// Rx.timer('B', Duration(seconds: 1)), + /// Stream.value('C'), + /// ]), + /// ); + /// + /// // Since the first two Streams do not emit data for 1-2 seconds, and the + /// // 3rd Stream will be emitted before that time, only data from the 3rd + /// // Stream will be emitted to the listener. + /// switchLatestStream.listen(print); // prints 'C' + /// ``` + static Stream switchLatest(Stream> streams) => + SwitchLatestStream(streams); + + /// Emits the given value after a specified amount of time. + /// + /// ### Example + /// + /// Rx.timer('hi', Duration(minutes: 1)) + /// .listen((i) => print(i)); // print 'hi' after 1 minute + static Stream timer(T value, Duration duration) => + TimerStream(value, duration); + + /// When listener listens to it, creates a resource object from resource factory function, + /// and creates a [Stream] from the given factory function and resource as argument. + /// Finally when the stream finishes emitting items or stream subscription + /// is cancelled (call [StreamSubscription.cancel] or `Stream.listen(cancelOnError: true)`), + /// call the disposer function on resource object. + /// + /// The [UsingStream] is a way you can instruct an Stream to create + /// a resource that exists only during the lifespan of the Stream + /// and is disposed of when the Stream terminates. + /// + /// [Marble diagram](http://reactivex.io/documentation/operators/images/using.c.png) + /// + /// ### Example + /// + /// Rx.using>( + /// resourceFactory: () => Queue.of([1, 2, 3]), + /// streamFactory: (r) => Stream.fromIterable(r), + /// disposer: (r) => r.clear(), + /// ).listen(print); // prints 1, 2, 3 + static Stream using({ + required FutureOr Function() resourceFactory, + required Stream Function(R) streamFactory, + required FutureOr Function(R) disposer, + }) => + UsingStream( + resourceFactory: resourceFactory, + streamFactory: streamFactory, + disposer: disposer, + ); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip2( + /// Stream.value('Hi '), + /// Stream.fromIterable(['Friend', 'Dropped']), + /// (a, b) => a + b) + /// .listen(print); // prints 'Hi Friend' + static Stream zip2( + Stream streamA, Stream streamB, T Function(A a, B b) zipper) => + ZipStream.zip2(streamA, streamB, zipper); + + /// Merges the iterable streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items and without any calls to the zipper function. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip( + /// [ + /// Stream.value('Hi '), + /// Stream.fromIterable(['Friend', 'Dropped']), + /// ], + /// (values) => values.first + values.last + /// ) + /// .listen(print); // prints 'Hi Friend' + static Stream zip( + Iterable> streams, R Function(List values) zipper) => + ZipStream(streams, zipper); + + /// Merges the iterable streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// If the provided streams is empty, the resulting sequence completes immediately + /// without emitting any items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zipList( + /// [ + /// Stream.value('Hi '), + /// Stream.fromIterable(['Friend', 'Dropped']), + /// ], + /// ) + /// .listen(print); // prints ['Hi ', 'Friend'] + static Stream> zipList(Iterable> streams) => + ZipStream.list(streams); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip3( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.fromIterable(['c', 'dropped']), + /// (a, b, c) => a + b + c) + /// .listen(print); //prints 'abc' + static Stream zip3(Stream streamA, Stream streamB, + Stream streamC, T Function(A a, B b, C c) zipper) => + ZipStream.zip3(streamA, streamB, streamC, zipper); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip4( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.fromIterable(['d', 'dropped']), + /// (a, b, c, d) => a + b + c + d) + /// .listen(print); //prints 'abcd' + static Stream zip4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + T Function(A a, B b, C c, D d) zipper) => + ZipStream.zip4(streamA, streamB, streamC, streamD, zipper); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip5( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.fromIterable(['e', 'dropped']), + /// (a, b, c, d, e) => a + b + c + d + e) + /// .listen(print); //prints 'abcde' + static Stream zip5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + T Function(A a, B b, C c, D d, E e) zipper) => + ZipStream.zip5(streamA, streamB, streamC, streamD, streamE, zipper); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip6( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.fromIterable(['f', 'dropped']), + /// (a, b, c, d, e, f) => a + b + c + d + e + f) + /// .listen(print); //prints 'abcdef' + static Stream zip6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + T Function(A a, B b, C c, D d, E e, F f) zipper) => + ZipStream.zip6( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + zipper, + ); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip7( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.fromIterable(['g', 'dropped']), + /// (a, b, c, d, e, f, g) => a + b + c + d + e + f + g) + /// .listen(print); //prints 'abcdefg' + static Stream zip7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + T Function(A a, B b, C c, D d, E e, F f, G g) zipper) => + ZipStream.zip7( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + zipper, + ); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip8( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.fromIterable(['h', 'dropped']), + /// (a, b, c, d, e, f, g, h) => a + b + c + d + e + f + g + h) + /// .listen(print); //prints 'abcdefgh' + static Stream zip8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + T Function(A a, B b, C c, D d, E e, F f, G g, H h) zipper) => + ZipStream.zip8( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + zipper, + ); + + /// Merges the specified streams into one stream sequence using the given + /// zipper function whenever all of the stream sequences have produced + /// an element at a corresponding index. + /// + /// It applies this function in strict sequence, so the first item emitted by + /// the new Stream will be the result of the function applied to the first + /// item emitted by Stream #1 and the first item emitted by Stream #2; + /// the second item emitted by the new ZipStream will be the result of + /// the function applied to the second item emitted by Stream #1 and the + /// second item emitted by Stream #2; and so forth. It will only emit as + /// many items as the number of items emitted by the source Stream that + /// emits the fewest items. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#zip) + /// + /// ### Example + /// + /// Rx.zip9( + /// Stream.value('a'), + /// Stream.value('b'), + /// Stream.value('c'), + /// Stream.value('d'), + /// Stream.value('e'), + /// Stream.value('f'), + /// Stream.value('g'), + /// Stream.value('h'), + /// Stream.fromIterable(['i', 'dropped']), + /// (a, b, c, d, e, f, g, h, i) => a + b + c + d + e + f + g + h + i) + /// .listen(print); //prints 'abcdefghi' + static Stream zip9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + T Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) zipper) => + ZipStream.zip9( + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI, + zipper, + ); +} diff --git a/core/reactivex/lib/src/streams/combine_latest.dart b/core/reactivex/lib/src/streams/combine_latest.dart new file mode 100644 index 00000000..9ed4331a --- /dev/null +++ b/core/reactivex/lib/src/streams/combine_latest.dart @@ -0,0 +1,352 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Merges the given Streams into one Stream sequence by using the +/// combiner function whenever any of the source stream sequences emits an +/// item. +/// +/// The Stream will not emit until all Streams have emitted at least one +/// item. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items and without any calls to the combiner function. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#combineLatest) +/// +/// ### Basic Example +/// +/// This constructor takes in an `Iterable>` and outputs a +/// `Stream>` whenever any of the values change from the source +/// stream. This is useful with a dynamic number of source streams! +/// +/// CombineLatestStream.list([ +/// Stream.fromIterable(['a']), +/// Stream.fromIterable(['b']), +/// Stream.fromIterable(['C', 'D'])]) +/// .listen(print); //prints ['a', 'b', 'C'], ['a', 'b', 'D'] +/// +/// ### Example with combiner +/// +/// If you wish to combine the list of values into a new object before you +/// +/// CombineLatestStream( +/// [ +/// Stream.fromIterable(['a']), +/// Stream.fromIterable(['b']), +/// Stream.fromIterable(['C', 'D']) +/// ], +/// (values) => values.last +/// ) +/// .listen(print); //prints 'C', 'D' +/// +/// ### Example with a specific number of Streams +/// +/// If you wish to combine a specific number of Streams together with proper +/// types information for the value of each Stream, use the +/// [combine2] - [combine9] operators. +/// +/// CombineLatestStream.combine2( +/// Stream.fromIterable([1]), +/// Stream.fromIterable([2, 3]), +/// (a, b) => a + b, +/// ) +/// .listen(print); // prints 3, 4 +class CombineLatestStream extends StreamView { + /// Constructs a [Stream] that observes an [Iterable] of [Stream] + /// and builds a [List] containing all latest events emitted by the provided [Iterable] of [Stream]. + /// The [combiner] maps this [List] into a new event of type [R] + CombineLatestStream( + Iterable> streams, + R Function(List values) combiner, + ) : super(_buildController(streams, combiner).stream); + + /// Constructs a [CombineLatestStream] using a default combiner, which simply + /// yields a [List] of all latest events emitted by the provided [Iterable] of [Stream]. + static CombineLatestStream> list( + Iterable> streams, + ) => + CombineLatestStream>( + streams, + (List values) => values, + ); + + /// Constructs a [CombineLatestStream] from a pair of [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine2( + Stream streamOne, + Stream streamTwo, + R Function(A a, B b) combiner, + ) => + CombineLatestStream( + [streamOne, streamTwo], + (List values) => combiner(values[0] as A, values[1] as B), + ); + + /// Constructs a [CombineLatestStream] from 3 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine3( + Stream streamA, + Stream streamB, + Stream streamC, + R Function(A a, B b, C c) combiner, + ) => + CombineLatestStream( + [streamA, streamB, streamC], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 4 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + R Function(A a, B b, C c, D d) combiner, + ) => + CombineLatestStream( + [streamA, streamB, streamC, streamD], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 5 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + R Function(A a, B b, C c, D d, E e) combiner, + ) => + CombineLatestStream( + [streamA, streamB, streamC, streamD, streamE], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 6 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + R Function(A a, B b, C c, D d, E e, F f) combiner, + ) => + CombineLatestStream( + [streamA, streamB, streamC, streamD, streamE, streamF], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 7 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + R Function(A a, B b, C c, D d, E e, F f, G g) combiner, + ) => + CombineLatestStream( + [streamA, streamB, streamC, streamD, streamE, streamF, streamG], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 8 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + R Function(A a, B b, C c, D d, E e, F f, G g, H h) combiner, + ) => + CombineLatestStream( + [ + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH + ], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + ); + }, + ); + + /// Constructs a [CombineLatestStream] from 9 [Stream]s + /// where [combiner] is used to create a new event of type [R], based on the + /// latest events emitted by the provided [Stream]s. + static CombineLatestStream combine9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + R Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) combiner, + ) => + CombineLatestStream( + [ + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI + ], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + values[8] as I, + ); + }, + ); + + static StreamController _buildController( + Iterable> streams, + R Function(List values) combiner, + ) { + final controller = StreamController(sync: true); + late List> subscriptions; + List? values; + + controller.onListen = () { + var triggered = 0, completed = 0; + + void onDone() { + if (++completed == subscriptions.length) { + controller.close(); + } + } + + subscriptions = streams.mapIndexed((index, stream) { + var hasFirstEvent = false; + + return stream.listen( + (T value) { + if (values == null) { + return; + } + + values![index] = value; + + if (!hasFirstEvent) { + hasFirstEvent = true; + triggered++; + } + + if (triggered == subscriptions.length) { + final R combined; + try { + combined = combiner(List.unmodifiable(values!)); + } catch (e, s) { + controller.addError(e, s); + return; + } + controller.add(combined); + } + }, + onError: controller.addError, + onDone: onDone, + ); + }).toList(growable: false); + if (subscriptions.isEmpty) { + controller.close(); + } else { + values = List.filled(subscriptions.length, null); + } + }; + controller.onPause = () => subscriptions.pauseAll(); + controller.onResume = () => subscriptions.resumeAll(); + controller.onCancel = () { + values = null; + return subscriptions.cancelAll(); + }; + + return controller; + } +} diff --git a/core/reactivex/lib/src/streams/concat.dart b/core/reactivex/lib/src/streams/concat.dart new file mode 100644 index 00000000..a451e6a9 --- /dev/null +++ b/core/reactivex/lib/src/streams/concat.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +/// Concatenates all of the specified stream sequences, as long as the +/// previous stream sequence terminated successfully. +/// +/// It does this by subscribing to each stream one by one, emitting all items +/// and completing before subscribing to the next stream. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#concat) +/// +/// ### Example +/// +/// ConcatStream([ +/// Stream.fromIterable([1]), +/// TimerStream(2, Duration(days: 1)), +/// Stream.fromIterable([3]) +/// ]) +/// .listen(print); // prints 1, 2, 3 +class ConcatStream extends StreamView { + /// Constructs a [Stream] which emits all events from [streams]. + /// The [Iterable] is traversed upwards, meaning that the current first + /// [Stream] in the [Iterable] needs to complete, before events from the + /// next [Stream] will be subscribed to. + ConcatStream(Iterable> streams) + : super(_buildController(streams).stream); + + static StreamController _buildController(Iterable> streams) { + final controller = StreamController(sync: true); + StreamSubscription? subscription; + + controller.onListen = () { + final iterator = streams.iterator; + + void moveNext() { + if (!iterator.moveNext()) { + controller.close(); + return; + } + subscription?.cancel(); + subscription = iterator.current.listen(controller.add, + onError: controller.addError, onDone: moveNext); + } + + moveNext(); + }; + controller.onPause = () => subscription?.pause(); + controller.onResume = () => subscription?.resume(); + controller.onCancel = () => subscription?.cancel(); + + return controller; + } +} + +/// Extends the Stream class with the ability to concatenate one stream with +/// another. +extension ConcatExtensions on Stream { + /// Returns a Stream that emits all items from the current Stream, + /// then emits all items from the given streams, one after the next. + /// + /// ### Example + /// + /// TimerStream(1, Duration(seconds: 10)) + /// .concatWith([Stream.fromIterable([2])]) + /// .listen(print); // prints 1, 2 + Stream concatWith(Iterable> other) { + final concatStream = ConcatStream([this, ...other]); + + return isBroadcast + ? concatStream.asBroadcastStream(onCancel: (s) => s.cancel()) + : concatStream; + } +} diff --git a/core/reactivex/lib/src/streams/concat_eager.dart b/core/reactivex/lib/src/streams/concat_eager.dart new file mode 100644 index 00000000..17beddd0 --- /dev/null +++ b/core/reactivex/lib/src/streams/concat_eager.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/streams/concat.dart'; +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Concatenates all of the specified stream sequences, as long as the +/// previous stream sequence terminated successfully. +/// +/// In the case of concatEager, rather than subscribing to one stream after +/// the next, all streams are immediately subscribed to. The events are then +/// captured and emitted at the correct time, after the previous stream has +/// finished emitting items. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#concat) +/// +/// ### Example +/// +/// ConcatEagerStream([ +/// Stream.fromIterable([1]), +/// TimerStream(2, Duration(days: 1)), +/// Stream.fromIterable([3]) +/// ]) +/// .listen(print); // prints 1, 2, 3 +class ConcatEagerStream extends StreamView { + /// Constructs a [Stream] which emits all events from [streams]. + /// Unlike [ConcatStream], all [Stream]s inside [streams] are + /// immediately subscribed to and events captured at the correct time, + /// but emitted only after the previous [Stream] in [streams] is + /// successfully closed. + ConcatEagerStream(Iterable> streams) + : super(_buildController(streams).stream); + + static StreamController _buildController(Iterable> streams) { + final controller = StreamController(sync: true); + late List> subscriptions; + StreamSubscription? activeSubscription; + + controller.onListen = () { + final completeEvents = >[]; + + void Function() onDone(int index) { + return () { + if (index < subscriptions.length - 1) { + completeEvents[index].complete(); + activeSubscription = subscriptions[index + 1]; + } else if (index == subscriptions.length - 1) { + controller.close(); + } + }; + } + + StreamSubscription createSubscription(int index, Stream stream) { + final subscription = stream.listen(controller.add, + onError: controller.addError, onDone: onDone(index)); + + // pause all subscriptions, except the first, initially + if (index > 0) { + final completer = Completer.sync(); + completeEvents.add(completer); + subscription.pause(completer.future); + } + + return subscription; + } + + subscriptions = + streams.mapIndexed(createSubscription).toList(growable: false); + if (subscriptions.isEmpty) { + controller.close(); + } else { + // initially, the very first subscription is the active one + activeSubscription = subscriptions.first; + } + }; + controller.onPause = () => activeSubscription?.pause(); + controller.onResume = () => activeSubscription?.resume(); + controller.onCancel = () { + activeSubscription = null; + return subscriptions.cancelAll(); + }; + + return controller; + } +} diff --git a/core/reactivex/lib/src/streams/connectable_stream.dart b/core/reactivex/lib/src/streams/connectable_stream.dart new file mode 100644 index 00000000..bf4a5cbe --- /dev/null +++ b/core/reactivex/lib/src/streams/connectable_stream.dart @@ -0,0 +1,516 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/streams/replay_stream.dart'; +import 'package:angel3_reactivex/src/streams/value_stream.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; +import 'package:angel3_reactivex/subjects.dart'; + +/// A ConnectableStream resembles an ordinary Stream, except that it +/// can be listened to multiple times and does not begin emitting items when +/// it is listened to, but only when its [connect] method is called. +/// +/// This class can be used to broadcast a single-subscription Stream, and +/// can be used to wait for all intended Observers to [listen] to the +/// Stream before it begins emitting items. +abstract class ConnectableStream extends StreamView { + /// Constructs a [Stream] which only begins emitting events when + /// the [connect] method is called. + ConnectableStream(Stream stream) : super(stream); + + /// Returns a [Stream] that automatically connects (at most once) to this + /// ConnectableStream when the first Observer subscribes. + /// + /// To disconnect from the source Stream, provide a [connection] callback and + /// cancel the `subscription` at the appropriate time. + Stream autoConnect({ + void Function(StreamSubscription subscription) connection, + }); + + /// Instructs the [ConnectableStream] to begin emitting items from the + /// source Stream. To disconnect from the source stream, cancel the + /// subscription. + StreamSubscription connect(); + + /// Returns a [Stream] that stays connected to this ConnectableStream + /// as long as there is at least one subscription to this + /// ConnectableStream. + Stream refCount(); +} + +enum _ConnectableStreamUse { + autoConnect, + connect, + refCount, +} + +/// Base class for implementations of [ConnectableStream]. +/// [S] is type of the forwarding [Subject]. +/// [R] is return type of [autoConnect] and [refCount] (type constraint: `S extends R`). +abstract class AbstractConnectableStream, + R extends Stream> extends ConnectableStream { + final Stream _source; + final S _subject; + _ConnectableStreamUse? _use; + + /// Constructs a [AbstractConnectableStream] with a source [Stream] and the forwarding [Subject]. + AbstractConnectableStream( + Stream source, + S subject, + ) : assert(subject is R), + _source = source, + _subject = subject, + super(subject); + + late final _connection = ConnectableStreamSubscription( + _source.listen( + _subject.add, + onError: _subject.addError, + onDone: _subject.close, + ), + _subject, + ); + + bool _canReuse(_ConnectableStreamUse use) { + if (_use != null && _use != use) { + throw StateError( + 'Do not mix autoConnect, connect and refCount together, you should only use one of them!'); + } + + final canReuse = _use != null && _use == use; + _use = use; + return canReuse; + } + + @override + R autoConnect({ + void Function(StreamSubscription subscription)? connection, + }) { + if (_canReuse(_ConnectableStreamUse.autoConnect)) { + return _subject as R; + } + + _subject.onListen = () { + final subscription = _connection; + connection?.call(subscription); + }; + _subject.onCancel = null; + + return _subject as R; + } + + @override + StreamSubscription connect() { + if (_canReuse(_ConnectableStreamUse.connect)) { + return _connection; + } + + _subject.onListen = _subject.onCancel = null; + return _connection; + } + + @override + R refCount() { + if (_canReuse(_ConnectableStreamUse.refCount)) { + return _subject as R; + } + + StreamSubscription? subscription; + _subject.onListen = () => subscription = _connection; + _subject.onCancel = () => subscription?.cancel(); + + return _subject as R; + } +} + +/// A [ConnectableStream] that converts a single-subscription Stream into +/// a broadcast [Stream]. +class PublishConnectableStream + extends AbstractConnectableStream, Stream> { + /// Constructs a [Stream] which only begins emitting events when + /// the [connect] method is called, this [Stream] acts like a + /// [PublishSubject]. + PublishConnectableStream(Stream source, {bool sync = false}) + : super(source, PublishSubject(sync: sync)); +} + +/// A [ConnectableStream] that converts a single-subscription Stream into +/// a broadcast Stream that replays the latest value to any new listener, and +/// provides synchronous access to the latest emitted value. +class ValueConnectableStream + extends AbstractConnectableStream, ValueStream> + implements ValueStream { + /// Constructs a [Stream] which only begins emitting events when + /// the [connect] method is called, this [Stream] acts like a + /// [BehaviorSubject]. + ValueConnectableStream(Stream source, {bool sync = false}) + : super(source, BehaviorSubject(sync: sync)); + + /// Constructs a [Stream] which only begins emitting events when + /// the [connect] method is called, this [Stream] acts like a + /// [BehaviorSubject.seeded]. + ValueConnectableStream.seeded(Stream source, T seedValue, + {bool sync = false}) + : super(source, BehaviorSubject.seeded(seedValue, sync: sync)); + + @override + bool get hasValue => _subject.hasValue; + + @override + T get value => _subject.value; + + @override + T? get valueOrNull => _subject.valueOrNull; + + @override + Object get error => _subject.error; + + @override + Object? get errorOrNull => _subject.errorOrNull; + + @override + bool get hasError => _subject.hasError; + + @override + StackTrace? get stackTrace => _subject.stackTrace; + + @override + StreamNotification? get lastEventOrNull => _subject.lastEventOrNull; +} + +/// A [ConnectableStream] that converts a single-subscription Stream into +/// a broadcast Stream that replays emitted items to any new listener, and +/// provides synchronous access to the list of emitted values. +class ReplayConnectableStream + extends AbstractConnectableStream, ReplayStream> + implements ReplayStream { + /// Constructs a [Stream] which only begins emitting events when + /// the [connect] method is called, this [Stream] acts like a + /// [ReplaySubject]. + ReplayConnectableStream(Stream stream, {int? maxSize, bool sync = false}) + : super( + stream, + ReplaySubject(maxSize: maxSize, sync: sync), + ); + + @override + List get values => _subject.values; + + @override + List get errors => _subject.errors; + + @override + List get stackTraces => _subject.stackTraces; +} + +/// A special [StreamSubscription] that not only cancels the connection to +/// the source [Stream], but also closes down a subject that drives the Stream. +class ConnectableStreamSubscription extends StreamSubscription { + final StreamSubscription _source; + final Subject _subject; + + /// Constructs a special [StreamSubscription], which will close the provided subject + /// when [cancel] is called. + ConnectableStreamSubscription(this._source, this._subject); + + @override + Future cancel() => + _source.cancel().then((_) => _subject.close()); + + @override + Never asFuture([E? futureValue]) => _unsupportedError(); + + @override + bool get isPaused => _source.isPaused; + + @override + Never onData(void Function(T data)? handleData) => _unsupportedError(); + + @override + Never onDone(void Function()? handleDone) => _unsupportedError(); + + @override + Never onError(Function? handleError) => _unsupportedError(); + + @override + void pause([Future? resumeSignal]) => _source.pause(resumeSignal); + + @override + void resume() => _source.resume(); + + Never _unsupportedError() => throw UnsupportedError( + 'Cannot change handlers of ConnectableStreamSubscription.'); +} + +/// Extends the Stream class with the ability to transform a single-subscription +/// Stream into a ConnectableStream. +extension ConnectableStreamExtensions on Stream { + /// Convert the current Stream into a [ConnectableStream] that can be listened + /// to multiple times. It will not begin emitting items from the original + /// Stream until the `connect` method is invoked. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream. + /// + /// ### Example + /// + /// ``` + /// final source = Stream.fromIterable([1, 2, 3]); + /// final connectable = source.publish(); + /// + /// // Does not print anything at first + /// connectable.listen(print); + /// + /// // Start listening to the source Stream. Will cause the previous + /// // line to start printing 1, 2, 3 + /// final subscription = connectable.connect(); + /// await Future(() {}); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // Subject + /// subscription.cancel(); + /// ``` + PublishConnectableStream publish() => + PublishConnectableStream(this, sync: true); + + /// Convert the current Stream into a [ValueConnectableStream] + /// that can be listened to multiple times. It will not begin emitting items + /// from the original Stream until the `connect` method is invoked. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream that replays the latest emitted value to any new + /// listener. It also provides access to the latest value synchronously. + /// + /// ### Example + /// + /// ``` + /// final source = Stream.fromIterable([1, 2, 3]); + /// final connectable = source.publishValue(); + /// + /// // Does not print anything at first + /// connectable.listen(print); + /// + /// // Start listening to the source Stream. Will cause the previous + /// // line to start printing 1, 2, 3 + /// final subscription = connectable.connect(); + /// + /// // Late subscribers will receive the last emitted value + /// connectable.listen(print); // Prints 3 + /// await Future(() {}); + /// + /// // Can access the latest emitted value synchronously. Prints 3 + /// print(connectable.value); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // BehaviorSubject + /// subscription.cancel(); + /// ``` + ValueConnectableStream publishValue() => + ValueConnectableStream(this, sync: true); + + /// Convert the current Stream into a [ValueConnectableStream] + /// that can be listened to multiple times, providing an initial seeded value. + /// It will not begin emitting items from the original Stream + /// until the `connect` method is invoked. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream that replays the latest emitted value to any new + /// listener. It also provides access to the latest value synchronously. + /// + /// ### Example + /// + /// ``` + /// final source = Stream.fromIterable([1, 2, 3]); + /// final connectable = source.publishValueSeeded(0); + /// + /// // Does not print anything at first + /// connectable.listen(print); + /// + /// // Start listening to the source Stream. Will cause the previous + /// // line to start printing 0, 1, 2, 3 + /// final subscription = connectable.connect(); + /// + /// // Late subscribers will receive the last emitted value + /// connectable.listen(print); // Prints 3 + /// await Future(() {}); + /// + /// // Can access the latest emitted value synchronously. Prints 3 + /// print(connectable.value); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // BehaviorSubject + /// subscription.cancel(); + /// ``` + ValueConnectableStream publishValueSeeded(T seedValue) => + ValueConnectableStream.seeded(this, seedValue, sync: true); + + /// Convert the current Stream into a [ReplayConnectableStream] + /// that can be listened to multiple times. It will not begin emitting items + /// from the original Stream until the `connect` method is invoked. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream that replays a given number of items to any new + /// listener. It also provides access to the emitted values synchronously. + /// + /// ### Example + /// + /// ``` + /// final source = Stream.fromIterable([1, 2, 3]); + /// final connectable = source.publishReplay(); + /// + /// // Does not print anything at first + /// connectable.listen(print); + /// + /// // Start listening to the source Stream. Will cause the previous + /// // line to start printing 1, 2, 3 + /// final subscription = connectable.connect(); + /// + /// // Late subscribers will receive the emitted value, up to a specified + /// // maxSize + /// connectable.listen(print); // Prints 1, 2, 3 + /// await Future(() {}); + /// + /// // Can access a list of the emitted values synchronously. Prints [1, 2, 3] + /// print(connectable.values); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // ReplaySubject + /// subscription.cancel(); + /// ``` + ReplayConnectableStream publishReplay({int? maxSize}) => + ReplayConnectableStream(this, maxSize: maxSize, sync: true); + + /// Convert the current Stream into a new Stream that can be listened + /// to multiple times. It will automatically begin emitting items when first + /// listened to, and shut down when no listeners remain. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream. + /// + /// ### Example + /// + /// ``` + /// // Convert a single-subscription fromIterable stream into a broadcast + /// // stream + /// final stream = Stream.fromIterable([1, 2, 3]).share(); + /// + /// // Start listening to the source Stream. Will start printing 1, 2, 3 + /// final subscription = stream.listen(print); + /// await Future(() {}); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // PublishSubject + /// subscription.cancel(); + /// ``` + Stream share() => publish().refCount(); + + /// Convert the current Stream into a new [ValueStream] that can + /// be listened to multiple times. It will automatically begin emitting items + /// when first listened to, and shut down when no listeners remain. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream. It's also useful for providing sync access to the latest + /// emitted value. + /// + /// It will replay the latest emitted value to any new listener. + /// + /// ### Example + /// + /// ``` + /// // Convert a single-subscription fromIterable stream into a broadcast + /// // stream that will emit the latest value to any new listeners + /// final stream = Stream.fromIterable([1, 2, 3]).shareValue(); + /// + /// // Start listening to the source Stream. Will start printing 1, 2, 3 + /// final subscription = stream.listen(print); + /// await Future(() {}); + /// + /// // Synchronously print the latest value + /// print(stream.value); + /// + /// // Subscribe again later. This will print 3 because it receives the last + /// // emitted value. + /// final subscription2 = stream.listen(print); + /// await Future(() {}); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // BehaviorSubject by cancelling all subscriptions. + /// subscription.cancel(); + /// subscription2.cancel(); + /// ``` + ValueStream shareValue() => publishValue().refCount(); + + /// Convert the current Stream into a new [ValueStream] that can + /// be listened to multiple times, providing an initial value. + /// It will automatically begin emitting items when first listened to, + /// and shut down when no listeners remain. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream. It's also useful for providing sync access to the latest + /// emitted value. + /// + /// It will replay the latest emitted value to any new listener. + /// + /// ### Example + /// + /// ``` + /// // Convert a single-subscription fromIterable stream into a broadcast + /// // stream that will emit the latest value to any new listeners + /// final stream = Stream.fromIterable([1, 2, 3]).shareValueSeeded(0); + /// + /// // Start listening to the source Stream. Will start printing 0, 1, 2, 3 + /// final subscription = stream.listen(print); + /// + /// // Synchronously print the latest value + /// print(stream.value); + /// + /// // Subscribe again later. This will print 3 because it receives the last + /// // emitted value. + /// final subscription2 = stream.listen(print); + /// await Future(() {}); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // BehaviorSubject by cancelling all subscriptions. + /// subscription.cancel(); + /// subscription2.cancel(); + /// ``` + ValueStream shareValueSeeded(T seedValue) => + publishValueSeeded(seedValue).refCount(); + + /// Convert the current Stream into a new [ReplayStream] that can + /// be listened to multiple times. It will automatically begin emitting items + /// when first listened to, and shut down when no listeners remain. + /// + /// This is useful for converting a single-subscription stream into a + /// broadcast Stream. It's also useful for gaining access to the l + /// + /// It will replay the emitted values to any new listener, up to a given + /// [maxSize]. + /// + /// ### Example + /// + /// ``` + /// // Convert a single-subscription fromIterable stream into a broadcast + /// // stream that will emit the latest value to any new listeners + /// final stream = Stream.fromIterable([1, 2, 3]).shareReplay(); + /// + /// // Start listening to the source Stream. Will start printing 1, 2, 3 + /// final subscription = stream.listen(print); + /// await Future(() {}); + /// + /// // Synchronously print the emitted values up to a given maxSize + /// // Prints [1, 2, 3] + /// print(stream.values); + /// + /// // Subscribe again later. This will print 1, 2, 3 because it receives the + /// // last emitted value. + /// final subscription2 = stream.listen(print); + /// await Future(() {}); + /// + /// // Stop emitting items from the source stream and close the underlying + /// // ReplaySubject by cancelling all subscriptions. + /// subscription.cancel(); + /// subscription2.cancel(); + /// ``` + ReplayStream shareReplay({int? maxSize}) => + publishReplay(maxSize: maxSize).refCount(); +} diff --git a/core/reactivex/lib/src/streams/defer.dart b/core/reactivex/lib/src/streams/defer.dart new file mode 100644 index 00000000..25a8a127 --- /dev/null +++ b/core/reactivex/lib/src/streams/defer.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +/// The defer factory waits until a listener subscribes to it, and then it +/// creates a Stream with the given factory function. +/// +/// In some circumstances, waiting until the last minute (that is, until +/// subscription time) to generate the Stream can ensure that listeners +/// receive the freshest data. +/// +/// By default, DeferStreams are single-subscription. However, it's possible +/// to make them reusable. +/// +/// ### Example +/// +/// DeferStream(() => Stream.value(1)).listen(print); //prints 1 +class DeferStream extends Stream { + final Stream Function() _factory; + final bool _isReusable; + + @override + bool get isBroadcast => _isReusable; + + /// Constructs a [Stream] lazily, at the moment of subscription, using + /// the [streamFactory] + DeferStream(Stream Function() streamFactory, {bool reusable = false}) + : _isReusable = reusable, + _factory = reusable + ? streamFactory + : (() { + Stream? stream; + return () => stream ??= streamFactory(); + }()); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + Stream stream; + + try { + stream = _factory(); + } catch (e, s) { + return Stream.error(e, s).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + return stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} diff --git a/core/reactivex/lib/src/streams/fork_join.dart b/core/reactivex/lib/src/streams/fork_join.dart new file mode 100644 index 00000000..349909ed --- /dev/null +++ b/core/reactivex/lib/src/streams/fork_join.dart @@ -0,0 +1,366 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// This operator is best used when you have a group of streams +/// and only care about the final emitted value of each. +/// One common use case for this is if you wish to issue multiple +/// requests on page load (or some other event) +/// and only want to take action when a response has been received for all. +/// +/// In this way it is similar to how you might use [Future.wait]. +/// +/// Be aware that if any of the inner streams supplied to forkJoin error +/// you will lose the value of any other streams that would or have already +/// completed if you do not catch the error correctly on the inner stream. +/// +/// If you are only concerned with all inner streams completing +/// successfully you can catch the error on the outside. +/// It's also worth noting that if you have an stream +/// that emits more than one item, and you are concerned with the previous +/// emissions forkJoin is not the correct choice. +/// +/// In these cases you may better off with an operator like combineLatest or zip. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items and without any calls to the combiner function. +/// +/// ### Basic Example +/// +/// This constructor takes in an `Iterable>` and outputs a +/// `Stream>` whenever any of the values change from the source +/// stream. This is useful with a dynamic number of source streams! +/// +/// ForkJoinStream.list([ +/// Stream.fromIterable(['a']), +/// Stream.fromIterable(['b']), +/// Stream.fromIterable(['C', 'D']), +/// ]) +/// .listen(print); //prints ['a', 'b', 'D'] +/// +/// ### Example with combiner +/// +/// If you wish to combine the list of values into a new object before emitting, +/// you can provide the `combiner` function to the constructor. +/// +/// ForkJoinStream( +/// [ +/// Stream.fromIterable(['a']), +/// Stream.fromIterable(['b']), +/// Stream.fromIterable(['C', 'D']), +/// ], +/// (values) => values.last, +/// ) +/// .listen(print); //prints 'D' +/// +/// ### Example with a specific number of Streams +/// +/// If you wish to combine a specific number of Streams together with proper +/// types information for the value of each Stream, use the +/// [join2] - [join9] operators. +/// +/// ForkJoinStream.join2( +/// Stream.fromIterable([1]), +/// Stream.fromIterable([2, 3]), +/// (a, b) => a + b, +/// ) +/// .listen(print); // prints 4 +class ForkJoinStream extends StreamView { + /// Constructs a [Stream] that awaits the last values of the [Stream]s + /// in [streams], then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + ForkJoinStream( + Iterable> streams, + R Function(List values) combiner, + ) : super(_buildStream(streams, combiner)); + + /// Constructs a [Stream] that awaits the last values of the [Stream]s + /// in [streams] and then emits these values as a [List]. + /// After this event, the [Stream] closes. + static ForkJoinStream> list( + Iterable> streams, + ) => + ForkJoinStream>( + streams, + (values) => values, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join2( + Stream streamOne, + Stream streamTwo, + R Function(A a, B b) combiner, + ) => + ForkJoinStream( + [streamOne, streamTwo], + (List values) => combiner(values[0] as A, values[1] as B), + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join3( + Stream streamA, + Stream streamB, + Stream streamC, + R Function(A a, B b, C c) combiner, + ) => + ForkJoinStream( + [streamA, streamB, streamC], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + R Function(A a, B b, C c, D d) combiner, + ) => + ForkJoinStream( + [streamA, streamB, streamC, streamD], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + R Function(A a, B b, C c, D d, E e) combiner, + ) => + ForkJoinStream( + [streamA, streamB, streamC, streamD, streamE], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + R Function(A a, B b, C c, D d, E e, F f) combiner, + ) => + ForkJoinStream( + [streamA, streamB, streamC, streamD, streamE, streamF], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + R Function(A a, B b, C c, D d, E e, F f, G g) combiner, + ) => + ForkJoinStream( + [streamA, streamB, streamC, streamD, streamE, streamF, streamG], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + R Function(A a, B b, C c, D d, E e, F f, G g, H h) combiner, + ) => + ForkJoinStream( + [ + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH + ], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + ); + }, + ); + + /// Constructs a [Stream] that awaits the last values the provided [Stream]s, + /// then calls the [combiner] to emit an event of type [R]. + /// After this event, the [Stream] closes. + static ForkJoinStream join9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + R Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) combiner, + ) => + ForkJoinStream( + [ + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI + ], + (List values) { + return combiner( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + values[8] as I, + ); + }, + ); + + static Stream _buildStream( + Iterable> streams, + R Function(List values) combiner, + ) { + final controller = StreamController(sync: true); + late List> subscriptions; + List? values; + + controller.onListen = () { + var completed = 0; + + StreamSubscription listen(int i, Stream stream) { + var hasValue = false; + + return stream.listen( + (value) { + hasValue = true; + values?[i] = value; + }, + onError: controller.addError, + onDone: () { + if (!hasValue) { + controller.addError(StateError('No element')); + controller.close(); + return; + } + + if (values == null) { + return; + } + if (++completed == subscriptions.length) { + final R combined; + try { + combined = combiner(List.unmodifiable(values!)); + } catch (e, s) { + controller.addError(e, s); + controller.close(); + return; + } + + controller.add(combined); + controller.close(); + } + }, + ); + } + + subscriptions = streams.mapIndexed(listen).toList(growable: false); + if (subscriptions.isEmpty) { + controller.close(); + } else { + values = List.filled(subscriptions.length, null); + } + }; + controller.onPause = () => subscriptions.pauseAll(); + controller.onResume = () => subscriptions.resumeAll(); + controller.onCancel = () { + values = null; + return subscriptions.cancelAll(); + }; + + return controller.stream; + } +} diff --git a/core/reactivex/lib/src/streams/from_callable.dart b/core/reactivex/lib/src/streams/from_callable.dart new file mode 100644 index 00000000..ee4a6ba2 --- /dev/null +++ b/core/reactivex/lib/src/streams/from_callable.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +/// Returns a Stream that, when listening to it, calls a function you specify +/// and then emits the value returned from that function. +/// +/// If result from invoking [callable] function: +/// - Is a [Future]: when the future completes, this stream will fire one event, either +/// data or error, and then close with a done-event. +/// - Is a [T]: this stream emits a single data event and then completes with a done event. +/// +/// By default, a [FromCallableStream] is a single-subscription Stream. However, it's possible +/// to make them reusable. +/// This Stream is effectively equivalent to one created by +/// `(() async* { yield await callable() }())` or `(() async* { yield callable(); }())`. +/// +/// [ReactiveX doc](http://reactivex.io/documentation/operators/from.html) +/// +/// ### Example +/// +/// FromCallableStream(() => 'Value').listen(print); // prints Value +/// +/// FromCallableStream(() async { +/// await Future.delayed(const Duration(seconds: 1)); +/// return 'Value'; +/// }).listen(print); // prints Value +class FromCallableStream extends Stream { + Stream? _stream; + + /// A function will be called at subscription time. + final FutureOr Function() callable; + final bool _isReusable; + + /// Construct a Stream that, when listening to it, calls a function you specify + /// and then emits the value returned from that function. + /// [reusable] indicates whether this Stream can be listened to multiple times or not. + FromCallableStream(this.callable, {bool reusable = false}) + : _isReusable = reusable; + + @override + bool get isBroadcast => _isReusable; + + @override + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + if (_isReusable || _stream == null) { + try { + final value = callable(); + + _stream = + value is Future ? Stream.fromFuture(value) : Stream.value(value); + } catch (e, s) { + _stream = Stream.error(e, s); + } + } + + return _stream!.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} diff --git a/core/reactivex/lib/src/streams/merge.dart b/core/reactivex/lib/src/streams/merge.dart new file mode 100644 index 00000000..2384052e --- /dev/null +++ b/core/reactivex/lib/src/streams/merge.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Flattens the items emitted by the given streams into a single Stream +/// sequence. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#merge) +/// +/// ### Example +/// +/// MergeStream([ +/// TimerStream(1, Duration(days: 10)), +/// Stream.fromIterable([2]) +/// ]) +/// .listen(print); // prints 2, 1 +class MergeStream extends StreamView { + /// Constructs a [Stream] which flattens all events in [streams] and emits + /// them in a single sequence. + MergeStream(Iterable> streams) + : super(_buildController(streams).stream); + + static StreamController _buildController(Iterable> streams) { + final controller = StreamController(sync: true); + late List> subscriptions; + + controller.onListen = () { + var completed = 0; + + void onDone() { + if (++completed == subscriptions.length) { + controller.close(); + } + } + + subscriptions = streams + .map((s) => s.listen(controller.add, + onError: controller.addError, onDone: onDone)) + .toList(growable: false); + + if (subscriptions.isEmpty) { + controller.close(); + } + }; + controller.onPause = () => subscriptions.pauseAll(); + controller.onResume = () => subscriptions.resumeAll(); + controller.onCancel = () => subscriptions.cancelAll(); + + return controller; + } +} + +/// Extends the Stream class with the ability to merge one stream with another. +extension MergeExtension on Stream { + /// Combines the items emitted by multiple streams into a single stream of + /// items. The items are emitted in the order they are emitted by their + /// sources. + /// + /// ### Example + /// + /// TimerStream(1, Duration(seconds: 10)) + /// .mergeWith([Stream.fromIterable([2])]) + /// .listen(print); // prints 2, 1 + Stream mergeWith(Iterable> streams) { + final stream = MergeStream([this, ...streams]); + + return isBroadcast + ? stream.asBroadcastStream(onCancel: (s) => s.cancel()) + : stream; + } +} diff --git a/core/reactivex/lib/src/streams/never.dart b/core/reactivex/lib/src/streams/never.dart new file mode 100644 index 00000000..fc6b18b9 --- /dev/null +++ b/core/reactivex/lib/src/streams/never.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +/// Returns a non-terminating stream sequence, which can be used to denote +/// an infinite duration. +/// +/// The never operator is one with very specific and limited behavior. These +/// are useful for testing purposes, and sometimes also for combining with +/// other Streams or as parameters to operators that expect other +/// Streams as parameters. +/// +/// ### Example +/// +/// NeverStream().listen(print); // Neither prints nor terminates +class NeverStream extends Stream { + // ignore: close_sinks + final _controller = StreamController(); + + /// Constructs a [Stream] which never emits an event and simply remains + /// open until implicitly closed by the developer. + NeverStream(); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) => + _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); +} diff --git a/core/reactivex/lib/src/streams/race.dart b/core/reactivex/lib/src/streams/race.dart new file mode 100644 index 00000000..98b6333b --- /dev/null +++ b/core/reactivex/lib/src/streams/race.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Given two or more source streams, emit all of the items from only +/// the first of these streams to emit an item or notification. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#race) +/// +/// ### Example +/// +/// RaceStream([ +/// TimerStream(1, Duration(days: 1)), +/// TimerStream(2, Duration(days: 2)), +/// TimerStream(3, Duration(seconds: 3)) +/// ]).listen(print); // prints 3 +class RaceStream extends StreamView { + /// Constructs a [Stream] which emits all events from a single [Stream] + /// inside [streams]. The selected [Stream] is the first one which emits + /// an event. + /// After this event, all other [Stream]s in [streams] are discarded. + RaceStream(Iterable> streams) + : super(_buildController(streams).stream); + + static StreamController _buildController(Iterable> streams) { + final controller = StreamController(sync: true); + late List> subscriptions; + + controller.onListen = () { + void reduceToWinner(int winnerIndex) { + final winner = subscriptions.removeAt(winnerIndex); + + subscriptions.cancelAll()?.onError((e, s) { + if (!controller.isClosed && controller.hasListener) { + controller.addError(e, s); + } + }); + + subscriptions = [winner]; + } + + void Function(T value) doUpdate(int index) { + return (T value) { + if (subscriptions.length > 1) { + reduceToWinner(index); + } + controller.add(value); + }; + } + + subscriptions = streams + .mapIndexed((index, stream) => stream.listen(doUpdate(index), + onError: controller.addError, onDone: controller.close)) + .toList(); + + if (subscriptions.isEmpty) { + controller.close(); + } + }; + controller.onPause = () => subscriptions.pauseAll(); + controller.onResume = () => subscriptions.resumeAll(); + controller.onCancel = () => subscriptions.cancelAll(); + + return controller; + } +} diff --git a/core/reactivex/lib/src/streams/range.dart b/core/reactivex/lib/src/streams/range.dart new file mode 100644 index 00000000..83b4c1ed --- /dev/null +++ b/core/reactivex/lib/src/streams/range.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +/// Returns a Stream that emits a sequence of Integers within a specified +/// range. +/// +/// ### Examples +/// +/// RangeStream(1, 3).listen((i) => print(i)); // Prints 1, 2, 3 +/// +/// RangeStream(3, 1).listen((i) => print(i)); // Prints 3, 2, 1 +class RangeStream extends Stream { + var _isListened = false; + final Stream _stream; + + /// Constructs a [Stream] which emits all integer values that exist + /// within the range between [startInclusive] and [endInclusive]. + RangeStream(int startInclusive, int endInclusive) + : _stream = _buildStream(startInclusive, endInclusive); + + @override + StreamSubscription listen(void Function(int event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + if (_isListened) { + throw StateError('Stream has already been listened to.'); + } + _isListened = true; + + return _stream.listen(onData, + onError: onError, onDone: onDone, cancelOnError: cancelOnError); + } + + static Stream _buildStream(int startInclusive, int endInclusive) { + final length = (endInclusive - startInclusive).abs() + 1; + + int nextValue(int index) => startInclusive > endInclusive + ? startInclusive - index + : startInclusive + index; + + return Stream.fromIterable(Iterable.generate(length, nextValue)); + } +} diff --git a/core/reactivex/lib/src/streams/repeat.dart b/core/reactivex/lib/src/streams/repeat.dart new file mode 100644 index 00000000..9f8f9c52 --- /dev/null +++ b/core/reactivex/lib/src/streams/repeat.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +/// Creates a [Stream] that will recreate and re-listen to the source +/// Stream the specified number of times until the [Stream] terminates +/// successfully. +/// +/// If [count] is not specified, it repeats indefinitely. +/// +/// ### Example +/// +/// RepeatStream((int repeatCount) => +/// Stream.value('repeat index: $repeatCount'), 3) +/// .listen((i) => print(i)); // Prints 'repeat index: 0, repeat index: 1, repeat index: 2' +class RepeatStream extends Stream { + /// The factory method used at subscription time + final Stream Function(int) streamFactory; + + /// The amount of repeat attempts that will be made + /// If 0, then an indefinite amount of attempts will be made. + final int? count; + int _repeatStep = 0; + StreamController? _controller; + StreamSubscription? _subscription; + + /// Constructs a [Stream] that will recreate and re-listen to the source + /// [Stream] (created with the provided factory method). + /// The count parameter specifies number of times the repeat will take place, + /// until this [Stream] terminates successfully. + /// If the count parameter is not specified, then this [Stream] will repeat + /// indefinitely. + RepeatStream(this.streamFactory, [this.count]); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + _controller ??= StreamController( + sync: true, + onListen: _maybeRepeatNext, + onPause: () => _subscription?.pause(), + onResume: () => _subscription?.resume(), + onCancel: () => _subscription?.cancel()); + + return _controller!.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + void _repeatNext() { + void onDone() { + _subscription?.cancel(); + + _maybeRepeatNext(); + } + + final controller = _controller!; + try { + _subscription = streamFactory(_repeatStep++).listen( + controller.add, + onError: controller.addError, + onDone: onDone, + cancelOnError: false, + ); + } catch (e, s) { + controller.addError(e, s); + } + } + + void _maybeRepeatNext() { + if (_repeatStep == count) { + _controller!.close(); + } else { + _repeatNext(); + } + } +} diff --git a/core/reactivex/lib/src/streams/replay_stream.dart b/core/reactivex/lib/src/streams/replay_stream.dart new file mode 100644 index 00000000..a9d79571 --- /dev/null +++ b/core/reactivex/lib/src/streams/replay_stream.dart @@ -0,0 +1,26 @@ +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; + +/// An [Stream] that provides synchronous access to the emitted values +abstract class ReplayStream implements Stream { + /// Synchronously get the values stored in Subject. May be empty. + List get values; + + /// Synchronously get the errors and stack traces stored in Subject. May be empty. + List get errors; + + /// Synchronously get the stack traces of errors stored in Subject. May be empty. + List get stackTraces; +} + +/// Extension method on [ReplayStream] to access the emitted [ErrorAndStackTrace]s. +extension ErrorAndStackTracesReplayStreamExtension on ReplayStream { + /// Returns the emitted [ErrorAndStackTrace]s. + /// May be empty. + List get errorAndStackTraces => + errors.zipWith( + stackTraces, + (e, s) => ErrorAndStackTrace(e, s), + growable: false, + ); +} diff --git a/core/reactivex/lib/src/streams/retry.dart b/core/reactivex/lib/src/streams/retry.dart new file mode 100644 index 00000000..f90cc974 --- /dev/null +++ b/core/reactivex/lib/src/streams/retry.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; + +/// Creates a [Stream] that will recreate and re-listen to the source +/// [Stream] the specified number of times until the [Stream] terminates +/// successfully. +/// +/// If the retry count is not specified, it retries indefinitely. If the retry +/// count is met, but the Stream has not terminated successfully, all of the errors +/// and StackTraces that caused the failure will be emitted. +/// +/// ### Example +/// +/// RetryStream(() => Stream.value(1)) +/// .listen((i) => print(i)); // Prints 1 +/// +/// RetryStream( +/// () => Stream.value(1).concatWith([Stream.error(Error())]), +/// 1, +/// ).listen( +/// print, +/// onError: (Object e, StackTrace s) => print(e), +/// ); // Prints 1, 1, Instance of 'Error', Instance of 'Error' +class RetryStream extends Stream { + /// The factory method used at subscription time + final Stream Function() streamFactory; + + /// The amount of retry attempts that will be made + /// If null, then an indefinite amount of attempts will be made. + final int? count; + + var _retryStep = 0; + final _errors = []; + late final StreamController _controller = StreamController( + sync: true, + onListen: _retry, + onPause: () => _subscription!.pause(), + onResume: () => _subscription!.resume(), + onCancel: () { + _errors.clear(); + return _subscription?.cancel(); + }, + ); + StreamSubscription? _subscription; + + /// Constructs a [Stream] that will recreate and re-listen to the source + /// [Stream] (created by the provided factory method) the specified number + /// of times until the [Stream] terminates successfully. + /// If [count] is not specified, it retries indefinitely. + RetryStream(this.streamFactory, [this.count]); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + return _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + void _retry() { + void onError(Object e, StackTrace s) { + _subscription!.cancel(); + _subscription = null; + + _errors.add(ErrorAndStackTrace(e, s)); + + if (count == _retryStep) { + for (var e in [..._errors]) { + _controller.addError(e.error, e.stackTrace); + } + _controller.close(); + } else { + ++_retryStep; + _retry(); + } + } + + _subscription = streamFactory().listen( + _controller.add, + onError: onError, + onDone: _controller.close, + cancelOnError: false, + ); + } +} diff --git a/core/reactivex/lib/src/streams/retry_when.dart b/core/reactivex/lib/src/streams/retry_when.dart new file mode 100644 index 00000000..05e6073b --- /dev/null +++ b/core/reactivex/lib/src/streams/retry_when.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +/// Creates a Stream that will recreate and re-listen to the source +/// Stream when the notifier emits a new value. If the source Stream +/// emits an error or it completes, the Stream terminates. +/// +/// If the [retryWhenFactory] throws an error or returns a Stream that emits an error, +/// original error will be emitted. And then, the error from [retryWhenFactory] will be emitted +/// if it is not identical with original error. +/// +/// ### Basic Example +/// +/// ```dart +/// RetryWhenStream( +/// () => Stream.fromIterable([1]), +/// (Object error, StackTrace s) => throw error, +/// ).listen(print); // Prints 1 +/// ``` +/// +/// ### Periodic Example +/// +/// ```dart +/// RetryWhenStream( +/// () => Stream.periodic(const Duration(seconds: 1), (int i) => i) +/// .map((int i) => i == 2 ? throw 'exception' : i), +/// (Object e, StackTrace s) => +/// Rx.timer(null, const Duration(milliseconds: 200)), +/// ).take(4).listen(print); // Prints 0, 1, 0, 1 +/// ``` +/// +/// ### Complex Example +/// +/// ```dart +/// var errorHappened = false; +/// RetryWhenStream( +/// () => Stream.periodic(const Duration(seconds: 1), (i) => i).map((i) { +/// if (i == 3 && !errorHappened) { +/// throw 'We can take this. Please restart.'; +/// } else if (i == 4) { +/// throw 'It\'s enough.'; +/// } else { +/// return i; +/// } +/// }), +/// (e, s) { +/// errorHappened = true; +/// if (e == 'We can take this. Please restart.') { +/// return Stream.value('Ok. Here you go!'); +/// } else { +/// return Stream.error(e, s); +/// } +/// }, +/// ).listen(print, onError: print); // Prints 0, 1, 2, 0, 1, 2, 3, It's enough. +/// ``` +class RetryWhenStream extends Stream { + /// The factory method used at subscription time + final Stream Function() streamFactory; + + /// The factory method used to create the [Stream] which triggers a re-listen + final Stream Function( + Object error, + StackTrace stackTrace, + ) retryWhenFactory; + + late final _controller = StreamController( + sync: true, + onListen: _retry, + onPause: () => _subscription!.pause(), + onResume: () => _subscription!.resume(), + onCancel: () => _subscription?.cancel(), + ); + StreamSubscription? _subscription; + + /// Constructs a [Stream] that will recreate and re-listen to the source + /// [Stream] (created by the provided factory method). + /// The retry will trigger whenever the [Stream] created by the retryWhen + /// factory emits and event. + RetryWhenStream(this.streamFactory, this.retryWhenFactory); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + return _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + void _retry() { + void onError(Object originalError, StackTrace originalStacktrace) { + _cancelSubscription(); + + Stream retryStream; + try { + retryStream = retryWhenFactory(originalError, originalStacktrace); + } catch (e, s) { + return _addErrorAndClose(originalError, originalStacktrace, e, s); + } + + _subscription = retryStream.listen( + (_) { + _cancelSubscription(); + _retry(); + }, + onError: (Object e, StackTrace s) { + _cancelSubscription(); + _addErrorAndClose(originalError, originalStacktrace, e, s); + }, + cancelOnError: false, + ); + } + + _subscription = streamFactory().listen( + _controller.add, + onError: onError, + onDone: _controller.close, + cancelOnError: false, + ); + } + + void _addErrorAndClose( + Object originalError, + StackTrace originalStacktrace, + Object e, + StackTrace s, + ) { + if (identical(originalError, e)) { + _controller.addError(originalError, originalStacktrace); + } else { + _controller.addError(originalError, originalStacktrace); + _controller.addError(e, s); + } + _controller.close(); + } + + void _cancelSubscription() { + _subscription!.cancel(); + _subscription = null; + } +} diff --git a/core/reactivex/lib/src/streams/sequence_equal.dart b/core/reactivex/lib/src/streams/sequence_equal.dart new file mode 100644 index 00000000..5fd8d11e --- /dev/null +++ b/core/reactivex/lib/src/streams/sequence_equal.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/streams/zip.dart'; +import 'package:angel3_reactivex/src/transformers/materialize.dart'; +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; + +/// Determine whether two Streams emit the same sequence of items. +/// You can provide an optional equals handler to determine equality. +/// +/// [Interactive marble diagram](https://rxmarbles.com/#sequenceEqual) +/// +/// ### Example +/// +/// SequenceEqualsStream([ +/// Stream.fromIterable([1, 2, 3, 4, 5]), +/// Stream.fromIterable([1, 2, 3, 4, 5]) +/// ]) +/// .listen(print); // prints true +class SequenceEqualStream extends Stream { + final StreamController _controller; + + /// Creates a [Stream] that emits true or false, depending on the + /// equality between the provided [Stream]s. + /// This single value is emitted when both provided [Stream]s are complete. + /// After this event, the [Stream] closes. + SequenceEqualStream( + Stream stream, + Stream other, { + bool Function(S s, T t)? dataEquals, + bool Function(ErrorAndStackTrace, ErrorAndStackTrace)? errorEquals, + }) : _controller = _buildController(stream, other, dataEquals, errorEquals); + + @override + StreamSubscription listen(void Function(bool event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) => + _controller.stream.listen(onData, + onError: onError, onDone: onDone, cancelOnError: cancelOnError); + + static StreamController _buildController( + Stream stream, + Stream other, + bool Function(S s, T t)? dataEquals, + bool Function(ErrorAndStackTrace, ErrorAndStackTrace)? errorEquals, + ) { + dataEquals = dataEquals ?? (s, t) => s == t; + errorEquals = errorEquals ?? (e1, e2) => e1 == e2; + + late StreamController controller; + late StreamSubscription subscription; + + controller = StreamController( + sync: true, + onListen: () { + void emitAndClose([bool value = true]) => controller + ..add(value) + ..close(); + + bool compare(StreamNotification s, StreamNotification t) { + if (s.kind != t.kind) { + return false; + } + + switch (s.kind) { + case NotificationKind.data: + return dataEquals!( + s.requireDataValue, + t.requireDataValue, + ); + case NotificationKind.done: + return true; + case NotificationKind.error: + return errorEquals!( + s.requireErrorAndStackTrace, + t.requireErrorAndStackTrace, + ); + } + } + + subscription = + ZipStream.zip2(stream.materialize(), other.materialize(), compare) + .where((isEqual) => !isEqual) + .listen( + emitAndClose, + onError: controller.addError, + onDone: emitAndClose, + ); + }, + onPause: () => subscription.pause(), + onResume: () => subscription.resume(), + onCancel: () => subscription.cancel()); + + return controller; + } +} diff --git a/core/reactivex/lib/src/streams/switch_latest.dart b/core/reactivex/lib/src/streams/switch_latest.dart new file mode 100644 index 00000000..87650ad6 --- /dev/null +++ b/core/reactivex/lib/src/streams/switch_latest.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +/// Convert a [Stream] that emits [Stream]s (aka a 'Higher Order Stream') into a +/// single [Stream] that emits the items emitted by the most-recently-emitted of +/// those [Stream]s. +/// +/// This stream will unsubscribe from the previously-emitted Stream when a new +/// Stream is emitted from the source Stream. +/// +/// ### Example +/// +/// ```dart +/// final switchLatestStream = SwitchLatestStream( +/// Stream.fromIterable(>[ +/// Rx.timer('A', Duration(seconds: 2)), +/// Rx.timer('B', Duration(seconds: 1)), +/// Stream.value('C'), +/// ]), +/// ); +/// +/// // Since the first two Streams do not emit data for 1-2 seconds, and the 3rd +/// // Stream will be emitted before that time, only data from the 3rd Stream +/// // will be emitted to the listener. +/// switchLatestStream.listen(print); // prints 'C' +/// ``` +class SwitchLatestStream extends Stream { + // ignore: close_sinks + final StreamController _controller; + + /// Constructs a [Stream] that emits [Stream]s (aka a 'Higher Order Stream") into a + /// single [Stream] that emits the items emitted by the most-recently-emitted of + /// those [Stream]s. + SwitchLatestStream(Stream> streams) + : _controller = _buildController(streams); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) => + _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + static StreamController _buildController(Stream> streams) { + late StreamController controller; + late StreamSubscription> subscription; + StreamSubscription? otherSubscription; + var leftClosed = false, rightClosed = false, hasMainEvent = false; + + controller = StreamController( + sync: true, + onListen: () { + void closeLeft() { + leftClosed = true; + + if (rightClosed || !hasMainEvent) controller.close(); + } + + void closeRight() { + rightClosed = true; + + if (leftClosed) controller.close(); + } + + subscription = streams.listen((stream) { + try { + otherSubscription?.cancel(); + + hasMainEvent = true; + + otherSubscription = stream.listen( + controller.add, + onError: controller.addError, + onDone: closeRight, + ); + } catch (e, s) { + controller.addError(e, s); + } + }, onError: controller.addError, onDone: closeLeft); + }, + onPause: () { + subscription.pause(); + otherSubscription?.pause(); + }, + onResume: () { + subscription.resume(); + otherSubscription?.resume(); + }, + onCancel: () async { + await subscription.cancel(); + + if (hasMainEvent) await otherSubscription?.cancel(); + }); + + return controller; + } +} diff --git a/core/reactivex/lib/src/streams/timer.dart b/core/reactivex/lib/src/streams/timer.dart new file mode 100644 index 00000000..c456b667 --- /dev/null +++ b/core/reactivex/lib/src/streams/timer.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +/// Emits the given value after a specified amount of time. +/// +/// ### Example +/// +/// TimerStream('hi', Duration(minutes: 1)) +/// .listen((i) => print(i)); // print 'hi' after 1 minute +class TimerStream extends Stream { + final StreamController _controller; + + /// Constructs a [Stream] which emits [value] after the specified [Duration]. + TimerStream(T value, Duration duration) + : _controller = _buildController(value, duration); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + return _controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + static StreamController _buildController(T value, Duration duration) { + final watch = Stopwatch(); + Timer? timer; + late StreamController controller; + Duration? totalElapsed = Duration.zero; + + void onResume() { + // Already cancelled or is not paused. + if (totalElapsed == null || timer != null) return; + + totalElapsed = totalElapsed! + watch.elapsed; + watch.start(); + + timer = Timer(duration - totalElapsed!, () { + controller.add(value); + controller.close(); + }); + } + + controller = StreamController( + sync: true, + onListen: () { + watch.start(); + timer = Timer(duration, () { + controller.add(value); + controller.close(); + }); + }, + onPause: () { + timer?.cancel(); + timer = null; + watch.stop(); + }, + onResume: onResume, + onCancel: () { + timer?.cancel(); + timer = null; + totalElapsed = null; + }, + ); + return controller; + } +} diff --git a/core/reactivex/lib/src/streams/using.dart b/core/reactivex/lib/src/streams/using.dart new file mode 100644 index 00000000..1cbed852 --- /dev/null +++ b/core/reactivex/lib/src/streams/using.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +/// When listener listens to it, creates a resource object from resource factory function, +/// and creates a [Stream] from the given factory function and resource as argument. +/// Finally when the stream finishes emitting items or stream subscription +/// is cancelled (call [StreamSubscription.cancel] or `Stream.listen(cancelOnError: true)`), +/// call the disposer function on resource object. +/// The disposer is called after the future returned from [StreamSubscription.cancel] completes. +/// +/// The [UsingStream] is a way you can instruct a Stream to create +/// a resource that exists only during the lifespan of the Stream +/// and is disposed of when the Stream terminates. +/// +/// [Marble diagram](http://reactivex.io/documentation/operators/images/using.c.png) +/// +/// ### Example +/// +/// UsingStream>( +/// resourceFactory: () => Queue.of([1, 2, 3]), +/// streamFactory: (r) => Stream.fromIterable(r), +/// disposer: (r) => r.clear(), +/// ).listen(print); // prints 1, 2, 3 +class UsingStream extends StreamView { + /// Construct a [UsingStream] that creates a resource object from [resourceFactory], + /// and then creates a [Stream] from [streamFactory] and resource as argument. + /// When the Stream terminates, call [disposer] on resource object. + UsingStream({ + required FutureOr Function() resourceFactory, + required Stream Function(R) streamFactory, + required FutureOr Function(R) disposer, + }) : super(_buildStream(resourceFactory, streamFactory, disposer)); + + static Stream _buildStream( + FutureOr Function() resourceFactory, + Stream Function(R) streamFactory, + FutureOr Function(R) disposer, + ) { + late StreamController controller; + var resourceCreated = false; + late R resource; + StreamSubscription? subscription; + + void useResource(R r) { + resource = r; + resourceCreated = true; + + Stream stream; + try { + stream = streamFactory(r); + } catch (e, s) { + controller.addError(e, s); + controller.close(); + return; + } + + subscription = stream.listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + } + + controller = StreamController( + sync: true, + onListen: () { + final FutureOr resourceOrFuture; + try { + resourceOrFuture = resourceFactory(); + } catch (e, s) { + controller.addError(e, s); + controller.close(); + return; + } + + if (resourceOrFuture is R) { + useResource(resourceOrFuture); + } else { + resourceOrFuture.then((r) { + // if the controller was cancelled before the resource is created, + // we should dispose the resource + if (!controller.hasListener) { + disposer(r); + } else { + useResource(r); + } + }).onError((e, s) { + controller.addError(e, s); + controller.close(); + }); + } + }, + onPause: () => subscription?.pause(), + onResume: () => subscription?.resume(), + onCancel: () { + final cancelFuture = subscription?.cancel(); + subscription = null; + + return cancelFuture == null + ? (resourceCreated ? disposer(resource) : null) + : cancelFuture + .then((_) => resourceCreated ? disposer(resource) : null); + }, + ); + + return controller.stream; + } +} diff --git a/core/reactivex/lib/src/streams/value_stream.dart b/core/reactivex/lib/src/streams/value_stream.dart new file mode 100644 index 00000000..2df9a862 --- /dev/null +++ b/core/reactivex/lib/src/streams/value_stream.dart @@ -0,0 +1,97 @@ +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; + +/// A [Stream] that provides synchronous access to the last emitted value (aka. data event). +abstract class ValueStream implements Stream { + /// Returns the last emitted value, failing if there is no value. + /// See [hasValue] to determine whether [value] has already been set. + /// + /// Throws [ValueStreamError] if this Stream has no value. + /// + /// See also [valueOrNull]. + T get value; + + /// Returns the last emitted value, or `null` if value events haven't yet been emitted. + T? get valueOrNull; + + /// Returns `true` when [value] is available, + /// meaning this Stream has emitted at least one value. + bool get hasValue; + + /// Returns last emitted error, failing if there is no error. + /// See [hasError] to determine whether [error] has already been set. + /// + /// Throws [ValueStreamError] if this Stream has no error. + /// + /// See also [errorOrNull]. + Object get error; + + /// Returns the last emitted error, or `null` if error events haven't yet been emitted. + Object? get errorOrNull; + + /// Returns `true` when [error] is available, + /// meaning this Stream has emitted at least one error. + bool get hasError; + + /// Returns [StackTrace] of the last emitted error. + /// + /// If error events haven't yet been emitted, + /// or the last emitted error didn't have a stack trace, + /// the returned value is `null`. + StackTrace? get stackTrace; + + /// Returns the last emitted event (either data/value or error event). + /// `null` if no value or error events have been emitted yet. + StreamNotification? get lastEventOrNull; +} + +/// Extension methods on [ValueStream] related to [lastEventOrNull]. +extension LastEventValueStreamExtensions on ValueStream { + /// Returns `true` if the last emitted event is a data event (aka. a value event). + bool get isLastEventValue => lastEventOrNull?.isData ?? false; + + /// Returns `true` if the last emitted event is an error event. + bool get isLastEventError => lastEventOrNull?.isError ?? false; +} + +/// Extension method on [ValueStream] to access the last emitted [ErrorAndStackTrace]. +extension ErrorAndStackTraceValueStreamExtension on ValueStream { + /// Returns the last emitted [ErrorAndStackTrace], + /// or `null` if no error events have been emitted yet. + ErrorAndStackTrace? get errorAndStackTraceOrNull { + final error = errorOrNull; + return error == null ? null : ErrorAndStackTrace(error, stackTrace); + } +} + +enum _MissingCase { + value, + error, +} + +/// The error throw by [ValueStream.value] or [ValueStream.error]. +class ValueStreamError extends Error { + final _MissingCase _missingCase; + + ValueStreamError._(this._missingCase); + + /// Construct an [ValueStreamError] thrown by [ValueStream.value] when there is no value. + factory ValueStreamError.hasNoValue() => + ValueStreamError._(_MissingCase.value); + + /// Construct an [ValueStreamError] thrown by [ValueStream.error] when there is no error. + factory ValueStreamError.hasNoError() => + ValueStreamError._(_MissingCase.error); + + @override + String toString() { + switch (_missingCase) { + case _MissingCase.value: + return 'ValueStream has no value. You should check ValueStream.hasValue ' + 'before accessing ValueStream.value, or use ValueStream.valueOrNull instead.'; + case _MissingCase.error: + return 'ValueStream has no error. You should check ValueStream.hasError ' + 'before accessing ValueStream.error, or use ValueStream.errorOrNull instead.'; + } + } +} diff --git a/core/reactivex/lib/src/streams/zip.dart b/core/reactivex/lib/src/streams/zip.dart new file mode 100644 index 00000000..5caf6eb7 --- /dev/null +++ b/core/reactivex/lib/src/streams/zip.dart @@ -0,0 +1,388 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Merges the specified streams into one stream sequence using the given +/// zipper Function whenever all of the stream sequences have produced +/// an element at a corresponding index. +/// +/// It applies this function in strict sequence, so the first item emitted by +/// the new Stream will be the result of the function applied to the first +/// item emitted by Stream #1 and the first item emitted by Stream #2; +/// the second item emitted by the new ZipStream will be the result of +/// the function applied to the second item emitted by Stream #1 and the +/// second item emitted by Stream #2; and so forth. It will only emit as +/// many items as the number of items emitted by the source Stream that +/// emits the fewest items. +/// +/// If the provided streams is empty, the resulting sequence completes immediately +/// without emitting any items and without any calls to the zipper function. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#zip) +/// +/// ### Basic Example +/// +/// ZipStream( +/// [ +/// Stream.fromIterable(['A']), +/// Stream.fromIterable(['B']), +/// Stream.fromIterable(['C', 'D']), +/// ], +/// (values) => values.join(), +/// ).listen(print); // prints 'ABC' +/// +/// ### Example with a specific number of Streams +/// +/// If you wish to zip a specific number of Streams together with proper types +/// information for the value of each Stream, use the [zip2] - [zip9] operators. +/// +/// ZipStream.zip2( +/// Stream.fromIterable(['A']), +/// Stream.fromIterable(['B', 'C']), +/// (a, b) => a + b, +/// ) +/// .listen(print); // prints 'AB' +class ZipStream extends StreamView { + /// Constructs a [Stream] which merges the specified [streams] into a sequence using the given + /// [zipper] Function, whenever all of the [streams] have produced + /// an element at a corresponding index. + ZipStream( + Iterable> streams, + R Function(List values) zipper, + ) : super(_buildController(streams, zipper).stream); + + /// Constructs a [Stream] which merges the specified [streams] into a [List], + /// containing values that were produced by the [streams] at a corresponding index. + static ZipStream> list(Iterable> streams) { + return ZipStream>( + streams, + (List values) => values, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip2( + Stream streamOne, + Stream streamTwo, + R Function(A a, B b) zipper, + ) { + return ZipStream( + [streamOne, streamTwo], + (List values) => zipper(values[0] as A, values[1] as B), + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip3( + Stream streamA, + Stream streamB, + Stream streamC, + R Function(A a, B b, C c) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip4( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + R Function(A a, B b, C c, D d) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC, streamD], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip5( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + R Function(A a, B b, C c, D d, E e) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC, streamD, streamE], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip6( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + R Function(A a, B b, C c, D d, E e, F f) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC, streamD, streamE, streamF], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip7( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + R Function(A a, B b, C c, D d, E e, F f, G g) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC, streamD, streamE, streamF, streamG], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip8( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + R Function(A a, B b, C c, D d, E e, F f, G g, H h) zipper, + ) { + return ZipStream( + [streamA, streamB, streamC, streamD, streamE, streamF, streamG, streamH], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + ); + }, + ); + } + + /// Constructs a [Stream] which merges the specified [Stream]s into a sequence using the given + /// [zipper] Function, whenever all of the provided [Stream]s have produced + /// an element at a corresponding index. + static ZipStream zip9( + Stream streamA, + Stream streamB, + Stream streamC, + Stream streamD, + Stream streamE, + Stream streamF, + Stream streamG, + Stream streamH, + Stream streamI, + R Function(A a, B b, C c, D d, E e, F f, G g, H h, I i) zipper, + ) { + return ZipStream( + [ + streamA, + streamB, + streamC, + streamD, + streamE, + streamF, + streamG, + streamH, + streamI + ], + (List values) { + return zipper( + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + values[8] as I, + ); + }, + ); + } + + static StreamController _buildController( + Iterable> streams, + R Function(List values) zipper, + ) { + final controller = StreamController(sync: true); + late List> subscriptions; + var pendingSubscriptions = >[]; + + controller.onListen = () { + Completer? completeCurrent; + late final _Window window; + + // resets variables for the next zip window + void next() { + completeCurrent?.complete(null); + completeCurrent = Completer(); + + pendingSubscriptions = subscriptions.toList(); + } + + void Function(T value) doUpdate(int index) { + return (T value) { + window.onValue(index, value); + + if (window.isComplete) { + // all streams emitted for the current zip index + // dispatch event and reset for next + final R combined; + try { + combined = zipper(window.flush()); + } catch (e, s) { + controller.addError(e, s); + return; + } + controller.add(combined); + + // reset for next zip event + next(); + } else { + // other streams are still pending to get to the next + // zip event index. + // pause this subscription while we await the others + final subscription = subscriptions[index] + ..pause(completeCurrent!.future); + + pendingSubscriptions.remove(subscription); + } + }; + } + + subscriptions = streams + .mapIndexed((index, stream) => stream.listen(doUpdate(index), + onError: controller.addError, onDone: controller.close)) + .toList(growable: false); + if (subscriptions.isEmpty) { + controller.close(); + } else { + window = _Window(subscriptions.length); + next(); + } + }; + controller.onPause = () => pendingSubscriptions.pauseAll(); + controller.onResume = () => pendingSubscriptions.resumeAll(); + controller.onCancel = () => pendingSubscriptions.cancelAll(); + + return controller; + } +} + +/// A window keeps track of the values emitted by the different +/// zipped Streams. +class _Window { + final int size; + final List _values; + + int _valuesReceived = 0; + + bool get isComplete => _valuesReceived == size; + + _Window(this.size) : _values = List.filled(size, null); + + void onValue(int index, T value) { + _values[index] = value; + + _valuesReceived++; + } + + List flush() { + _valuesReceived = 0; + + return List.unmodifiable(_values); + } +} + +/// Extends the Stream class with the ability to zip one Stream with another. +extension ZipWithExtension on Stream { + /// Returns a Stream that combines the current stream together with another + /// stream using a given zipper function. + /// + /// ### Example + /// + /// Stream.fromIterable([1]) + /// .zipWith(Stream.fromIterable([2]), (one, two) => one + two) + /// .listen(print); // prints 3 + Stream zipWith(Stream other, R Function(T t, S s) zipper) { + final stream = ZipStream.zip2(this, other, zipper); + + return isBroadcast + ? stream.asBroadcastStream(onCancel: (s) => s.cancel()) + : stream; + } +} diff --git a/core/reactivex/lib/src/subjects/behavior_subject.dart b/core/reactivex/lib/src/subjects/behavior_subject.dart new file mode 100644 index 00000000..4a9f7d4e --- /dev/null +++ b/core/reactivex/lib/src/subjects/behavior_subject.dart @@ -0,0 +1,275 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/rx.dart'; +import 'package:angel3_reactivex/src/streams/value_stream.dart'; +import 'package:angel3_reactivex/src/subjects/subject.dart'; +import 'package:angel3_reactivex/src/transformers/start_with.dart'; +import 'package:angel3_reactivex/src/transformers/start_with_error.dart'; +import 'package:angel3_reactivex/src/utils/empty.dart'; +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; + +/// A special StreamController that captures the latest item that has been +/// added to the controller, and emits that as the first item to any new +/// listener. +/// +/// This subject allows sending data, error and done events to the listener. +/// The latest item that has been added to the subject will be sent to any +/// new listeners of the subject. After that, any new events will be +/// appropriately sent to the listeners. It is possible to provide a seed value +/// that will be emitted if no items have been added to the subject. +/// +/// BehaviorSubject is, by default, a broadcast (aka hot) controller, in order +/// to fulfill the Rx Subject contract. This means the Subject's `stream` can +/// be listened to multiple times. +/// +/// ### Example +/// +/// final subject = BehaviorSubject(); +/// +/// subject.add(1); +/// subject.add(2); +/// subject.add(3); +/// +/// subject.stream.listen(print); // prints 3 +/// subject.stream.listen(print); // prints 3 +/// subject.stream.listen(print); // prints 3 +/// +/// ### Example with seed value +/// +/// final subject = BehaviorSubject.seeded(1); +/// +/// subject.stream.listen(print); // prints 1 +/// subject.stream.listen(print); // prints 1 +/// subject.stream.listen(print); // prints 1 +class BehaviorSubject extends Subject implements ValueStream { + final _Wrapper _wrapper; + + BehaviorSubject._( + StreamController controller, + Stream stream, + this._wrapper, + ) : super(controller, stream); + + /// Constructs a [BehaviorSubject], optionally pass handlers for + /// [onListen], [onCancel] and a flag to handle events [sync]. + /// + /// See also [StreamController.broadcast] + factory BehaviorSubject({ + void Function()? onListen, + void Function()? onCancel, + bool sync = false, + }) { + // ignore: close_sinks + final controller = StreamController.broadcast( + onListen: onListen, + onCancel: onCancel, + sync: sync, + ); + + final wrapper = _Wrapper(); + + return BehaviorSubject._( + controller, + Rx.defer(_deferStream(wrapper, controller, sync), reusable: true), + wrapper); + } + + /// Constructs a [BehaviorSubject], optionally pass handlers for + /// [onListen], [onCancel] and a flag to handle events [sync]. + /// + /// [seedValue] becomes the current [value] and is emitted immediately. + /// + /// See also [StreamController.broadcast] + factory BehaviorSubject.seeded( + T seedValue, { + void Function()? onListen, + void Function()? onCancel, + bool sync = false, + }) { + // ignore: close_sinks + final controller = StreamController.broadcast( + onListen: onListen, + onCancel: onCancel, + sync: sync, + ); + + final wrapper = _Wrapper.seeded(seedValue); + + return BehaviorSubject._( + controller, + Rx.defer(_deferStream(wrapper, controller, sync), reusable: true), + wrapper, + ); + } + + static Stream Function() _deferStream( + _Wrapper wrapper, StreamController controller, bool sync) => + () { + final errorAndStackTrace = wrapper.errorAndStackTrace; + if (errorAndStackTrace != null && !wrapper.isValue) { + return controller.stream.transform( + StartWithErrorStreamTransformer( + errorAndStackTrace.error, + errorAndStackTrace.stackTrace, + ), + ); + } + + final value = wrapper.value; + if (isNotEmpty(value) && wrapper.isValue) { + return controller.stream + .transform(StartWithStreamTransformer(value as T)); + } + + return controller.stream; + }; + + @override + void onAdd(T event) => _wrapper.setValue(event); + + @override + void onAddError(Object error, [StackTrace? stackTrace]) => + _wrapper.setError(error, stackTrace); + + @override + ValueStream get stream => _BehaviorSubjectStream(this); + + @override + bool get hasValue => isNotEmpty(_wrapper.value); + + @override + T get value { + final value = _wrapper.value; + if (isNotEmpty(value)) { + return value as T; + } + throw ValueStreamError.hasNoValue(); + } + + @override + T? get valueOrNull => unbox(_wrapper.value); + + /// Set and emit the new value. + set value(T newValue) => add(newValue); + + @override + bool get hasError => _wrapper.errorAndStackTrace != null; + + @override + Object? get errorOrNull => _wrapper.errorAndStackTrace?.error; + + @override + Object get error { + final errorAndSt = _wrapper.errorAndStackTrace; + if (errorAndSt != null) { + return errorAndSt.error; + } + throw ValueStreamError.hasNoError(); + } + + @override + StackTrace? get stackTrace => _wrapper.errorAndStackTrace?.stackTrace; + + @override + StreamNotification? get lastEventOrNull { + // data event + if (_wrapper.isValue) { + return StreamNotification.data(_wrapper.value as T); + } + + // error event + final errorAndSt = _wrapper.errorAndStackTrace; + if (errorAndSt != null) { + return ErrorNotification(errorAndSt); + } + + // no event + return null; + } +} + +class _Wrapper { + var isValue = false; + var value = EMPTY; + ErrorAndStackTrace? errorAndStackTrace; + + /// Non-seeded constructor + _Wrapper() : isValue = false; + + _Wrapper.seeded(T v) { + setValue(v); + } + + void setValue(T event) { + value = event; + isValue = true; + } + + void setError(Object error, StackTrace? stackTrace) { + errorAndStackTrace = ErrorAndStackTrace(error, stackTrace); + isValue = false; + } +} + +class _BehaviorSubjectStream extends Stream implements ValueStream { + final BehaviorSubject _subject; + + _BehaviorSubjectStream(this._subject); + + @override + bool get isBroadcast => true; + + // Override == and hashCode so that new streams returned by the same + // subject are considered equal. + // The subject returns a new stream each time it's queried, + // but doesn't have to cache the result. + + @override + int get hashCode => _subject.hashCode ^ 0x35323532; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _BehaviorSubjectStream && + identical(other._subject, _subject); + } + + @override + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => + _subject.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + @override + Object get error => _subject.error; + + @override + Object? get errorOrNull => _subject.errorOrNull; + + @override + bool get hasError => _subject.hasError; + + @override + bool get hasValue => _subject.hasValue; + + @override + StackTrace? get stackTrace => _subject.stackTrace; + + @override + T get value => _subject.value; + + @override + T? get valueOrNull => _subject.valueOrNull; + + @override + StreamNotification? get lastEventOrNull => _subject.lastEventOrNull; +} diff --git a/core/reactivex/lib/src/subjects/publish_subject.dart b/core/reactivex/lib/src/subjects/publish_subject.dart new file mode 100644 index 00000000..35bbc6dc --- /dev/null +++ b/core/reactivex/lib/src/subjects/publish_subject.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/subjects/subject.dart'; + +/// Exactly like a normal broadcast StreamController with one exception: +/// this class is both a Stream and Sink. +/// +/// This Subject allows sending data, error and done events to the listener. +/// +/// PublishSubject is, by default, a broadcast (aka hot) controller, in order +/// to fulfill the Rx Subject contract. This means the Subject's `stream` can +/// be listened to multiple times. +/// +/// ### Example +/// +/// final subject = PublishSubject(); +/// +/// // observer1 will receive all data and done events +/// subject.stream.listen(observer1); +/// subject.add(1); +/// subject.add(2); +/// +/// // observer2 will only receive 3 and done event +/// subject.stream.listen(observer2); +/// subject.add(3); +/// subject.close(); +class PublishSubject extends Subject { + PublishSubject._(StreamController controller, Stream stream) + : super(controller, stream); + + /// Constructs a [PublishSubject], optionally pass handlers for + /// [onListen], [onCancel] and a flag to handle events [sync]. + /// + /// See also [StreamController.broadcast] + factory PublishSubject( + {void Function()? onListen, + void Function()? onCancel, + bool sync = false}) { + // ignore: close_sinks + final controller = StreamController.broadcast( + onListen: onListen, + onCancel: onCancel, + sync: sync, + ); + + return PublishSubject._( + controller, + controller.stream, + ); + } +} diff --git a/core/reactivex/lib/src/subjects/replay_subject.dart b/core/reactivex/lib/src/subjects/replay_subject.dart new file mode 100644 index 00000000..5181a075 --- /dev/null +++ b/core/reactivex/lib/src/subjects/replay_subject.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:angel3_reactivex/src/rx.dart'; +import 'package:angel3_reactivex/src/streams/replay_stream.dart'; +import 'package:angel3_reactivex/src/subjects/subject.dart'; +import 'package:angel3_reactivex/src/transformers/start_with.dart'; +import 'package:angel3_reactivex/src/transformers/start_with_error.dart'; +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/empty.dart'; +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; + +/// A special StreamController that captures all of the items that have been +/// added to the controller, and emits those as the first items to any new +/// listener. +/// +/// This subject allows sending data, error and done events to the listener. +/// As items are added to the subject, the ReplaySubject will store them. +/// When the stream is listened to, those recorded items will be emitted to +/// the listener. After that, any new events will be appropriately sent to the +/// listeners. It is possible to cap the number of stored events by setting +/// a maxSize value. +/// +/// ReplaySubject is, by default, a broadcast (aka hot) controller, in order +/// to fulfill the Rx Subject contract. This means the Subject's `stream` can +/// be listened to multiple times. +/// +/// ### Example +/// +/// final subject = ReplaySubject(); +/// +/// subject.add(1); +/// subject.add(2); +/// subject.add(3); +/// +/// subject.stream.listen(print); // prints 1, 2, 3 +/// subject.stream.listen(print); // prints 1, 2, 3 +/// subject.stream.listen(print); // prints 1, 2, 3 +/// +/// ### Example with maxSize +/// +/// final subject = ReplaySubject(maxSize: 2); +/// +/// subject.add(1); +/// subject.add(2); +/// subject.add(3); +/// +/// subject.stream.listen(print); // prints 2, 3 +/// subject.stream.listen(print); // prints 2, 3 +/// subject.stream.listen(print); // prints 2, 3 +class ReplaySubject extends Subject implements ReplayStream { + final Queue<_Event> _queue; + final int? _maxSize; + + /// Constructs a [ReplaySubject], optionally pass handlers for + /// [onListen], [onCancel] and a flag to handle events [sync]. + /// + /// See also [StreamController.broadcast] + factory ReplaySubject({ + int? maxSize, + void Function()? onListen, + void Function()? onCancel, + bool sync = false, + }) { + // ignore: close_sinks + final controller = StreamController.broadcast( + onListen: onListen, + onCancel: onCancel, + sync: sync, + ); + + final queue = Queue<_Event>(); + + return ReplaySubject._( + controller, + Rx.defer( + () => queue.toList(growable: false).reversed.fold( + controller.stream, + (stream, event) { + final errorAndStackTrace = event.errorAndStackTrace; + + if (errorAndStackTrace != null) { + return stream.transform( + StartWithErrorStreamTransformer( + errorAndStackTrace.error, + errorAndStackTrace.stackTrace, + ), + ); + } else { + return stream + .transform(StartWithStreamTransformer(event.data as T)); + } + }, + ), + reusable: true, + ), + queue, + maxSize, + ); + } + + ReplaySubject._( + StreamController controller, + Stream stream, + this._queue, + this._maxSize, + ) : super(controller, stream); + + @override + void onAdd(T event) { + if (_queue.length == _maxSize) { + _queue.removeFirst(); + } + + _queue.add(_Event.data(event)); + } + + @override + void onAddError(Object error, [StackTrace? stackTrace]) { + if (_queue.length == _maxSize) { + _queue.removeFirst(); + } + + _queue.add(_Event.error(ErrorAndStackTrace(error, stackTrace))); + } + + @override + List get values => _queue + .where((event) => event.errorAndStackTrace == null) + .map((event) => event.data as T) + .toList(growable: false); + + @override + List get errors => _queue + .mapNotNull((event) => event.errorAndStackTrace?.error) + .toList(growable: false); + + @override + List get stackTraces => _queue + .mapNotNull((event) => event.errorAndStackTrace) + .map((errorAndStackTrace) => errorAndStackTrace.stackTrace) + .toList(growable: false); + + @override + ReplayStream get stream => _ReplaySubjectStream(this); +} + +class _Event { + final Object? data; + final ErrorAndStackTrace? errorAndStackTrace; + + _Event._({required this.data, required this.errorAndStackTrace}); + + factory _Event.data(T data) => _Event._(data: data, errorAndStackTrace: null); + + factory _Event.error(ErrorAndStackTrace e) => + _Event._(errorAndStackTrace: e, data: EMPTY); +} + +class _ReplaySubjectStream extends Stream implements ReplayStream { + final ReplaySubject _subject; + + _ReplaySubjectStream(this._subject); + + @override + bool get isBroadcast => true; + + @override + List get values => _subject.values; + + @override + List get errors => _subject.errors; + + @override + List get stackTraces => _subject.stackTraces; + + // Override == and hashCode so that new streams returned by the same + // subject are considered equal. + // The subject returns a new stream each time it's queried, + // but doesn't have to cache the result. + + @override + int get hashCode => _subject.hashCode ^ 0x35323532; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _ReplaySubjectStream && identical(other._subject, _subject); + } + + @override + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => + _subject.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); +} diff --git a/core/reactivex/lib/src/subjects/subject.dart b/core/reactivex/lib/src/subjects/subject.dart new file mode 100644 index 00000000..746188de --- /dev/null +++ b/core/reactivex/lib/src/subjects/subject.dart @@ -0,0 +1,231 @@ +import 'dart:async'; + +/// The base for all Subjects. If you'd like to create a new Subject, +/// extend from this class. +/// +/// It handles all of the nitty-gritty details that conform to the +/// StreamController spec and don't need to be repeated over and +/// over. +/// +/// Please see `PublishSubject` for the simplest example of how to +/// extend from this class, or `BehaviorSubject` for a slightly more +/// complex example. +abstract class Subject extends StreamView implements StreamController { + final StreamController _controller; + + bool _isAddingStreamItems = false; + + /// Constructs a [Subject] which wraps the provided [controller]. + /// This constructor is applicable only for classes that extend [Subject]. + /// + /// To guarantee the contract of a [Subject], the [controller] must be + /// a broadcast [StreamController] and the [stream] must also be a broadcast [Stream]. + Subject(StreamController controller, Stream stream) + : _controller = controller, + assert(stream.isBroadcast, 'Subject requires a broadcast stream'), + super(stream); + + @override + StreamSink get sink => _StreamSinkWrapper(this); + + @override + ControllerCallback? get onListen => _controller.onListen; + + @override + set onListen(void Function()? onListenHandler) { + _controller.onListen = onListenHandler; + } + + @override + Stream get stream => _SubjectStream(this); + + @override + ControllerCallback get onPause => + throw UnsupportedError('Subjects do not support pause callbacks'); + + @override + set onPause(void Function()? onPauseHandler) => + throw UnsupportedError('Subjects do not support pause callbacks'); + + @override + ControllerCallback get onResume => + throw UnsupportedError('Subjects do not support resume callbacks'); + + @override + set onResume(void Function()? onResumeHandler) => + throw UnsupportedError('Subjects do not support resume callbacks'); + + @override + ControllerCancelCallback? get onCancel => _controller.onCancel; + + @override + set onCancel(ControllerCancelCallback? onCancelHandler) { + _controller.onCancel = onCancelHandler; + } + + @override + bool get isClosed => _controller.isClosed; + + @override + bool get isPaused => _controller.isPaused; + + @override + bool get hasListener => _controller.hasListener; + + @override + Future get done => _controller.done; + + @override + void addError(Object error, [StackTrace? stackTrace]) { + if (_isAddingStreamItems) { + throw StateError( + 'You cannot add an error while items are being added from addStream'); + } + + _addError(error, stackTrace); + } + + void _addError(Object error, [StackTrace? stackTrace]) { + if (!_controller.isClosed) { + onAddError(error, stackTrace); + } + + // if the controller is closed, calling addError() will throw an StateError. + // that is expected behavior. + _controller.addError(error, stackTrace); + } + + /// An extension point for sub-classes. Perform any side-effect / state + /// management you need to here, rather than overriding the `add` method + /// directly. + void onAddError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream source, {bool? cancelOnError}) { + if (_isAddingStreamItems) { + throw StateError( + 'You cannot add items while items are being added from addStream'); + } + _isAddingStreamItems = true; + + final completer = Completer(); + void complete() { + if (!completer.isCompleted) { + _isAddingStreamItems = false; + completer.complete(); + } + } + + source.listen( + _add, + onError: identical(cancelOnError, true) + ? (Object e, StackTrace s) { + _addError(e, s); + complete(); + } + : _addError, + onDone: complete, + cancelOnError: cancelOnError, + ); + + return completer.future; + } + + @override + void add(T event) { + if (_isAddingStreamItems) { + throw StateError( + 'You cannot add items while items are being added from addStream'); + } + + _add(event); + } + + void _add(T event) { + if (!_controller.isClosed) { + onAdd(event); + } + + // if the controller is closed, calling add() will throw an StateError. + // that is expected behavior. + _controller.add(event); + } + + /// An extension point for sub-classes. Perform any side-effect / state + /// management you need to here, rather than overriding the `add` method + /// directly. + void onAdd(T event) {} + + @override + Future close() { + if (_isAddingStreamItems) { + throw StateError( + 'You cannot close the subject while items are being added from addStream'); + } + + return _controller.close(); + } +} + +class _SubjectStream extends Stream { + final Subject _subject; + + _SubjectStream(this._subject); + + @override + bool get isBroadcast => true; + + // Override == and hashCode so that new streams returned by the same + // subject are considered equal. + // The subject returns a new stream each time it's queried, + // but doesn't have to cache the result. + + @override + int get hashCode => _subject.hashCode ^ 0x35323532; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _SubjectStream && identical(other._subject, _subject); + } + + @override + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => + _subject.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); +} + +/// A class that exposes only the [StreamSink] interface of an object. +class _StreamSinkWrapper implements StreamSink { + final StreamController _target; + + _StreamSinkWrapper(this._target); + + @override + void add(T data) { + _target.add(data); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + _target.addError(error, stackTrace); + } + + @override + Future close() => _target.close(); + + @override + Future addStream(Stream source) => _target.addStream(source); + + @override + Future get done => _target.done; +} diff --git a/core/reactivex/lib/src/transformers/backpressure/backpressure.dart b/core/reactivex/lib/src/transformers/backpressure/backpressure.dart new file mode 100644 index 00000000..c4a544ca --- /dev/null +++ b/core/reactivex/lib/src/transformers/backpressure/backpressure.dart @@ -0,0 +1,357 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +/// The strategy that is used to determine how and when a new window is created. +enum WindowStrategy { + /// cancels the open window (if any) and immediately opens a fresh one. + everyEvent, + + /// waits until the current open window completes, then when the + /// source [Stream] emits a next event, it opens a new window. + eventAfterLastWindow, + + /// opens a recurring window right after the very first event on + /// the source [Stream] is emitted. + firstEventOnly, + + /// does not open any windows, rather all events are buffered and emitted + /// whenever the handler triggers, after this trigger, the buffer is cleared. + onHandler +} + +class _BackpressureStreamSink extends ForwardingSink { + final WindowStrategy _strategy; + final Stream Function(S event)? _windowStreamFactory; + final T Function(S event)? _onWindowStart; + final T Function(List queue)? _onWindowEnd; + final int _startBufferEvery; + final bool Function(List queue)? _closeWindowWhen; + final bool _ignoreEmptyWindows; + final bool _dispatchOnClose; + final Queue queue = DoubleLinkedQueue(); + final int? maxLengthQueue; + var skip = 0; + var _hasData = false; + var _mainClosed = false; + StreamSubscription? _windowSubscription; + + _BackpressureStreamSink( + this._strategy, + this._windowStreamFactory, + this._onWindowStart, + this._onWindowEnd, + this._startBufferEvery, + this._closeWindowWhen, + this._ignoreEmptyWindows, + this._dispatchOnClose, + this.maxLengthQueue, + ); + + @override + void onData(S data) { + _hasData = true; + maybeCreateWindow(data, sink); + + if (skip == 0) { + queue.add(data); + + if (maxLengthQueue != null && queue.length > maxLengthQueue!) { + queue.removeFirstElements(queue.length - maxLengthQueue!); + } + } + + if (skip > 0) { + skip--; + } + + maybeCloseWindow(sink); + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _mainClosed = true; + + if (_strategy == WindowStrategy.eventAfterLastWindow) { + return; + } + + // treat the final event as a Window that opens + // and immediately closes again + if (_dispatchOnClose && queue.isNotEmpty) { + resolveWindowStart(queue.last, sink); + } + + resolveWindowEnd(sink, true); + + queue.clear(); + + _windowSubscription?.cancel(); + sink.close(); + } + + @override + FutureOr onCancel() => _windowSubscription?.cancel(); + + @override + void onListen() {} + + @override + void onPause() => _windowSubscription?.pause(); + + @override + void onResume() => _windowSubscription?.resume(); + + void maybeCreateWindow(S event, EventSink sink) { + switch (_strategy) { + // for example throttle + case WindowStrategy.eventAfterLastWindow: + if (_windowSubscription != null) return; + + _windowSubscription = singleWindow(event, sink); + + resolveWindowStart(event, sink); + + break; + // for example scan + case WindowStrategy.firstEventOnly: + if (_windowSubscription != null) return; + + _windowSubscription = multiWindow(event, sink); + + resolveWindowStart(event, sink); + + break; + // for example debounce + case WindowStrategy.everyEvent: + _windowSubscription?.cancel(); + + _windowSubscription = singleWindow(event, sink); + + resolveWindowStart(event, sink); + + break; + case WindowStrategy.onHandler: + break; + } + } + + void maybeCloseWindow(EventSink sink) { + if (_closeWindowWhen != null && _closeWindowWhen!(unmodifiableQueue)) { + resolveWindowEnd(sink); + } + } + + StreamSubscription singleWindow(S event, EventSink sink) => + buildStream(event, sink).take(1).listen( + null, + onError: sink.addError, + onDone: () => resolveWindowEnd(sink, _mainClosed), + ); + + // opens a new Window which is kept open until the main Stream + // closes. + StreamSubscription multiWindow(S event, EventSink sink) => + buildStream(event, sink).listen( + (dynamic _) => resolveWindowEnd(sink), + onError: sink.addError, + onDone: () => resolveWindowEnd(sink), + ); + + Stream buildStream(S event, EventSink sink) { + Stream stream; + + _windowSubscription?.cancel(); + + stream = _windowStreamFactory!(event); + + return stream; + } + + void resolveWindowStart(S event, EventSink sink) { + if (_onWindowStart != null) { + sink.add(_onWindowStart!(event)); + } + } + + void resolveWindowEnd(EventSink sink, [bool isControllerClosing = false]) { + if (isControllerClosing && + _strategy == WindowStrategy.eventAfterLastWindow) { + if (_dispatchOnClose && + _hasData && + queue.length > 1 && + _onWindowEnd != null) { + sink.add(_onWindowEnd!(unmodifiableQueue)); + } + + queue.clear(); + _windowSubscription?.cancel(); + _windowSubscription = null; + + sink.close(); + return; + } + + if (isControllerClosing || + _strategy == WindowStrategy.eventAfterLastWindow || + _strategy == WindowStrategy.everyEvent) { + _windowSubscription?.cancel(); + _windowSubscription = null; + } + + if (isControllerClosing && !_dispatchOnClose) { + return; + } + + if (_hasData && (queue.isNotEmpty || !_ignoreEmptyWindows)) { + if (_onWindowEnd != null) { + sink.add(_onWindowEnd!(unmodifiableQueue)); + } + + // prepare the buffer for the next window. + // by default, this is just a cleared buffer + if (!isControllerClosing && _startBufferEvery > 0) { + skip = _startBufferEvery > queue.length + ? _startBufferEvery - queue.length + : 0; + + // ...unless startBufferEvery is provided. + // here we backtrack to the first event of the last buffer + // and count forward using startBufferEvery until we reach + // the next event. + // + // if the next event is found inside the current buffer, + // then this event and any later events in the buffer + // become the starting values of the next buffer. + // if the next event is not yet available, then a skip + // count is calculated. + // this count will skip the next Future n-events. + // when skip is reset to 0, then we start adding events + // again into the new buffer. + // + // example: + // startBufferEvery = 2 + // last buffer: [0, 1, 2, 3, 4] + // 0 is the first event, + // 2 is the n-th event + // new buffer starts with [2, 3, 4] + // + // example: + // startBufferEvery = 3 + // last buffer: [0, 1] + // 0 is the first event, + // the n-the event is not yet dispatched at this point + // skip becomes 1 + // event 2 is skipped, skip becomes 0 + // event 3 is now added to the buffer + if (_startBufferEvery < queue.length) { + queue.removeFirstElements(_startBufferEvery); + } else { + queue.clear(); + } + } else { + queue.clear(); + } + } + } + + List get unmodifiableQueue => List.unmodifiable(queue); +} + +/// A highly customizable [StreamTransformer] which can be configured +/// to serve any of the common rx backpressure operators. +/// +/// The [StreamTransformer] works by creating windows, during which it +/// buffers events to a [Queue]. +/// +/// The [StreamTransformer] works by creating windows, during which it +/// buffers events to a [Queue]. It uses a [WindowStrategy] to determine +/// how and when a new window is created. +/// +/// onWindowStart and onWindowEnd are handlers that fire when a window +/// opens and closes, right before emitting the transformed event. +/// +/// startBufferEvery allows to skip events coming from the source [Stream]. +/// +/// ignoreEmptyWindows can be set to true, to allow events to be emitted +/// at the end of a window, even if the current buffer is empty. +/// If the buffer is empty, then an empty [List] will be emitted. +/// If false, then nothing is emitted on an empty buffer. +/// +/// dispatchOnClose will cause the remaining values in the buffer to be +/// emitted when the source [Stream] closes. +/// When false, the remaining buffer is discarded on close. +class BackpressureStreamTransformer extends StreamTransformerBase { + /// Determines how the window is created + final WindowStrategy strategy; + + /// Factory method used to create the [Stream] which will be buffered + final Stream Function(S event)? windowStreamFactory; + + /// Handler which fires when the window opens + final T Function(S event)? onWindowStart; + + /// Handler which fires when the window closes + final T Function(List queue)? onWindowEnd; + + /// Maximum length of the buffer. + /// Specify this value to avoid running out of memory when adding too many events to the buffer. + /// If it's `null`, maximum length of the buffer is unlimited. + final int? maxLengthQueue; + + /// Used to skip an amount of events + final int startBufferEvery; + + /// Predicate which determines when the current window should close + final bool Function(List queue)? closeWindowWhen; + + /// Toggle to prevent, or allow windows that contain + /// no events to be dispatched + final bool ignoreEmptyWindows; + + /// Toggle to prevent, or allow the final set of events to be dispatched + /// when the source [Stream] closes + final bool dispatchOnClose; + + /// Constructs a [StreamTransformer] which buffers events emitted by the + /// [Stream] that is created by [windowStreamFactory]. + /// + /// Use the various optional parameters to precisely determine how and when + /// this buffer should be created. + /// + /// For more info on the parameters, see [BackpressureStreamTransformer], + /// or see the various back pressure [StreamTransformer]s for examples. + BackpressureStreamTransformer( + this.strategy, + this.windowStreamFactory, { + this.onWindowStart, + this.onWindowEnd, + this.startBufferEvery = 0, + this.closeWindowWhen, + this.ignoreEmptyWindows = true, + this.dispatchOnClose = true, + this.maxLengthQueue, + }); + + @override + Stream bind(Stream stream) => forwardStream( + stream, + () => _BackpressureStreamSink( + strategy, + windowStreamFactory, + onWindowStart, + onWindowEnd, + startBufferEvery, + closeWindowWhen, + ignoreEmptyWindows, + dispatchOnClose, + maxLengthQueue, + ), + ); +} diff --git a/core/reactivex/lib/src/transformers/backpressure/buffer.dart b/core/reactivex/lib/src/transformers/backpressure/buffer.dart new file mode 100644 index 00000000..1b6044b3 --- /dev/null +++ b/core/reactivex/lib/src/transformers/backpressure/buffer.dart @@ -0,0 +1,149 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// Creates a [Stream] where each item is a [List] containing the items +/// from the source sequence. +/// +/// This [List] is emitted every time the window [Stream] +/// emits an event. +/// +/// ### Example +/// +/// Stream.periodic(const Duration(milliseconds: 100), (i) => i) +/// .buffer(Stream.periodic(const Duration(milliseconds: 160), (i) => i)) +/// .listen(print); // prints [0, 1] [2, 3] [4, 5] ... +class BufferStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [List] and + /// emits this [List] whenever [window] fires an event. + /// + /// The [List] is cleared upon every [window] event. + BufferStreamTransformer(Stream Function(T event) window) + : super(WindowStrategy.firstEventOnly, window, + onWindowEnd: (queue) => queue, ignoreEmptyWindows: false); +} + +/// Buffers a number of values from the source Stream by count then +/// emits the buffer and clears it, and starts a new buffer each +/// startBufferEvery values. If startBufferEvery is not provided, +/// then new buffers are started immediately at the start of the source +/// and when each buffer closes and is emitted. +/// +/// ### Example +/// count is the maximum size of the buffer emitted +/// +/// Rx.range(1, 4) +/// .bufferCount(2) +/// .listen(print); // prints [1, 2], [3, 4] done! +/// +/// ### Example +/// if startBufferEvery is 2, then a new buffer will be started +/// on every other value from the source. A new buffer is started at the +/// beginning of the source by default. +/// +/// Rx.range(1, 5) +/// .bufferCount(3, 2) +/// .listen(print); // prints [1, 2, 3], [3, 4, 5], [5] done! +class BufferCountStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [List] and + /// emits this [List] whenever its length is equal to [count]. + /// + /// A new buffer is created for every n-th event emitted + /// by the [Stream] that is being transformed, as specified by + /// the [startBufferEvery] parameter. + /// + /// If [startBufferEvery] is omitted or equals 0, then a new buffer is started whenever + /// the previous one reaches a length of [count]. + BufferCountStreamTransformer(int count, [int startBufferEvery = 0]) + : super(WindowStrategy.onHandler, null, + onWindowEnd: (queue) => queue, + startBufferEvery: startBufferEvery, + closeWindowWhen: (queue) => queue.length == count) { + if (count < 1) throw ArgumentError.value(count, 'count'); + if (startBufferEvery < 0) { + throw ArgumentError.value(startBufferEvery, 'startBufferEvery'); + } + } +} + +/// Creates a [Stream] where each item is a [List] containing the items +/// from the source sequence, batched whenever test passes. +/// +/// ### Example +/// +/// Stream.periodic(const Duration(milliseconds: 100), (int i) => i) +/// .bufferTest((i) => i % 2 == 0) +/// .listen(print); // prints [0], [1, 2] [3, 4] [5, 6] ... +class BufferTestStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [List] and + /// emits this [List] whenever the [test] Function yields true. + BufferTestStreamTransformer(bool Function(T value) test) + : super(WindowStrategy.onHandler, null, + onWindowEnd: (queue) => queue, + closeWindowWhen: (queue) => test(queue.last)); +} + +/// Extends the Stream class with the ability to buffer events in various ways +extension BufferExtensions on Stream { + /// Creates a Stream where each item is a [List] containing the items + /// from the source sequence. + /// + /// This [List] is emitted every time [window] emits an event. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (i) => i) + /// .buffer(Stream.periodic(Duration(milliseconds: 160), (i) => i)) + /// .listen(print); // prints [0, 1] [2, 3] [4, 5] ... + Stream> buffer(Stream window) => + BufferStreamTransformer((_) => window).bind(this); + + /// Buffers a number of values from the source Stream by [count] then + /// emits the buffer and clears it, and starts a new buffer each + /// [startBufferEvery] values. If [startBufferEvery] is not provided, + /// then new buffers are started immediately at the start of the source + /// and when each buffer closes and is emitted. + /// + /// ### Example + /// [count] is the maximum size of the buffer emitted + /// + /// RangeStream(1, 4) + /// .bufferCount(2) + /// .listen(print); // prints [1, 2], [3, 4] done! + /// + /// ### Example + /// if [startBufferEvery] is 2, then a new buffer will be started + /// on every other value from the source. A new buffer is started at the + /// beginning of the source by default. + /// + /// RangeStream(1, 5) + /// .bufferCount(3, 2) + /// .listen(print); // prints [1, 2, 3], [3, 4, 5], [5] done! + Stream> bufferCount(int count, [int startBufferEvery = 0]) => + BufferCountStreamTransformer(count, startBufferEvery).bind(this); + + /// Creates a Stream where each item is a [List] containing the items + /// from the source sequence, batched whenever test passes. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (int i) => i) + /// .bufferTest((i) => i % 2 == 0) + /// .listen(print); // prints [0], [1, 2] [3, 4] [5, 6] ... + Stream> bufferTest(bool Function(T event) onTestHandler) => + BufferTestStreamTransformer(onTestHandler).bind(this); + + /// Creates a Stream where each item is a [List] containing the items + /// from the source sequence, sampled on a time frame with [duration]. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (int i) => i) + /// .bufferTime(Duration(milliseconds: 220)) + /// .listen(print); // prints [0, 1] [2, 3] [4, 5] ... + Stream> bufferTime(Duration duration) => + buffer(Stream.periodic(duration)); +} diff --git a/core/reactivex/lib/src/transformers/backpressure/debounce.dart b/core/reactivex/lib/src/transformers/backpressure/debounce.dart new file mode 100644 index 00000000..b9e9a9e5 --- /dev/null +++ b/core/reactivex/lib/src/transformers/backpressure/debounce.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/streams/timer.dart'; +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// Transforms a [Stream] so that will only emit items from the source sequence +/// if a window has completed, without the source sequence emitting +/// another item. +/// +/// This window is created after the last debounced event was emitted. +/// You can use the value of the last debounced event to determine +/// the length of the next window. +/// +/// A window is open until the first window event emits. +/// +/// The debounce [StreamTransformer] filters out items emitted by the source +/// Stream that are rapidly followed by another emitted item. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#debounce) +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, 4]) +/// .debounceTime(Duration(seconds: 1)) +/// .listen(print); // prints 4 +class DebounceStreamTransformer extends BackpressureStreamTransformer { + /// Constructs a [StreamTransformer] which will only emit items from the source sequence + /// if a window has completed, without the source sequence emitting. + /// + /// The [window] is reset whenever the [Stream] that is being transformed + /// emits an event. + DebounceStreamTransformer(Stream Function(T event) window) + : super( + WindowStrategy.everyEvent, + window, + onWindowEnd: (queue) => queue.last, + maxLengthQueue: 1, + ); +} + +/// Extends the Stream class with the ability to debounce events in various ways +extension DebounceExtensions on Stream { + /// Transforms a [Stream] so that will only emit items from the source sequence + /// if a [window] has completed, without the source sequence emitting + /// another item. + /// + /// This [window] is created after the last debounced event was emitted. + /// You can use the value of the last debounced event to determine + /// the length of the next [window]. + /// + /// A [window] is open until the first [window] event emits. + /// + /// debounce filters out items emitted by the source [Stream] + /// that are rapidly followed by another emitted item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#debounce) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4]) + /// .debounce((_) => TimerStream(true, Duration(seconds: 1))) + /// .listen(print); // prints 4 + Stream debounce(Stream Function(T event) window) => + DebounceStreamTransformer(window).bind(this); + + /// Transforms a [Stream] so that will only emit items from the source + /// sequence whenever the time span defined by [duration] passes, without the + /// source sequence emitting another item. + /// + /// This time span start after the last debounced event was emitted. + /// + /// debounceTime filters out items emitted by the source [Stream] that are + /// rapidly followed by another emitted item. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#debounceTime) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4]) + /// .debounceTime(Duration(seconds: 1)) + /// .listen(print); // prints 4 + Stream debounceTime(Duration duration) => + DebounceStreamTransformer((_) => TimerStream(null, duration)) + .bind(this); +} diff --git a/core/reactivex/lib/src/transformers/backpressure/pairwise.dart b/core/reactivex/lib/src/transformers/backpressure/pairwise.dart new file mode 100644 index 00000000..fa2320e8 --- /dev/null +++ b/core/reactivex/lib/src/transformers/backpressure/pairwise.dart @@ -0,0 +1,35 @@ +import 'package:angel3_reactivex/src/streams/never.dart'; +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// Emits the n-th and n-1th events as a pair. +/// The first event won't be emitted until the second one arrives. +/// +/// ### Example +/// +/// Rx.range(1, 4) +/// .pairwise() +/// .listen(print); // prints [1, 2], [2, 3], [3, 4] +class PairwiseStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into pairs as a [List]. + PairwiseStreamTransformer() + : super(WindowStrategy.firstEventOnly, (_) => NeverStream(), + onWindowEnd: (queue) => queue, + startBufferEvery: 1, + closeWindowWhen: (queue) => queue.length == 2, + dispatchOnClose: false); +} + +/// Extends the Stream class with the ability to emit the nth and n-1th events +/// as a pair +extension PairwiseExtension on Stream { + /// Emits the n-th and n-1th events as a pair. + /// The first event won't be emitted until the second one arrives. + /// + /// ### Example + /// + /// RangeStream(1, 4) + /// .pairwise() + /// .listen(print); // prints [1, 2], [2, 3], [3, 4] + Stream> pairwise() => PairwiseStreamTransformer().bind(this); +} diff --git a/core/reactivex/lib/src/transformers/backpressure/sample.dart b/core/reactivex/lib/src/transformers/backpressure/sample.dart new file mode 100644 index 00000000..b2c55456 --- /dev/null +++ b/core/reactivex/lib/src/transformers/backpressure/sample.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// A [StreamTransformer] that, when the specified window [Stream] emits +/// an item or completes, emits the most recently emitted item (if any) +/// emitted by the source [Stream] since the previous emission from +/// the sample [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform(SampleStreamTransformer(TimerStream(1, const Duration(seconds: 1))) +/// .listen(print); // prints 3 +class SampleStreamTransformer extends BackpressureStreamTransformer { + /// Constructs a [StreamTransformer] that, when the specified [window] emits + /// an item or completes, emits the most recently emitted item (if any) + /// emitted by the source [Stream] since the previous emission from + /// the sample [Stream]. + SampleStreamTransformer(Stream Function(T event) window) + : super(WindowStrategy.firstEventOnly, window, + onWindowEnd: (queue) => queue.last); +} + +/// Extends the Stream class with the ability to sample events from the Stream +extension SampleExtensions on Stream { + /// Emits the most recently emitted item (if any) + /// emitted by the source [Stream] since the previous emission from + /// the [sampleStream]. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .sample(TimerStream(1, Duration(seconds: 1))) + /// .listen(print); // prints 3 + Stream sample(Stream sampleStream) => + SampleStreamTransformer((_) => sampleStream).bind(this); + + /// Emits the most recently emitted item (if any) emitted by the source + /// [Stream] since the previous emission within the recurring time span, + /// defined by [duration] + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .sampleTime(Duration(seconds: 1)) + /// .listen(print); // prints 3 + Stream sampleTime(Duration duration) => + sample(Stream.periodic(duration)); +} diff --git a/core/reactivex/lib/src/transformers/backpressure/throttle.dart b/core/reactivex/lib/src/transformers/backpressure/throttle.dart new file mode 100644 index 00000000..523c180a --- /dev/null +++ b/core/reactivex/lib/src/transformers/backpressure/throttle.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/streams/timer.dart'; +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// A [StreamTransformer] that emits a value from the source [Stream], +/// then ignores subsequent source values while the window [Stream] is open, +/// then repeats this process. +/// +/// If leading is true, then the first item in each window is emitted. +/// If trailing is true, then the last item in each window is emitted. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform(ThrottleStreamTransformer((_) => TimerStream(true, const Duration(seconds: 1)))) +/// .listen(print); // prints 1 +class ThrottleStreamTransformer extends BackpressureStreamTransformer { + /// Construct a [StreamTransformer] that emits a value from the source [Stream], + /// then ignores subsequent source values while the window [Stream] is open, + /// then repeats this process. + /// + /// If [leading] is true, then the first item in each window is emitted. + /// If [trailing] is true, then the last item in each window is emitted. + ThrottleStreamTransformer( + Stream Function(T event) window, { + bool trailing = false, + bool leading = true, + }) : super( + WindowStrategy.eventAfterLastWindow, + window, + onWindowStart: leading ? (event) => event : null, + onWindowEnd: trailing ? (queue) => queue.last : null, + dispatchOnClose: trailing, + maxLengthQueue: trailing ? 2 : 0, + ); +} + +/// Extends the Stream class with the ability to throttle events in various ways +extension ThrottleExtensions on Stream { + /// Emits a value from the source [Stream], then ignores subsequent source values + /// while the window [Stream] is open, then repeats this process. + /// + /// If leading is true, then the first item in each window is emitted. + /// If trailing is true, then the last item in each window is emitted. + /// + /// You can use the value of the last throttled event to determine the length + /// of the next [window]. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .throttle((_) => TimerStream(true, Duration(seconds: 1))); + Stream throttle(Stream Function(T event) window, + {bool trailing = false, bool leading = true}) => + ThrottleStreamTransformer( + window, + trailing: trailing, + leading: leading, + ).bind(this); + + /// Emits a value from the source [Stream], then ignores subsequent source values + /// for a duration, then repeats this process. + /// + /// If leading is true, then the first item in each window is emitted. + /// If [trailing] is true, then the last item is emitted instead. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .throttleTime(Duration(seconds: 1)); + Stream throttleTime(Duration duration, + {bool trailing = false, bool leading = true}) => + ThrottleStreamTransformer( + (_) => TimerStream(true, duration), + trailing: trailing, + leading: leading, + ).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/backpressure/window.dart b/core/reactivex/lib/src/transformers/backpressure/window.dart new file mode 100644 index 00000000..544a1b43 --- /dev/null +++ b/core/reactivex/lib/src/transformers/backpressure/window.dart @@ -0,0 +1,158 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/transformers/backpressure/backpressure.dart'; + +/// Creates a [Stream] where each item is a [Stream] containing the items +/// from the source sequence. +/// +/// This [List] is emitted every time the window [Stream] +/// emits an event. +/// +/// ### Example +/// +/// Stream.periodic(const Duration(milliseconds: 100), (i) => i) +/// .window(Stream.periodic(const Duration(milliseconds: 160), (i) => i)) +/// .asyncMap((stream) => stream.toList()) +/// .listen(print); // prints [0, 1] [2, 3] [4, 5] ... +class WindowStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [Stream] and + /// emits this [Stream] whenever [window] fires an event. + /// + /// The [Stream] is recreated and starts empty upon every [window] event. + WindowStreamTransformer(Stream Function(T event) window) + : super(WindowStrategy.firstEventOnly, window, + onWindowEnd: (queue) => Stream.fromIterable(queue), + ignoreEmptyWindows: false); +} + +/// Buffers a number of values from the source Stream by count then emits the +/// buffer as a [Stream] and clears it, and starts a new buffer each +/// startBufferEvery values. If startBufferEvery is not provided, then new +/// buffers are started immediately at the start of the source and when each +/// buffer closes and is emitted. +/// +/// ### Example +/// count is the maximum size of the buffer emitted +/// +/// Rx.range(1, 4) +/// .windowCount(2) +/// .asyncMap((stream) => stream.toList()) +/// .listen(print); // prints [1, 2], [3, 4] done! +/// +/// ### Example +/// if startBufferEvery is 2, then a new buffer will be started +/// on every other value from the source. A new buffer is started at the +/// beginning of the source by default. +/// +/// Rx.range(1, 5) +/// .bufferCount(3, 2) +/// .listen(print); // prints [1, 2, 3], [3, 4, 5], [5] done! +class WindowCountStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [Stream] and + /// emits this [Stream] whenever its length is equal to [count]. + /// + /// A new buffer is created for every n-th event emitted + /// by the [Stream] that is being transformed, as specified by + /// the [startBufferEvery] parameter. + /// + /// If [startBufferEvery] is omitted or equals 0, then a new buffer is started whenever + /// the previous one reaches a length of [count]. + WindowCountStreamTransformer(int count, [int startBufferEvery = 0]) + : super(WindowStrategy.onHandler, null, + onWindowEnd: (queue) => Stream.fromIterable(queue), + startBufferEvery: startBufferEvery, + closeWindowWhen: (queue) => queue.length == count) { + if (count < 1) throw ArgumentError.value(count, 'count'); + if (startBufferEvery < 0) { + throw ArgumentError.value(startBufferEvery, 'startBufferEvery'); + } + } +} + +/// Creates a [Stream] where each item is a [Stream] containing the items +/// from the source sequence, batched whenever test passes. +/// +/// ### Example +/// +/// Stream.periodic(const Duration(milliseconds: 100), (int i) => i) +/// .windowTest((i) => i % 2 == 0) +/// .asyncMap((stream) => stream.toList()) +/// .listen(print); // prints [0], [1, 2] [3, 4] [5, 6] ... +class WindowTestStreamTransformer + extends BackpressureStreamTransformer> { + /// Constructs a [StreamTransformer] which buffers events into a [Stream] and + /// emits this [Stream] whenever the [test] Function yields true. + WindowTestStreamTransformer(bool Function(T value) test) + : super(WindowStrategy.onHandler, null, + onWindowEnd: (queue) => Stream.fromIterable(queue), + closeWindowWhen: (queue) => test(queue.last)); +} + +/// Extends the Stream class with the ability to window +extension WindowExtensions on Stream { + /// Creates a Stream where each item is a [Stream] containing the items from + /// the source sequence. + /// + /// This [List] is emitted every time [window] emits an event. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (i) => i) + /// .window(Stream.periodic(Duration(milliseconds: 160), (i) => i)) + /// .asyncMap((stream) => stream.toList()) + /// .listen(print); // prints [0, 1] [2, 3] [4, 5] ... + Stream> window(Stream window) => + WindowStreamTransformer((_) => window).bind(this); + + /// Buffers a number of values from the source Stream by [count] then emits + /// the buffer as a [Stream] and clears it, and starts a new buffer each + /// [startBufferEvery] values. If [startBufferEvery] is not provided, then new + /// buffers are started immediately at the start of the source and when each + /// buffer closes and is emitted. + /// + /// ### Example + /// [count] is the maximum size of the buffer emitted + /// + /// RangeStream(1, 4) + /// .windowCount(2) + /// .asyncMap((stream) => stream.toList()) + /// .listen(print); // prints [1, 2], [3, 4] done! + /// + /// ### Example + /// if [startBufferEvery] is 2, then a new buffer will be started + /// on every other value from the source. A new buffer is started at the + /// beginning of the source by default. + /// + /// RangeStream(1, 5) + /// .bufferCount(3, 2) + /// .listen(print); // prints [1, 2, 3], [3, 4, 5], [5] done! + Stream> windowCount(int count, [int startBufferEvery = 0]) => + WindowCountStreamTransformer(count, startBufferEvery).bind(this); + + /// Creates a Stream where each item is a [Stream] containing the items from + /// the source sequence, batched whenever test passes. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (int i) => i) + /// .windowTest((i) => i % 2 == 0) + /// .asyncMap((stream) => stream.toList()) + /// .listen(print); // prints [0], [1, 2] [3, 4] [5, 6] ... + Stream> windowTest(bool Function(T event) onTestHandler) => + WindowTestStreamTransformer(onTestHandler).bind(this); + + /// Creates a Stream where each item is a [Stream] containing the items from + /// the source sequence, sampled on a time frame with [duration]. + /// + /// ### Example + /// + /// Stream.periodic(Duration(milliseconds: 100), (int i) => i) + /// .windowTime(Duration(milliseconds: 220)) + /// .doOnData((_) => print('next window')) + /// .flatMap((s) => s) + /// .listen(print); // prints next window 0, 1, next window 2, 3, ... + Stream> windowTime(Duration duration) => + window(Stream.periodic(duration)); +} diff --git a/core/reactivex/lib/src/transformers/default_if_empty.dart b/core/reactivex/lib/src/transformers/default_if_empty.dart new file mode 100644 index 00000000..fb9deedf --- /dev/null +++ b/core/reactivex/lib/src/transformers/default_if_empty.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +class _DefaultIfEmptyStreamSink implements EventSink { + final S _defaultValue; + final EventSink _outputSink; + bool _isEmpty = true; + + _DefaultIfEmptyStreamSink(this._outputSink, this._defaultValue); + + @override + void add(S data) { + _isEmpty = false; + _outputSink.add(data); + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() { + if (_isEmpty) { + _outputSink.add(_defaultValue); + } + + _outputSink.close(); + } +} + +/// Emit items from the source [Stream], or a single default item if the source +/// Stream emits nothing. +/// +/// ### Example +/// +/// Stream.empty() +/// .transform(DefaultIfEmptyStreamTransformer(10)) +/// .listen(print); // prints 10 +class DefaultIfEmptyStreamTransformer extends StreamTransformerBase { + /// The event that should be emitted if the source [Stream] is empty + final S defaultValue; + + /// Constructs a [StreamTransformer] which either emits from the source [Stream], + /// or just a [defaultValue] if the source [Stream] emits nothing. + DefaultIfEmptyStreamTransformer(this.defaultValue); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _DefaultIfEmptyStreamSink(sink, defaultValue)); +} + +/// +extension DefaultIfEmptyExtension on Stream { + /// Emit items from the source Stream, or a single default item if the source + /// Stream emits nothing. + /// + /// ### Example + /// + /// Stream.empty().defaultIfEmpty(10).listen(print); // prints 10 + Stream defaultIfEmpty(T defaultValue) => + DefaultIfEmptyStreamTransformer(defaultValue).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/delay.dart b/core/reactivex/lib/src/transformers/delay.dart new file mode 100644 index 00000000..f2fd2b90 --- /dev/null +++ b/core/reactivex/lib/src/transformers/delay.dart @@ -0,0 +1,98 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:angel3_reactivex/src/rx.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _DelayStreamSink extends ForwardingSink { + final Duration _duration; + var _inputClosed = false; + final _subscriptions = Queue>(); + + _DelayStreamSink(this._duration); + + @override + void onData(S data) { + final subscription = Rx.timer(null, _duration).listen((_) { + _subscriptions.removeFirst(); + + sink.add(data); + + if (_inputClosed && _subscriptions.isEmpty) { + sink.close(); + } + }); + + _subscriptions.addLast(subscription); + } + + @override + void onError(Object error, StackTrace st) => sink.addError(error, st); + + @override + void onDone() { + _inputClosed = true; + + if (_subscriptions.isEmpty) { + sink.close(); + } + } + + @override + Future? onCancel() => _subscriptions.cancelAll(); + + @override + void onListen() {} + + @override + void onPause() => _subscriptions.pauseAll(); + + @override + void onResume() => _subscriptions.resumeAll(); +} + +/// The Delay operator modifies its source Stream by pausing for +/// a particular increment of time (that you specify) before emitting +/// each of the source Streamโ€™s items. +/// This has the effect of shifting the entire sequence of items emitted +/// by the Stream forward in time by that specified increment. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#delay) +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, 4]) +/// .delay(Duration(seconds: 1)) +/// .listen(print); // [after one second delay] prints 1, 2, 3, 4 immediately +class DelayStreamTransformer extends StreamTransformerBase { + /// The delay used to pause initial emission of events by + final Duration duration; + + /// Constructs a [StreamTransformer] which will first pause for [duration] of time, + /// before submitting events from the source [Stream]. + DelayStreamTransformer(this.duration); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _DelayStreamSink(duration)); +} + +/// Extends the Stream class with the ability to delay events being emitted +extension DelayExtension on Stream { + /// The Delay operator modifies its source Stream by pausing for a particular + /// increment of time (that you specify) before emitting each of the source + /// Streamโ€™s items. This has the effect of shifting the entire sequence of + /// items emitted by the Stream forward in time by that specified increment. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#delay) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4]) + /// .delay(Duration(seconds: 1)) + /// .listen(print); // [after one second delay] prints 1, 2, 3, 4 immediately + Stream delay(Duration duration) => + DelayStreamTransformer(duration).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/delay_when.dart b/core/reactivex/lib/src/transformers/delay_when.dart new file mode 100644 index 00000000..5b80eb0d --- /dev/null +++ b/core/reactivex/lib/src/transformers/delay_when.dart @@ -0,0 +1,171 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/future.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _DelayWhenStreamSink extends ForwardingSink { + final Stream Function(T) itemDelaySelector; + final Stream? listenDelay; + + final subscriptions = >[]; + StreamSubscription? delaySubscription; + var closed = false; + + _DelayWhenStreamSink(this.itemDelaySelector, this.listenDelay); + + @override + void onData(T data) { + final subscription = + itemDelaySelector(data).take(1).listen(null, onError: sink.addError); + + subscription.onDone(() { + subscriptions.remove(subscription); + + sink.add(data); + if (subscriptions.isEmpty && closed) { + sink.close(); + } + }); + + subscriptions.add(subscription); + } + + @override + void onError(Object error, StackTrace st) => sink.addError(error, st); + + @override + void onDone() { + closed = true; + if (subscriptions.isEmpty) { + sink.close(); + } + } + + @override + Future? onCancel() { + final future = delaySubscription?.cancel(); + delaySubscription = null; + + if (subscriptions.isEmpty) { + return future; + } + + final futures = [ + for (final s in subscriptions) s.cancel(), + if (future != null) future, + ]; + subscriptions.clear(); + + return waitFuturesList(futures); + } + + @override + FutureOr onListen() { + if (listenDelay == null) { + return null; + } + + final completer = Completer.sync(); + delaySubscription = listenDelay!.take(1).listen( + null, + onError: (Object e, StackTrace s) { + delaySubscription?.cancel(); + delaySubscription = null; + completer.completeError(e, s); + }, + onDone: () { + delaySubscription?.cancel(); + delaySubscription = null; + completer.complete(null); + }, + ); + return completer.future; + } + + @override + void onPause() { + delaySubscription?.pause(); + subscriptions.pauseAll(); + } + + @override + void onResume() { + delaySubscription?.resume(); + subscriptions.resumeAll(); + } +} + +/// Delays the emission of items from the source [Stream] by a given time span +/// determined by the emissions of another [Stream]. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#delayWhen) +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform(DelayWhenStreamTransformer( +/// (i) => Rx.timer(null, Duration(seconds: i)))) +/// .listen(print); // [after 1s] prints 1 [after 1s] prints 2 [after 1s] prints 3 +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform( +/// DelayWhenStreamTransformer( +/// (i) => Rx.timer(null, Duration(seconds: i)), +/// listenDelay: Rx.timer(null, Duration(seconds: 2)), +/// ), +/// ) +/// .listen(print); // [after 3s] prints 1 [after 1s] prints 2 [after 1s] prints 3 +class DelayWhenStreamTransformer extends StreamTransformerBase { + /// A function used to determine delay time span for each data event. + final Stream Function(T value) itemDelaySelector; + + /// When [listenDelay] emits its first data or done event, the source Stream is listen to. + final Stream? listenDelay; + + /// Constructs a [StreamTransformer] which delays the emission of items + /// from the source [Stream] by a given time span determined by the emissions of another [Stream]. + DelayWhenStreamTransformer(this.itemDelaySelector, {this.listenDelay}); + + @override + Stream bind(Stream stream) => forwardStream( + stream, () => _DelayWhenStreamSink(itemDelaySelector, listenDelay)); +} + +/// Extends the Stream class with the ability to delay events being emitted. +extension DelayWhenExtension on Stream { + /// Delays the emission of items from the source [Stream] by a given time span + /// determined by the emissions of another [Stream]. + /// + /// When the source emits a data element, the `itemDelaySelector` function is called + /// with the data element as argument, and return a "duration" Stream. + /// The source element is emitted on the output Stream only when the "duration" Stream + /// emits a data or done event. + /// + /// Optionally, `delayWhen` takes a second argument `listenDelay`. When `listenDelay` + /// emits its first data or done event, the source Stream is listen to. + /// If `listenDelay` is not provided, `delayWhen` will listen to the source Stream + /// as soon as the output Stream is listen. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#delayWhen) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .delayWhen((i) => Rx.timer(null, Duration(seconds: i))) + /// .listen(print); // [after 1s] prints 1 [after 1s] prints 2 [after 1s] prints 3 + /// + /// Stream.fromIterable([1, 2, 3]) + /// .delayWhen( + /// (i) => Rx.timer(null, Duration(seconds: i)), + /// listenDelay: Rx.timer(null, Duration(seconds: 2)), + /// ) + /// .listen(print); // [after 3s] prints 1 [after 1s] prints 2 [after 1s] prints 3 + Stream delayWhen( + Stream Function(T value) itemDelaySelector, { + Stream? listenDelay, + }) => + DelayWhenStreamTransformer(itemDelaySelector, listenDelay: listenDelay) + .bind(this); +} diff --git a/core/reactivex/lib/src/transformers/dematerialize.dart b/core/reactivex/lib/src/transformers/dematerialize.dart new file mode 100644 index 00000000..685b8ceb --- /dev/null +++ b/core/reactivex/lib/src/transformers/dematerialize.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; + +class _DematerializeStreamSink implements EventSink> { + final EventSink _outputSink; + + _DematerializeStreamSink(this._outputSink); + + @override + void add(StreamNotification data) => data.when( + data: _outputSink.add, + done: _outputSink.close, + error: _outputSink.addErrorAndStackTrace, + ); + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Converts the onData, onDone, and onError [StreamNotification] objects from a +/// materialized stream into normal onData, onDone, and onError events. +/// +/// When a stream has been materialized, it emits onData, onDone, and onError +/// events as [StreamNotification] objects. Dematerialize simply reverses this by +/// transforming [StreamNotification] objects back to a normal stream of events. +/// +/// ### Example +/// +/// Stream> +/// .fromIterable([StreamNotification.data(1), StreamNotification.done()]) +/// .transform(DematerializeStreamTransformer()) +/// .listen(print); // Prints 1 +/// +/// ### Error example +/// +/// Stream> +/// .fromIterable([StreamNotification.error(Exception(), null)]) +/// .transform(DematerializeStreamTransformer()) +/// .listen(null, onError: (e, s) => print(e)); // Prints Exception +class DematerializeStreamTransformer + extends StreamTransformerBase, S> { + /// Constructs a [StreamTransformer] which converts the onData, onDone, and + /// onError [StreamNotification] objects from a materialized stream into normal + /// onData, onDone, and onError events. + DematerializeStreamTransformer(); + + @override + Stream bind(Stream> stream) => + Stream.eventTransformed(stream, (sink) => _DematerializeStreamSink(sink)); +} + +/// Converts the onData, onDone, and onError [StreamNotification]s from a +/// materialized stream into normal onData, onDone, and onError events. +extension DematerializeExtension on Stream> { + /// Converts the onData, onDone, and onError [StreamNotification] objects from a + /// materialized stream into normal onData, onDone, and onError events. + /// + /// When a stream has been materialized, it emits onData, onDone, and onError + /// events as [StreamNotification] objects. Dematerialize simply reverses this by + /// transforming [StreamNotification] objects back to a normal stream of events. + /// + /// ### Example + /// + /// Stream> + /// .fromIterable([StreamNotification.data(1), StreamNotification.done()]) + /// .dematerialize() + /// .listen(print); // Prints 1 + /// + /// ### Error example + /// + /// Stream> + /// .fromIterable([StreamNotification.error(Exception(), null)]) + /// .dematerialize() + /// .listen(null, onError: (e, s) => print(e)); // Prints Exception + Stream dematerialize() => DematerializeStreamTransformer().bind(this); +} diff --git a/core/reactivex/lib/src/transformers/distinct_unique.dart b/core/reactivex/lib/src/transformers/distinct_unique.dart new file mode 100644 index 00000000..f3785df3 --- /dev/null +++ b/core/reactivex/lib/src/transformers/distinct_unique.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'dart:collection'; + +class _DistinctUniqueStreamSink implements EventSink { + final EventSink _outputSink; + final HashSet _collection; + + _DistinctUniqueStreamSink(this._outputSink, + {bool Function(S e1, S e2)? equals, int Function(S e)? hashCodeMethod}) + : _collection = HashSet(equals: equals, hashCode: hashCodeMethod); + + @override + void add(S data) { + if (_collection.add(data)) { + _outputSink.add(data); + } + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() { + _collection.clear(); + _outputSink.close(); + } +} + +/// Create a [Stream] which implements a [HashSet] under the hood, using +/// the provided `equals` as equality. +/// +/// The [Stream] will only emit an event, if that event is not yet found +/// within the underlying [HashSet]. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 1, 2, 1, 2, 3, 2, 1]) +/// .listen((event) => print(event)); +/// +/// will emit: +/// 1, 2, 3 +/// +/// The provided `equals` must define a stable equivalence relation, and +/// `hashCode` must be consistent with `equals`. +/// +/// If `equals` or `hashCode` are omitted, the set uses the elements' intrinsic +/// `Object.==` and `Object.hashCode`. If you supply one of `equals` and +/// `hashCode`, you should generally also to supply the other. +class DistinctUniqueStreamTransformer extends StreamTransformerBase { + /// Optional method which determines equality between two events + final bool Function(S e1, S e2)? equals; + + /// Optional method which is used to create a hash from an event + final int Function(S e)? hashCodeMethod; + + /// Constructs a [StreamTransformer] which emits events from the source + /// [Stream] as if they were processed through a [HashSet]. + /// + /// See [HashSet] for a more detailed explanation. + DistinctUniqueStreamTransformer({this.equals, this.hashCodeMethod}); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, + (sink) => _DistinctUniqueStreamSink(sink, + equals: equals, hashCodeMethod: hashCodeMethod)); +} + +/// Extends the Stream class with the ability to skip items that have previously +/// been emitted. +extension DistinctUniqueExtension on Stream { + /// WARNING: More commonly known as distinct in other Rx implementations. + /// Creates a Stream where data events are skipped if they have already + /// been emitted before. + /// + /// Equality is determined by the provided equals and hashCode methods. + /// If these are omitted, the '==' operator and hashCode on the last provided + /// data element are used. + /// + /// The returned stream is a broadcast stream if this stream is. If a + /// broadcast stream is listened to more than once, each subscription will + /// individually perform the equals and hashCode tests. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#distinct) + Stream distinctUnique({ + bool Function(T e1, T e2)? equals, + int Function(T e)? hashCode, + }) => + DistinctUniqueStreamTransformer( + equals: equals, hashCodeMethod: hashCode) + .bind(this); +} diff --git a/core/reactivex/lib/src/transformers/do.dart b/core/reactivex/lib/src/transformers/do.dart new file mode 100644 index 00000000..637505e3 --- /dev/null +++ b/core/reactivex/lib/src/transformers/do.dart @@ -0,0 +1,305 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/notification.dart'; + +class _DoStreamSink extends ForwardingSink { + final FutureOr Function()? _onCancel; + final void Function(S event)? _onData; + final void Function()? _onDone; + final void Function(StreamNotification notification)? _onEach; + final void Function(Object, StackTrace)? _onError; + final void Function()? _onListen; + final void Function()? _onPause; + final void Function()? _onResume; + + _DoStreamSink( + this._onCancel, + this._onData, + this._onDone, + this._onEach, + this._onError, + this._onListen, + this._onPause, + this._onResume, + ); + + @override + void onData(S data) { + try { + _onData?.call(data); + } catch (e, s) { + sink.addError(e, s); + } + try { + _onEach?.call(StreamNotification.data(data)); + } catch (e, s) { + sink.addError(e, s); + } + sink.add(data); + } + + @override + void onError(Object e, StackTrace st) { + try { + _onError?.call(e, st); + } catch (e, s) { + sink.addError(e, s); + } + try { + _onEach?.call(StreamNotification.error(e, st)); + } catch (e, s) { + sink.addError(e, s); + } + sink.addError(e, st); + } + + @override + void onDone() { + try { + _onDone?.call(); + } catch (e, s) { + sink.addError(e, s); + } + try { + _onEach?.call(StreamNotification.done()); + } catch (e, s) { + sink.addError(e, s); + } + sink.close(); + } + + @override + FutureOr onCancel() => _onCancel?.call(); + + @override + void onListen() { + try { + _onListen?.call(); + } catch (e, s) { + sink.addError(e, s); + } + } + + @override + void onPause() { + try { + _onPause?.call(); + } catch (e, s) { + sink.addError(e, s); + } + } + + @override + void onResume() { + try { + _onResume?.call(); + } catch (e, s) { + sink.addError(e, s); + } + } +} + +/// Invokes the given callback at the corresponding point the the stream +/// lifecycle. For example, if you pass in an onDone callback, it will +/// be invoked when the stream finishes emitting items. +/// +/// This transformer can be used for debugging, logging, etc. by intercepting +/// the stream at different points to run arbitrary actions. +/// +/// It is possible to hook onto the following parts of the stream lifecycle: +/// +/// - onCancel +/// - onData +/// - onDone +/// - onError +/// - onListen +/// - onPause +/// - onResume +/// +/// In addition, the `onEach` argument is called at `onData`, `onDone`, and +/// `onError` with a [StreamNotification] passed in. The [StreamNotification] argument +/// contains the [NotificationKind] of event (OnData, OnDone, OnError), and the item or +/// error that was emitted. In the case of onDone, no data is emitted as part +/// of the [StreamNotification]. +/// +/// If no callbacks are passed in, a runtime error will be thrown in dev mode +/// in order to 'fail fast' and alert the developer that the transformer should +/// be used or safely removed. +/// +/// ### Example +/// +/// Stream.fromIterable([1]) +/// .transform(DoStreamTransformer( +/// onData: print, +/// onError: (e, s) => print('Oh no!'), +/// onDone: () => print('Done'))) +/// .listen(null); // Prints: 1, 'Done' +class DoStreamTransformer extends StreamTransformerBase { + /// fires when all subscriptions have cancelled. + final FutureOr Function()? onCancel; + + /// fires when data is emitted + final void Function(S event)? onData; + + /// fires on close + final void Function()? onDone; + + /// fires on data, close and error + final void Function(StreamNotification notification)? onEach; + + /// fires on errors + final void Function(Object, StackTrace)? onError; + + /// fires when a subscription first starts + final void Function()? onListen; + + /// fires when the subscription pauses + final void Function()? onPause; + + /// fires when the subscription resumes + final void Function()? onResume; + + /// Constructs a [StreamTransformer] which will trigger any of the provided + /// handlers as they occur. + DoStreamTransformer( + {this.onCancel, + this.onData, + this.onDone, + this.onEach, + this.onError, + this.onListen, + this.onPause, + this.onResume}) { + if (onCancel == null && + onData == null && + onDone == null && + onEach == null && + onError == null && + onListen == null && + onPause == null && + onResume == null) { + throw ArgumentError('Must provide at least one handler'); + } + } + + @override + Stream bind(Stream stream) => forwardStream( + stream, + () => _DoStreamSink( + onCancel, + onData, + onDone, + onEach, + onError, + onListen, + onPause, + onResume, + ), + true, + ); +} + +/// Extends the Stream class with the ability to execute a callback function +/// at different points in the Stream's lifecycle. +extension DoExtensions on Stream { + /// Invokes the given callback function when the stream subscription is + /// cancelled. Often called doOnUnsubscribe or doOnDispose in other + /// implementations. + /// + /// ### Example + /// + /// final subscription = TimerStream(1, Duration(minutes: 1)) + /// .doOnCancel(() => print('hi')) + /// .listen(null); + /// + /// subscription.cancel(); // prints 'hi' + Stream doOnCancel(FutureOr Function() onCancel) => + DoStreamTransformer(onCancel: onCancel).bind(this); + + /// Invokes the given callback function when the stream emits an item. In + /// other implementations, this is called doOnNext. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .doOnData(print) + /// .listen(null); // prints 1, 2, 3 + Stream doOnData(void Function(T event) onData) => + DoStreamTransformer(onData: onData).bind(this); + + /// Invokes the given callback function when the stream finishes emitting + /// items. In other implementations, this is called doOnComplete(d). + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .doOnDone(() => print('all set')) + /// .listen(null); // prints 'all set' + Stream doOnDone(void Function() onDone) => + DoStreamTransformer(onDone: onDone).bind(this); + + /// Invokes the given callback function when the stream emits data, emits + /// an error, or emits done. The callback receives a [StreamNotification] object. + /// + /// The [StreamNotification] object contains the [NotificationKind] of event (OnData, onDone, + /// or OnError), and the item or error that was emitted. In the case of + /// onDone, no data is emitted as part of the [StreamNotification]. + /// + /// ### Example + /// + /// Stream.fromIterable([1]) + /// .doOnEach(print) + /// .listen(null); // Prints DataNotification{value: 1}, DoneNotification{} + Stream doOnEach( + void Function(StreamNotification notification) onEach) => + DoStreamTransformer(onEach: onEach).bind(this); + + /// Invokes the given callback function when the stream emits an error. + /// + /// ### Example + /// + /// Stream.error(Exception()) + /// .doOnError((error, stacktrace) => print('oh no')) + /// .listen(null); // prints 'Oh no' + Stream doOnError(void Function(Object, StackTrace) onError) => + DoStreamTransformer(onError: onError).bind(this); + + /// Invokes the given callback function when the stream is first listened to. + /// + /// ### Example + /// + /// Stream.fromIterable([1]) + /// .doOnListen(() => print('Is someone there?')) + /// .listen(null); // prints 'Is someone there?' + Stream doOnListen(void Function() onListen) => + DoStreamTransformer(onListen: onListen).bind(this); + + /// Invokes the given callback function when the stream subscription is + /// paused. + /// + /// ### Example + /// + /// final subscription = Stream.fromIterable([1]) + /// .doOnPause(() => print('Gimme a minute please')) + /// .listen(null); + /// + /// subscription.pause(); // prints 'Gimme a minute please' + Stream doOnPause(void Function() onPause) => + DoStreamTransformer(onPause: onPause).bind(this); + + /// Invokes the given callback function when the stream subscription + /// resumes receiving items. + /// + /// ### Example + /// + /// final subscription = Stream.fromIterable([1]) + /// .doOnResume(() => print('Let's do this!')) + /// .listen(null); + /// + /// subscription.pause(); + /// subscription.resume(); 'Let's do this!' + Stream doOnResume(void Function() onResume) => + DoStreamTransformer(onResume: onResume).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/end_with.dart b/core/reactivex/lib/src/transformers/end_with.dart new file mode 100644 index 00000000..c4f99eec --- /dev/null +++ b/core/reactivex/lib/src/transformers/end_with.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +class _EndWithStreamSink implements EventSink { + final S _endValue; + final EventSink _outputSink; + + _EndWithStreamSink(this._outputSink, this._endValue); + + @override + void add(S data) => _outputSink.add(data); + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() { + _outputSink.add(_endValue); + _outputSink.close(); + } +} + +/// Appends a value to the source [Stream] before closing. +/// +/// ### Example +/// +/// Stream.fromIterable([2]) +/// .transform(EndWithStreamTransformer(1)) +/// .listen(print); // prints 2, 1 +class EndWithStreamTransformer extends StreamTransformerBase { + /// The ending event of this [Stream] + final S endValue; + + /// Constructs a [StreamTransformer] which appends the source [Stream] + /// with [endValue] just before it closes. + EndWithStreamTransformer(this.endValue); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _EndWithStreamSink(sink, endValue)); +} + +/// Extends the [Stream] class with the ability to emit the given value as the +/// final item before closing. +extension EndWithExtension on Stream { + /// Appends a value to the source [Stream] before closing. + /// + /// ### Example + /// + /// Stream.fromIterable([2]).endWith(1).listen(print); // prints 2, 1 + Stream endWith(T endValue) => + EndWithStreamTransformer(endValue).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/end_with_many.dart b/core/reactivex/lib/src/transformers/end_with_many.dart new file mode 100644 index 00000000..050e4568 --- /dev/null +++ b/core/reactivex/lib/src/transformers/end_with_many.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +class _EndWithManyStreamSink implements EventSink { + final Iterable _endValues; + final EventSink _outputSink; + + _EndWithManyStreamSink(this._outputSink, this._endValues); + + @override + void add(S data) => _outputSink.add(data); + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() { + _endValues.forEach(_outputSink.add); + _outputSink.close(); + } +} + +/// Appends a sequence of values to the source [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([3]) +/// .transform(EndWithManyStreamTransformer([1, 2])) +/// .listen(print); // prints 3, 1, 2 +class EndWithManyStreamTransformer extends StreamTransformerBase { + /// The ending events of this [Stream] + final Iterable endValues; + + /// Constructs a [StreamTransformer] which appends the source [Stream] + /// with [endValues] before closing. + EndWithManyStreamTransformer(this.endValues); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _EndWithManyStreamSink(sink, endValues)); +} + +/// Extends the Stream class with the ability to emit the given value as the +/// final item before closing. +extension EndWithManyExtension on Stream { + /// Appends a sequence of values as final events to the source [Stream] before closing. + /// + /// ### Example + /// + /// Stream.fromIterable([2]).endWithMany([1, 0]).listen(print); // prints 2, 1, 0 + Stream endWithMany(Iterable endValues) => + EndWithManyStreamTransformer(endValues).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/exhaust_map.dart b/core/reactivex/lib/src/transformers/exhaust_map.dart new file mode 100644 index 00000000..d9c9bacf --- /dev/null +++ b/core/reactivex/lib/src/transformers/exhaust_map.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _ExhaustMapStreamSink extends ForwardingSink { + final Stream Function(S value) _mapper; + StreamSubscription? _mapperSubscription; + bool _inputClosed = false; + + _ExhaustMapStreamSink(this._mapper); + + @override + void onData(S data) { + if (_mapperSubscription != null) { + return; + } + + final Stream mappedStream; + try { + mappedStream = _mapper(data); + } catch (e, s) { + sink.addError(e, s); + return; + } + + _mapperSubscription = mappedStream.listen( + sink.add, + onError: sink.addError, + onDone: () { + _mapperSubscription = null; + + if (_inputClosed) { + sink.close(); + } + }, + ); + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _inputClosed = true; + + _mapperSubscription ?? sink.close(); + } + + @override + FutureOr onCancel() => _mapperSubscription?.cancel(); + + @override + void onListen() {} + + @override + void onPause() => _mapperSubscription?.pause(); + + @override + void onResume() => _mapperSubscription?.resume(); +} + +/// Converts events from the source stream into a new Stream using a given +/// mapper. It ignores all items from the source stream until the new stream +/// completes. +/// +/// Useful when you have a noisy source Stream and only want to respond once +/// the previous async operation is finished. +/// +/// ### Example +/// // Emits 0, 1, 2 +/// Stream.periodic(Duration(milliseconds: 200), (i) => i).take(3) +/// .transform(ExhaustMapStreamTransformer( +/// // Emits the value it's given after 200ms +/// (i) => Rx.timer(i, Duration(milliseconds: 200)), +/// )) +/// .listen(print); // prints 0, 2 +class ExhaustMapStreamTransformer extends StreamTransformerBase { + /// Method which converts incoming events into a new [Stream] + final Stream Function(S value) mapper; + + /// Constructs a [StreamTransformer] which maps each event from the source [Stream] + /// using [mapper]. + /// + /// The mapped [Stream] will be be listened to and begin + /// emitting items, and any previously created mapper [Stream]s will stop emitting. + ExhaustMapStreamTransformer(this.mapper); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _ExhaustMapStreamSink(mapper)); +} + +/// Extends the Stream class with the ability to transform the Stream into +/// a new Stream. The new Stream emits items and ignores events from the source +/// Stream until the new Stream completes. +extension ExhaustMapExtension on Stream { + /// Converts items from the source stream into a Stream using a given + /// mapper. It ignores all items from the source stream until the new stream + /// completes. + /// + /// Useful when you have a noisy source Stream and only want to respond once + /// the previous async operation is finished. + /// + /// ### Example + /// + /// RangeStream(0, 2).interval(Duration(milliseconds: 50)) + /// .exhaustMap((i) => + /// TimerStream(i, Duration(milliseconds: 75))) + /// .listen(print); // prints 0, 2 + Stream exhaustMap(Stream Function(T value) mapper) => + ExhaustMapStreamTransformer(mapper).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/flat_map.dart b/core/reactivex/lib/src/transformers/flat_map.dart new file mode 100644 index 00000000..c5dff2a3 --- /dev/null +++ b/core/reactivex/lib/src/transformers/flat_map.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _FlatMapStreamSink extends ForwardingSink { + final Stream Function(S value) _mapper; + final int? maxConcurrent; + + final List> _subscriptions = >[]; + final Queue queue = DoubleLinkedQueue(); + bool _inputClosed = false; + + _FlatMapStreamSink(this._mapper, this.maxConcurrent); + + @override + void onData(S data) { + if (maxConcurrent != null && _subscriptions.length >= maxConcurrent!) { + queue.addLast(data); + } else { + listenInner(data); + } + } + + void listenInner(S data) { + final Stream mappedStream; + try { + mappedStream = _mapper(data); + } catch (e, s) { + sink.addError(e, s); + return; + } + + final subscription = mappedStream.listen(sink.add, onError: sink.addError); + subscription.onDone(() { + _subscriptions.remove(subscription); + + if (queue.isNotEmpty) { + listenInner(queue.removeFirst()); + } else if (_inputClosed && _subscriptions.isEmpty) { + sink.close(); + } + }); + _subscriptions.add(subscription); + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _inputClosed = true; + + if (_subscriptions.isEmpty) { + sink.close(); + } + } + + @override + Future? onCancel() { + queue.clear(); + return _subscriptions.cancelAll(); + } + + @override + void onListen() {} + + @override + void onPause() => _subscriptions.pauseAll(); + + @override + void onResume() => _subscriptions.resumeAll(); +} + +/// Converts each emitted item into a new Stream using the given mapper function, +/// while limiting the maximum number of concurrent subscriptions to these [Stream]s. +/// The newly created Stream will be listened to and begin emitting items downstream. +/// +/// The items emitted by each of the new Streams are emitted downstream in the +/// same order they arrive. In other words, the sequences are merged +/// together. +/// +/// ### Example +/// +/// Stream.fromIterable([4, 3, 2, 1]) +/// .transform(FlatMapStreamTransformer((i) => +/// Stream.fromFuture( +/// Future.delayed(Duration(minutes: i), () => i)) +/// .listen(print); // prints 1, 2, 3, 4 +class FlatMapStreamTransformer extends StreamTransformerBase { + /// Method which converts incoming events into a new [Stream] + final Stream Function(S value) mapper; + + /// Maximum number of inner [Stream] that may be listened to concurrently. + /// If it's `null`, it means unlimited. + final int? maxConcurrent; + + /// Constructs a [StreamTransformer] which emits events from the source [Stream] using the given [mapper]. + /// The mapped [Stream] will be listened to and begin emitting items downstream. + FlatMapStreamTransformer(this.mapper, {this.maxConcurrent}); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _FlatMapStreamSink(mapper, maxConcurrent)); +} + +/// Extends the Stream class with the ability to convert the source Stream into +/// a new Stream each time the source emits an item. +extension FlatMapExtension on Stream { + /// Converts each emitted item into a Stream using the given mapper function, + /// while limiting the maximum number of concurrent subscriptions to these [Stream]s. + /// The newly created Stream will be be listened to and begin emitting items downstream. + /// + /// The items emitted by each of the Streams are emitted downstream in the + /// same order they arrive. In other words, the sequences are merged + /// together. + /// + /// ### Example + /// + /// RangeStream(4, 1) + /// .flatMap((i) => TimerStream(i, Duration(minutes: i))) + /// .listen(print); // prints 1, 2, 3, 4 + Stream flatMap(Stream Function(T value) mapper, + {int? maxConcurrent}) => + FlatMapStreamTransformer(mapper, maxConcurrent: maxConcurrent) + .bind(this); + + /// Converts each item into a Stream. The Stream must return an + /// Iterable. Then, each item from the Iterable will be emitted one by one. + /// + /// Use case: you may have an API that returns a list of items, such as + /// a Stream>. However, you might want to operate on the individual items + /// rather than the list itself. This is the job of `flatMapIterable`. + /// + /// ### Example + /// + /// RangeStream(1, 4) + /// .flatMapIterable((i) => Stream.fromIterable([[i]])) + /// .listen(print); // prints 1, 2, 3, 4 + Stream flatMapIterable(Stream> Function(T value) mapper, + {int? maxConcurrent}) => + FlatMapStreamTransformer>(mapper, + maxConcurrent: maxConcurrent) + .bind(this) + .expand((Iterable iterable) => iterable); +} diff --git a/core/reactivex/lib/src/transformers/group_by.dart b/core/reactivex/lib/src/transformers/group_by.dart new file mode 100644 index 00000000..e2d2edbc --- /dev/null +++ b/core/reactivex/lib/src/transformers/group_by.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/future.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _GroupByStreamSink extends ForwardingSink> { + final K Function(T event) grouper; + final Stream Function(GroupedStream)? duration; + + final groups = >{}; + Map>? subscriptions; + + _GroupByStreamSink(this.grouper, this.duration); + + void _closeAll() { + for (var c in groups.values) { + c.close(); + } + groups.clear(); + } + + StreamController _controllerBuilder(K key) { + final groupedController = StreamController.broadcast(sync: true); + final groupByStream = GroupedStream(key, groupedController.stream); + + if (duration != null) { + subscriptions?.remove(key)?.cancel(); + (subscriptions ??= {})[key] = duration!(groupByStream).take(1).listen( + null, + onDone: () { + subscriptions!.remove(key); + groups.remove(key)?.close(); + }, + onError: onError, + ); + } + + sink.add(groupByStream); + return groupedController; + } + + @override + void onData(T data) { + final K key; + try { + key = grouper(data); + } catch (e, s) { + sink.addError(e, s); + return; + } + + groups.putIfAbsent(key, () => _controllerBuilder(key)).add(data); + } + + @override + void onError(e, st) => sink.addError(e, st); + + @override + void onDone() { + _closeAll(); + sink.close(); + } + + @override + Future? onCancel() { + scheduleMicrotask(_closeAll); + + if (subscriptions?.isNotEmpty == true) { + final future = waitFuturesList([ + for (final s in subscriptions!.values) s.cancel(), + ]); + subscriptions?.clear(); + subscriptions = null; + return future; + } + return null; + } + + @override + FutureOr onListen() {} + + @override + void onPause() => subscriptions?.values.pauseAll(); + + @override + void onResume() => subscriptions?.values.resumeAll(); +} + +/// The GroupBy operator divides a [Stream] that emits items into +/// a [Stream] that emits [GroupedStream], +/// each one of which emits some subset of the items +/// from the original source [Stream]. +/// +/// [GroupedStream] acts like a regular [Stream], yet +/// adding a 'key' property, which receives its [Type] and value from +/// the [_grouper] Function. +/// +/// All items with the same key are emitted by the same [GroupedStream]. +class GroupByStreamTransformer + extends StreamTransformerBase> { + /// Method which converts incoming events into a new [GroupedStream] + final K Function(T event) grouper; + + /// A function that returns an [Stream] to determine how long each group should exist. + /// When the returned [Stream] emits its first data or done event, + /// the group will be closed and removed. + final Stream Function(GroupedStream grouped)? durationSelector; + + /// Constructs a [StreamTransformer] which groups events from the source + /// [Stream] and emits them as [GroupedStream]. + GroupByStreamTransformer(this.grouper, {this.durationSelector}); + + @override + Stream> bind(Stream stream) => forwardStream( + stream, () => _GroupByStreamSink(grouper, durationSelector)); +} + +/// The [Stream] used by [GroupByStreamTransformer], it contains events +/// that are grouped by a key value. +class GroupedStream extends StreamView { + /// The key is the category to which all events in this group belong to. + final K key; + + /// Constructs a [Stream] which only emits events that can be + /// categorized under [key]. + GroupedStream(this.key, Stream stream) : super(stream); + + @override + String toString() => 'GroupedStream{key: $key}'; +} + +/// Extends the Stream class with the ability to convert events into Streams +/// of events that are united by a key. +extension GroupByExtension on Stream { + /// The GroupBy operator divides a [Stream] that emits items into a [Stream] + /// that emits [GroupedStream], each one of which emits some subset of the + /// items from the original source [Stream]. + /// + /// [GroupedStream] acts like a regular [Stream], yet adding a 'key' property, + /// which receives its [Type] and value from the [grouper] Function. + /// + /// All items with the same key are emitted by the same [GroupedStream]. + /// + /// Optionally, `groupBy` takes a second argument [durationSelector]. + /// [durationSelector] is a function that returns an [Stream] to determine how long + /// each group should exist. When the returned [Stream] emits its first data or done event, + /// the group will be closed and removed. + Stream> groupBy( + K Function(T value) grouper, { + Stream Function(GroupedStream grouped)? durationSelector, + }) => + GroupByStreamTransformer(grouper, + durationSelector: durationSelector) + .bind(this); +} diff --git a/core/reactivex/lib/src/transformers/ignore_elements.dart b/core/reactivex/lib/src/transformers/ignore_elements.dart new file mode 100644 index 00000000..a2f372bd --- /dev/null +++ b/core/reactivex/lib/src/transformers/ignore_elements.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +class _IgnoreElementsStreamSink implements EventSink { + final EventSink _outputSink; + + _IgnoreElementsStreamSink(this._outputSink); + + @override + void add(S data) {} + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Creates a [Stream] where all emitted items are ignored, only the +/// error / completed notifications are passed +/// +/// [ReactiveX doc](http://reactivex.io/documentation/operators/ignoreelements.html) +/// [Interactive marble diagram](https://rxmarbles.com/#ignoreElements) +/// +/// ### Example +/// +/// MergeStream([ +/// Stream.fromIterable([1]), +/// ErrorStream(Exception()) +/// ]) +/// .transform(IgnoreElementsStreamTransformer()) +/// .listen(print, onError: print); // prints Exception +class IgnoreElementsStreamTransformer + extends StreamTransformerBase { + /// Constructs a [StreamTransformer] which simply ignores all events from + /// the source [Stream], except for error or completed events. + IgnoreElementsStreamTransformer(); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _IgnoreElementsStreamSink(sink)); +} + +/// Extends the Stream class with the ability to skip, or ignore, data events. +extension IgnoreElementsExtension on Stream { + /// Creates a Stream where all emitted items are ignored, only the error / + /// completed notifications are passed + /// + /// [ReactiveX doc](http://reactivex.io/documentation/operators/ignoreelements.html) + /// [Interactive marble diagram](https://rxmarbles.com/#ignoreElements) + /// + /// ### Example + /// + /// MergeStream([ + /// Stream.fromIterable([1]), + /// Stream.error(Exception()) + /// ]) + /// .ignoreElements() + /// .listen(print, onError: print); // prints Exception + Stream ignoreElements() => + IgnoreElementsStreamTransformer().bind(this); +} diff --git a/core/reactivex/lib/src/transformers/interval.dart b/core/reactivex/lib/src/transformers/interval.dart new file mode 100644 index 00000000..43667602 --- /dev/null +++ b/core/reactivex/lib/src/transformers/interval.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:collection'; + +class _IntervalStreamSink implements EventSink { + final Duration _duration; + final EventSink _outputSink; + final _queue = Queue(); + var _inputClosed = false; + var _openIntervals = 0; + + bool get noOpenIntervals => _openIntervals == 0; + + _IntervalStreamSink(this._outputSink, this._duration); + + @override + void add(S data) { + _queue.add(data); + + if (noOpenIntervals) { + _addNext(); + } + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() { + _inputClosed = true; + + if (noOpenIntervals) { + _outputSink.close(); + } + } + + void _addNext() { + if (_queue.isNotEmpty) { + _addDelayed(_queue.removeFirst()).whenComplete(_addNext); + } + } + + Future _addDelayed(S data) { + _openIntervals++; + + return Future.delayed(_duration, () => data) + .then(_outputSink.add) + .whenComplete(() { + _openIntervals--; + + if (_inputClosed && _queue.isEmpty) { + _outputSink.close(); + } + }); + } +} + +/// Creates a Stream that emits each item in the Stream after a given +/// duration. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform(IntervalStreamTransformer(Duration(seconds: 1))) +/// .listen((i) => print('$i sec')); // prints 1 sec, 2 sec, 3 sec +class IntervalStreamTransformer extends StreamTransformerBase { + /// The interval after which incoming events need to be emitted. + final Duration duration; + + /// Constructs a [StreamTransformer] which emits each item from the source [Stream], + /// after a given duration. + IntervalStreamTransformer(this.duration); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _IntervalStreamSink(sink, duration)); +} + +/// Extends the Stream class with the ability to emit each item after a given +/// duration. +extension IntervalExtension on Stream { + /// Creates a Stream that emits each item in the Stream after a given + /// duration. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .interval(Duration(seconds: 1)) + /// .listen((i) => print('$i sec'); // prints 1 sec, 2 sec, 3 sec + Stream interval(Duration duration) => + IntervalStreamTransformer(duration).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/map_not_null.dart b/core/reactivex/lib/src/transformers/map_not_null.dart new file mode 100644 index 00000000..724d6ba1 --- /dev/null +++ b/core/reactivex/lib/src/transformers/map_not_null.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +class _MapNotNullSink implements EventSink { + final R? Function(T) _transform; + final EventSink _outputSink; + + _MapNotNullSink(this._outputSink, this._transform); + + @override + void add(T event) { + final value = _transform(event); + if (value != null) { + _outputSink.add(value); + } + } + + @override + void addError(Object error, [StackTrace? stackTrace]) => + _outputSink.addError(error, stackTrace); + + @override + void close() => _outputSink.close(); +} + +/// Create a Stream containing only the non-`null` results +/// of applying the given [transform] function to each element of the Stream. +/// +/// ### Example +/// +/// Stream.fromIterable(['1', 'two', '3', 'four']) +/// .transform(MapNotNullStreamTransformer(int.tryParse)) +/// .listen(print); // prints 1, 3 +/// +/// // equivalent to: +/// +/// Stream.fromIterable(['1', 'two', '3', 'four']) +/// .map(int.tryParse) +/// .transform(WhereTypeStreamTransformer()) +/// .listen(print); // prints 1, 3 +class MapNotNullStreamTransformer + extends StreamTransformerBase { + /// A function that transforms each elements of the Stream. + final R? Function(T) transform; + + /// Constructs a [StreamTransformer] which emits non-`null` elements + /// of applying the given [transform] function to each element of the Stream. + const MapNotNullStreamTransformer(this.transform); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _MapNotNullSink(sink, transform)); +} + +/// Extends the Stream class with the ability to convert the source Stream +/// to a Stream containing only the non-`null` results +/// of applying the given [transform] function to each element of this Stream. +extension MapNotNullExtension on Stream { + /// Returns a Stream containing only the non-`null` results + /// of applying the given [transform] function to each element of this Stream. + /// + /// ### Example + /// + /// Stream.fromIterable(['1', 'two', '3', 'four']) + /// .mapNotNull(int.tryParse) + /// .listen(print); // prints 1, 3 + /// + /// // equivalent to: + /// + /// Stream.fromIterable(['1', 'two', '3', 'four']) + /// .map(int.tryParse) + /// .whereType() + /// .listen(print); // prints 1, 3 + Stream mapNotNull(R? Function(T) transform) => + MapNotNullStreamTransformer(transform).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/map_to.dart b/core/reactivex/lib/src/transformers/map_to.dart new file mode 100644 index 00000000..d58d74ad --- /dev/null +++ b/core/reactivex/lib/src/transformers/map_to.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +class _MapToStreamSink implements EventSink { + final T _value; + final EventSink _outputSink; + + _MapToStreamSink(this._outputSink, this._value); + + @override + void add(S data) => _outputSink.add(_value); + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Emits the given constant value on the output Stream every time the source +/// Stream emits a value. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, 4]) +/// .mapTo(true) +/// .listen(print); // prints true, true, true, true +class MapToStreamTransformer extends StreamTransformerBase { + /// A constant [value] which will always be returned when using this transformer. + final T value; + + /// Constructs a [StreamTransformer] which always maps every event from + /// the source [Stream] to a constant [value]. + MapToStreamTransformer(this.value); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _MapToStreamSink(sink, value)); +} + +/// Extends the Stream class with the ability to convert each item to the same +/// value. +extension MapToExtension on Stream { + /// Emits the given constant value on the output Stream every time the source + /// Stream emits a value. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4]) + /// .mapTo(true) + /// .listen(print); // prints true, true, true, true + Stream mapTo(T value) => MapToStreamTransformer(value).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/materialize.dart b/core/reactivex/lib/src/transformers/materialize.dart new file mode 100644 index 00000000..e0f3d562 --- /dev/null +++ b/core/reactivex/lib/src/transformers/materialize.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/notification.dart'; + +class _MaterializeStreamSink implements EventSink { + final EventSink> _outputSink; + + _MaterializeStreamSink(this._outputSink); + + @override + void add(S data) => _outputSink.add(StreamNotification.data(data)); + + @override + void addError(e, [st]) => _outputSink.add(StreamNotification.error(e, st)); + + @override + void close() { + _outputSink.add(StreamNotification.done()); + _outputSink.close(); + } +} + +/// Converts the onData, on Done, and onError events into [StreamNotification] +/// objects that are passed into the downstream onData listener. +/// +/// The [StreamNotification] object contains the [NotificationKind] of event (OnData, onDone, or +/// OnError), and the item or error that was emitted. In the case of onDone, +/// no data is emitted as part of the [StreamNotification]. +/// +/// ### Example +/// +/// Stream.fromIterable([1]) +/// .transform(MaterializeStreamTransformer()) +/// .listen(print); // Prints DataNotification{value: 1}, DoneNotification{} +class MaterializeStreamTransformer + extends StreamTransformerBase> { + /// Constructs a [StreamTransformer] which transforms the onData, on Done, + /// and onError events into [StreamNotification] objects. + MaterializeStreamTransformer(); + + @override + Stream> bind(Stream stream) => + Stream.eventTransformed( + stream, (sink) => _MaterializeStreamSink(sink)); +} + +/// Extends the Stream class with the ability to convert the onData, on Done, +/// and onError events into [StreamNotification]s that are passed into the +/// downstream onData listener. +extension MaterializeExtension on Stream { + /// Converts the onData, on Done, and onError events into [StreamNotification] + /// objects that are passed into the downstream onData listener. + /// + /// The [StreamNotification] object contains the [NotificationKind] of event (OnData, onDone, or + /// OnError), and the item or error that was emitted. In the case of onDone, + /// no data is emitted as part of the [StreamNotification]. + /// + /// Example: + /// Stream.fromIterable([1]) + /// .materialize() + /// .listen(print); // Prints DataNotification{value: 1}, DoneNotification{} + /// + /// Stream.error(Exception()) + /// .materialize() + /// .listen(print); // Prints ErrorNotification{error: Exception, stackTrace: }, DoneNotification{} + Stream> materialize() => + MaterializeStreamTransformer().bind(this); +} diff --git a/core/reactivex/lib/src/transformers/max.dart b/core/reactivex/lib/src/transformers/max.dart new file mode 100644 index 00000000..ff66ae6e --- /dev/null +++ b/core/reactivex/lib/src/transformers/max.dart @@ -0,0 +1,25 @@ +import 'package:angel3_reactivex/src/utils/min_max.dart'; + +/// Extends the Stream class with the ability to transform into a Future +/// that completes with the largest item emitted by the Stream. +extension MaxExtension on Stream { + /// Converts a Stream into a Future that completes with the largest item + /// emitted by the Stream. + /// + /// This is similar to finding the max value in a list, but the values are + /// asynchronous. + /// + /// ### Example + /// + /// final max = await Stream.fromIterable([1, 2, 3]).max(); + /// + /// print(max); // prints 3 + /// + /// ### Example with custom [Comparator] + /// + /// final stream = Stream.fromIterable(['short', 'looooooong']); + /// final max = await stream.max((a, b) => a.length - b.length); + /// + /// print(max); // prints 'looooooong' + Future max([Comparator? comparator]) => minMax(this, false, comparator); +} diff --git a/core/reactivex/lib/src/transformers/min.dart b/core/reactivex/lib/src/transformers/min.dart new file mode 100644 index 00000000..41caec17 --- /dev/null +++ b/core/reactivex/lib/src/transformers/min.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/min_max.dart'; + +/// Extends the Stream class with the ability to transform into a Future +/// that completes with the smallest item emitted by the Stream. +extension MinExtension on Stream { + /// Converts a Stream into a Future that completes with the smallest item + /// emitted by the Stream. + /// + /// This is similar to finding the min value in a list, but the values are + /// asynchronous! + /// + /// ### Example + /// + /// final min = await Stream.fromIterable([1, 2, 3]).min(); + /// + /// print(min); // prints 1 + /// + /// ### Example with custom [Comparator] + /// + /// final stream = Stream.fromIterable(['short', 'looooooong']); + /// final min = await stream.min((a, b) => a.length - b.length); + /// + /// print(min); // prints 'short' + Future min([Comparator? comparator]) => minMax(this, true, comparator); +} diff --git a/core/reactivex/lib/src/transformers/on_error_resume.dart b/core/reactivex/lib/src/transformers/on_error_resume.dart new file mode 100644 index 00000000..ad574b01 --- /dev/null +++ b/core/reactivex/lib/src/transformers/on_error_resume.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _OnErrorResumeStreamSink extends ForwardingSink { + final Stream Function(Object error, StackTrace stackTrace) _recoveryFn; + final List> _recoverySubscriptions = []; + var closed = false; + + _OnErrorResumeStreamSink(this._recoveryFn); + + @override + void onData(S data) => sink.add(data); + + @override + void onError(Object e, StackTrace st) { + final Stream recoveryStream; + + try { + recoveryStream = _recoveryFn(e, st); + } catch (newError, newSt) { + sink.addError(newError, newSt); + return; + } + + final subscription = + recoveryStream.listen(sink.add, onError: sink.addError); + subscription.onDone(() { + _recoverySubscriptions.remove(subscription); + if (closed && _recoverySubscriptions.isEmpty) { + sink.close(); + } + }); + _recoverySubscriptions.add(subscription); + } + + @override + void onDone() { + closed = true; + if (_recoverySubscriptions.isEmpty) { + sink.close(); + } + } + + @override + Future? onCancel() => _recoverySubscriptions.cancelAll(); + + @override + void onListen() {} + + @override + void onPause() => _recoverySubscriptions.pauseAll(); + + @override + void onResume() => _recoverySubscriptions.resumeAll(); +} + +/// Intercepts error events and switches to a recovery stream created by the +/// provided recoveryFn Function. +/// +/// The OnErrorResumeStreamTransformer intercepts an onError notification from +/// the source Stream. Instead of passing the error through to any +/// listeners, it replaces it with another Stream of items created by the +/// recoveryFn. +/// +/// The recoveryFn receives the emitted error and returns a Stream. You can +/// perform logic in the recoveryFn to return different Streams based on the +/// type of error that was emitted. +/// +/// ### Example +/// +/// Stream.error(Exception()) +/// .onErrorResume((dynamic e) => +/// Stream.value(e is StateError ? 1 : 0) +/// .listen(print); // prints 0 +class OnErrorResumeStreamTransformer extends StreamTransformerBase { + /// Method which returns a [Stream], based from the error. + final Stream Function(Object error, StackTrace stackTrace) recoveryFn; + + /// Constructs a [StreamTransformer] which intercepts error events and + /// switches to a recovery [Stream] created by the provided [recoveryFn] Function. + OnErrorResumeStreamTransformer(this.recoveryFn); + + @override + Stream bind(Stream stream) => forwardStream( + stream, + () => _OnErrorResumeStreamSink(recoveryFn), + ); +} + +/// Extends the Stream class with the ability to recover from errors in various +/// ways +extension OnErrorExtensions on Stream { + /// Intercepts error events and switches to the given recovery stream in + /// that case + /// + /// The onErrorResumeNext operator intercepts an onError notification from + /// the source Stream. Instead of passing the error through to any + /// listeners, it replaces it with another Stream of items. + /// + /// If you need to perform logic based on the type of error that was emitted, + /// please consider using [onErrorResume]. + /// + /// ### Example + /// + /// ErrorStream(Exception()) + /// .onErrorResumeNext(Stream.fromIterable([1, 2, 3])) + /// .listen(print); // prints 1, 2, 3 + Stream onErrorResumeNext(Stream recoveryStream) => + OnErrorResumeStreamTransformer((_, __) => recoveryStream).bind(this); + + /// Intercepts error events and switches to a recovery stream created by the + /// provided [recoveryFn]. + /// + /// The onErrorResume operator intercepts an onError notification from + /// the source Stream. Instead of passing the error through to any + /// listeners, it replaces it with another Stream of items created by the + /// [recoveryFn]. + /// + /// The [recoveryFn] receives the emitted error and returns a Stream. You can + /// perform logic in the [recoveryFn] to return different Streams based on the + /// type of error that was emitted. + /// + /// If you do not need to perform logic based on the type of error that was + /// emitted, please consider using [onErrorResumeNext] or [onErrorReturn]. + /// + /// ### Example + /// + /// ErrorStream(Exception()) + /// .onErrorResume((e, st) => + /// Stream.fromIterable([e is StateError ? 1 : 0])) + /// .listen(print); // prints 0 + Stream onErrorResume( + Stream Function(Object error, StackTrace stackTrace) recoveryFn) => + OnErrorResumeStreamTransformer(recoveryFn).bind(this); + + /// Instructs a Stream to emit a particular item when it encounters an + /// error, and then terminate normally + /// + /// The onErrorReturn operator intercepts an onError notification from + /// the source Stream. Instead of passing it through to any observers, it + /// replaces it with a given item, and then terminates normally. + /// + /// If you need to perform logic based on the type of error that was emitted, + /// please consider using [onErrorReturnWith]. + /// + /// ### Example + /// + /// ErrorStream(Exception()) + /// .onErrorReturn(1) + /// .listen(print); // prints 1 + Stream onErrorReturn(T returnValue) => + OnErrorResumeStreamTransformer((_, __) => Stream.value(returnValue)) + .bind(this); + + /// Instructs a Stream to emit a particular item created by the + /// [returnFn] when it encounters an error, and then terminate normally. + /// + /// The onErrorReturnWith operator intercepts an onError notification from + /// the source Stream. Instead of passing it through to any observers, it + /// replaces it with a given item, and then terminates normally. + /// + /// The [returnFn] receives the emitted error and returns a value. You can + /// perform logic in the [returnFn] to return different value based on the + /// type of error that was emitted. + /// + /// If you do not need to perform logic based on the type of error that was + /// emitted, please consider using [onErrorReturn]. + /// + /// ### Example + /// + /// ErrorStream(Exception()) + /// .onErrorReturnWith((e, st) => e is Exception ? 1 : 0) + /// .listen(print); // prints 1 + Stream onErrorReturnWith( + T Function(Object error, StackTrace stackTrace) returnFn) => + OnErrorResumeStreamTransformer( + (e, st) => Stream.value(returnFn(e, st))).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/scan.dart b/core/reactivex/lib/src/transformers/scan.dart new file mode 100644 index 00000000..a2d4e716 --- /dev/null +++ b/core/reactivex/lib/src/transformers/scan.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +class _ScanStreamSink implements EventSink { + final T Function(T accumulated, S value, int index) _accumulator; + final EventSink _outputSink; + T _acc; + var _index = 0; + + _ScanStreamSink(this._outputSink, this._accumulator, this._acc); + + @override + void add(S data) => + _outputSink.add(_acc = _accumulator(_acc, data, _index++)); + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Applies an accumulator function over an stream sequence and returns +/// each intermediate result. The seed value is used as the initial +/// accumulator value. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3]) +/// .transform(ScanStreamTransformer((acc, curr, i) => acc + curr, 0)) +/// .listen(print); // prints 1, 3, 6 +class ScanStreamTransformer extends StreamTransformerBase { + /// Method which accumulates incoming event into a single, accumulated object + final T Function(T accumulated, S value, int index) accumulator; + + /// The initial value for the accumulated value in the [accumulator] + final T seed; + + /// Constructs a [ScanStreamTransformer] which applies an accumulator Function + /// over the source [Stream] and returns each intermediate result. + /// The seed value is used as the initial accumulator value. + ScanStreamTransformer(this.accumulator, this.seed); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _ScanStreamSink(sink, accumulator, seed)); +} + +/// Extends +extension ScanExtension on Stream { + /// Applies an accumulator function over a Stream sequence and returns each + /// intermediate result. The seed value is used as the initial + /// accumulator value. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3]) + /// .scan((acc, curr, i) => acc + curr, 0) + /// .listen(print); // prints 1, 3, 6 + Stream scan( + S Function(S accumulated, T value, int index) accumulator, S seed) => + ScanStreamTransformer(accumulator, seed).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/skip_last.dart b/core/reactivex/lib/src/transformers/skip_last.dart new file mode 100644 index 00000000..1bbdfe63 --- /dev/null +++ b/core/reactivex/lib/src/transformers/skip_last.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _SkipLastStreamSink extends ForwardingSink { + _SkipLastStreamSink(this.count); + + final int count; + final List queue = []; + + @override + void onData(T data) { + queue.add(data); + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + final limit = queue.length - count; + if (limit > 0) { + queue.sublist(0, limit).forEach(sink.add); + } + sink.close(); + } + + @override + FutureOr onCancel() { + queue.clear(); + } + + @override + void onListen() {} + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Skip the last [count] items emitted by the source [Stream] +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, 4, 5]) +/// .transform(SkipLastStreamTransformer(3)) +/// .listen(print); // prints 1, 2 +class SkipLastStreamTransformer extends StreamTransformerBase { + /// Constructs a [StreamTransformer] which skip the last [count] items + /// emitted by the source [Stream] + SkipLastStreamTransformer(this.count) { + if (count < 0) throw ArgumentError.value(count, 'count'); + } + + /// The [count] of final items to skip. + final int count; + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _SkipLastStreamSink(count)); +} + +/// Extends the Stream class with the ability to skip the last [count] items +/// emitted by the source [Stream] +extension SkipLastExtension on Stream { + /// Starts emitting every items except last [count] items. + /// This causes items to be delayed. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4, 5]) + /// .skipLast(3) + /// .listen(print); // prints 1, 2 + Stream skipLast(int count) => + SkipLastStreamTransformer(count).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/skip_until.dart b/core/reactivex/lib/src/transformers/skip_until.dart new file mode 100644 index 00000000..accf206d --- /dev/null +++ b/core/reactivex/lib/src/transformers/skip_until.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _SkipUntilStreamSink extends ForwardingSink { + final Stream _otherStream; + StreamSubscription? _otherSubscription; + var _canAdd = false; + + _SkipUntilStreamSink(this._otherStream); + + @override + void onData(S data) { + if (_canAdd) { + sink.add(data); + } + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _otherSubscription?.cancel(); + sink.close(); + } + + @override + FutureOr onCancel() => _otherSubscription?.cancel(); + + @override + void onListen() => _otherSubscription = _otherStream + .take(1) + .listen(null, onError: sink.addError, onDone: () => _canAdd = true); + + @override + void onPause() => _otherSubscription?.pause(); + + @override + void onResume() => _otherSubscription?.resume(); +} + +/// Starts emitting events only after the given stream emits an event. +/// +/// ### Example +/// +/// MergeStream([ +/// Stream.value(1), +/// TimerStream(2, Duration(minutes: 2)) +/// ]) +/// .transform(SkipUntilStreamTransformer(TimerStream(1, Duration(minutes: 1)))) +/// .listen(print); // prints 2; +class SkipUntilStreamTransformer extends StreamTransformerBase { + /// The [Stream] which is required to emit first, before this [Stream] starts emitting + final Stream otherStream; + + /// Constructs a [StreamTransformer] which starts emitting events + /// only after [otherStream] emits an event. + SkipUntilStreamTransformer(this.otherStream); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _SkipUntilStreamSink(otherStream)); +} + +/// Extends the Stream class with the ability to skip events until another +/// Stream emits an item. +extension SkipUntilExtension on Stream { + /// Starts emitting items only after the given stream emits an item. + /// + /// ### Example + /// + /// MergeStream([ + /// Stream.fromIterable([1]), + /// TimerStream(2, Duration(minutes: 2)) + /// ]) + /// .skipUntil(TimerStream(true, Duration(minutes: 1))) + /// .listen(print); // prints 2; + Stream skipUntil(Stream otherStream) => + SkipUntilStreamTransformer(otherStream).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/start_with.dart b/core/reactivex/lib/src/transformers/start_with.dart new file mode 100644 index 00000000..60e966c6 --- /dev/null +++ b/core/reactivex/lib/src/transformers/start_with.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _StartWithStreamSink extends ForwardingSink { + final S _startValue; + + _StartWithStreamSink(this._startValue); + + @override + void onData(S data) => sink.add(data); + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() => sink.close(); + + @override + FutureOr onCancel() {} + + @override + void onListen() { + sink.add(_startValue); + } + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Prepends a value to the source [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([2]) +/// .transform(StartWithStreamTransformer(1)) +/// .listen(print); // prints 1, 2 +class StartWithStreamTransformer extends StreamTransformerBase { + /// The starting event of this [Stream] + final S startValue; + + /// Constructs a [StreamTransformer] which prepends the source [Stream] + /// with [startValue]. + StartWithStreamTransformer(this.startValue); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _StartWithStreamSink(startValue)); +} + +/// Extends the [Stream] class with the ability to emit the given value as the +/// first item. +extension StartWithExtension on Stream { + /// Prepends a value to the source [Stream]. + /// + /// ### Example + /// + /// Stream.fromIterable([2]).startWith(1).listen(print); // prints 1, 2 + Stream startWith(T startValue) => + StartWithStreamTransformer(startValue).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/start_with_error.dart b/core/reactivex/lib/src/transformers/start_with_error.dart new file mode 100644 index 00000000..7a74a080 --- /dev/null +++ b/core/reactivex/lib/src/transformers/start_with_error.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _StartWithErrorStreamSink extends ForwardingSink { + final Object _e; + final StackTrace? _st; + + _StartWithErrorStreamSink(this._e, this._st); + + @override + void onData(S data) => sink.add(data); + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() => sink.close(); + + @override + FutureOr onCancel() {} + + @override + void onListen() { + sink.addError(_e, _st); + } + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Prepends an error to the source [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([2]) +/// .transform(StartWithErrorStreamTransformer('error')) +/// .listen(null, onError: (e) => print(e)); // prints 'error' +class StartWithErrorStreamTransformer extends StreamTransformerBase { + /// The starting error of this [Stream] + final Object error; + + /// The starting stackTrace of this [Stream] + final StackTrace? stackTrace; + + /// Constructs a [StreamTransformer] which starts with the provided [error] + /// and then outputs all events from the source [Stream]. + StartWithErrorStreamTransformer(this.error, [this.stackTrace]); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _StartWithErrorStreamSink(error, stackTrace)); +} diff --git a/core/reactivex/lib/src/transformers/start_with_many.dart b/core/reactivex/lib/src/transformers/start_with_many.dart new file mode 100644 index 00000000..7c8e401d --- /dev/null +++ b/core/reactivex/lib/src/transformers/start_with_many.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _StartWithManyStreamSink extends ForwardingSink { + final Iterable _startValues; + + _StartWithManyStreamSink(this._startValues); + + @override + void onData(S data) => sink.add(data); + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() => sink.close(); + + @override + FutureOr onCancel() {} + + @override + void onListen() { + _startValues.forEach(sink.add); + } + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Prepends a sequence of values to the source [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([3]) +/// .transform(StartWithManyStreamTransformer([1, 2])) +/// .listen(print); // prints 1, 2, 3 +class StartWithManyStreamTransformer extends StreamTransformerBase { + /// The starting events of this [Stream] + final Iterable startValues; + + /// Constructs a [StreamTransformer] which prepends the source [Stream] + /// with [startValues]. + StartWithManyStreamTransformer(this.startValues); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _StartWithManyStreamSink(startValues)); +} + +/// Extends the [Stream] class with the ability to emit the given values as the +/// first items. +extension StartWithManyExtension on Stream { + /// Prepends a sequence of values to the source [Stream]. + /// + /// ### Example + /// + /// Stream.fromIterable([3]).startWithMany([1, 2]) + /// .listen(print); // prints 1, 2, 3 + Stream startWithMany(List startValues) => + StartWithManyStreamTransformer(startValues).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/switch_if_empty.dart b/core/reactivex/lib/src/transformers/switch_if_empty.dart new file mode 100644 index 00000000..aa5f7794 --- /dev/null +++ b/core/reactivex/lib/src/transformers/switch_if_empty.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _SwitchIfEmptyStreamSink extends ForwardingSink { + final Stream _fallbackStream; + + var _isEmpty = true; + StreamSubscription? _fallbackSubscription; + + _SwitchIfEmptyStreamSink(this._fallbackStream); + + @override + void onData(S data) { + _isEmpty = false; + sink.add(data); + } + + @override + void onError(Object error, StackTrace st) { + sink.addError(error, st); + } + + @override + void onDone() { + if (_isEmpty) { + _fallbackSubscription = _fallbackStream.listen( + sink.add, + onError: sink.addError, + onDone: sink.close, + ); + } else { + sink.close(); + } + } + + @override + FutureOr onCancel() => _fallbackSubscription?.cancel(); + + @override + void onListen() {} + + @override + void onPause() => _fallbackSubscription?.pause(); + + @override + void onResume() => _fallbackSubscription?.resume(); +} + +/// When the original stream emits no items, this operator subscribes to +/// the given fallback stream and emits items from that stream instead. +/// +/// This can be particularly useful when consuming data from multiple sources. +/// For example, when using the Repository Pattern. Assuming you have some +/// data you need to load, you might want to start with the fastest access +/// point and keep falling back to the slowest point. For example, first query +/// an in-memory database, then a database on the file system, then a network +/// call if the data isn't on the local machine. +/// +/// This can be achieved quite simply with switchIfEmpty! +/// +/// ### Example +/// +/// // Let's pretend we have some Data sources that complete without emitting +/// // any items if they don't contain the data we're looking for +/// Stream memory; +/// Stream disk; +/// Stream network; +/// +/// // Start with memory, fallback to disk, then fallback to network. +/// // Simple as that! +/// Stream getThatData = +/// memory.switchIfEmpty(disk).switchIfEmpty(network); +class SwitchIfEmptyStreamTransformer extends StreamTransformerBase { + /// The [Stream] which will be used as fallback, if the source [Stream] is empty. + final Stream fallbackStream; + + /// Constructs a [StreamTransformer] which, when the source [Stream] emits + /// no events, switches over to [fallbackStream]. + SwitchIfEmptyStreamTransformer(this.fallbackStream); + + @override + Stream bind(Stream stream) { + return forwardStream( + stream, () => _SwitchIfEmptyStreamSink(fallbackStream)); + } +} + +/// Extend the Stream class with the ability to return an alternative Stream +/// if the initial Stream completes with no items. +extension SwitchIfEmptyExtension on Stream { + /// When the original Stream emits no items, this operator subscribes to the + /// given fallback stream and emits items from that Stream instead. + /// + /// This can be particularly useful when consuming data from multiple sources. + /// For example, when using the Repository Pattern. Assuming you have some + /// data you need to load, you might want to start with the fastest access + /// point and keep falling back to the slowest point. For example, first query + /// an in-memory database, then a database on the file system, then a network + /// call if the data isn't on the local machine. + /// + /// This can be achieved quite simply with switchIfEmpty! + /// + /// ### Example + /// + /// // Let's pretend we have some Data sources that complete without + /// // emitting any items if they don't contain the data we're looking for + /// Stream memory; + /// Stream disk; + /// Stream network; + /// + /// // Start with memory, fallback to disk, then fallback to network. + /// // Simple as that! + /// Stream getThatData = + /// memory.switchIfEmpty(disk).switchIfEmpty(network); + Stream switchIfEmpty(Stream fallbackStream) => + SwitchIfEmptyStreamTransformer(fallbackStream).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/switch_map.dart b/core/reactivex/lib/src/transformers/switch_map.dart new file mode 100644 index 00000000..63b63ae4 --- /dev/null +++ b/core/reactivex/lib/src/transformers/switch_map.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _SwitchMapStreamSink extends ForwardingSink { + final Stream Function(S value) _mapper; + StreamSubscription? _mapperSubscription; + bool _inputClosed = false; + bool _isCancelled = false; + + _SwitchMapStreamSink(this._mapper); + + @override + void onData(S data) { + final Stream mappedStream; + try { + mappedStream = _mapper(data); + } catch (e, s) { + sink.addError(e, s); + return; + } + + final mapperSubscription = _mapperSubscription; + + if (mapperSubscription == null) { + listenToInner(mappedStream); + return; + } + + _mapperSubscription = null; + pauseSubscription(); + mapperSubscription.cancel().onError((e, s) { + if (!_isCancelled) { + sink.addError(e, s); + } + }).whenComplete(() => resumeAndListenToInner(mappedStream)); + } + + void resumeAndListenToInner(Stream mappedStream) { + if (_isCancelled) { + return; + } + + resumeSubscription(); + listenToInner(mappedStream); + } + + void listenToInner(Stream mappedStream) { + assert(_mapperSubscription == null); + + _mapperSubscription = mappedStream.listen( + sink.add, + onError: sink.addError, + onDone: () { + _mapperSubscription = null; + + if (_inputClosed) { + sink.close(); + } + }, + ); + + // https://github.com/dart-lang/stream_transform/blob/9743578b0119de6a8badd30bb16ef15d79bd3b15/lib/src/switch.dart#L71-L74 + // If a pause happens during an _mapperSubscription.cancel, + // we still listen to the next stream when the cancel is done. + // Then we immediately pause it again here. + if (sink.isPaused) { + _mapperSubscription?.pause(); + } + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _inputClosed = true; + + _mapperSubscription ?? sink.close(); + } + + @override + FutureOr onCancel() { + _isCancelled = true; + + return _mapperSubscription?.cancel(); + } + + @override + void onListen() {} + + @override + void onPause() => _mapperSubscription?.pause(); + + @override + void onResume() => _mapperSubscription?.resume(); +} + +/// Converts each emitted item into a new Stream using the given mapper +/// function. The newly created Stream will be be listened to and begin +/// emitting items, and any previously created Stream will stop emitting. +/// +/// The switchMap operator is similar to the flatMap and concatMap +/// methods, but it only emits items from the most recently created Stream. +/// +/// This can be useful when you only want the very latest state from +/// asynchronous APIs, for example. +/// +/// ### Example +/// +/// Stream.fromIterable([4, 3, 2, 1]) +/// .transform(SwitchMapStreamTransformer((i) => +/// Stream.fromFuture( +/// Future.delayed(Duration(minutes: i), () => i)) +/// .listen(print); // prints 1 +class SwitchMapStreamTransformer extends StreamTransformerBase { + /// Method which converts incoming events into a new [Stream] + final Stream Function(S value) mapper; + + /// Constructs a [StreamTransformer] which maps each event from the source [Stream] + /// using [mapper]. + /// + /// The mapped [Stream] will be be listened to and begin + /// emitting items, and any previously created mapper [Stream]s will stop emitting. + SwitchMapStreamTransformer(this.mapper); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _SwitchMapStreamSink(mapper)); +} + +/// Extends the Stream with the ability to convert one stream into a new Stream +/// whenever the source emits an item. Every time a new Stream is created, the +/// previous Stream is discarded. +extension SwitchMapExtension on Stream { + /// Converts each emitted item into a Stream using the given mapper function. + /// The newly created Stream will be be listened to and begin emitting items, + /// and any previously created Stream will stop emitting. + /// + /// The switchMap operator is similar to the flatMap and concatMap methods, + /// but it only emits items from the most recently created Stream. + /// + /// This can be useful when you only want the very latest state from + /// asynchronous APIs, for example. + /// + /// ### Example + /// + /// RangeStream(4, 1) + /// .switchMap((i) => + /// TimerStream(i, Duration(minutes: i))) + /// .listen(print); // prints 1 + Stream switchMap(Stream Function(T value) mapper) => + SwitchMapStreamTransformer(mapper).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/take_last.dart b/core/reactivex/lib/src/transformers/take_last.dart new file mode 100644 index 00000000..56a11a76 --- /dev/null +++ b/core/reactivex/lib/src/transformers/take_last.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _TakeLastStreamSink extends ForwardingSink { + _TakeLastStreamSink(this.count); + + final int count; + final Queue queue = DoubleLinkedQueue(); + + @override + void onData(T data) { + if (count > 0) { + queue.addLast(data); + if (queue.length > count) { + queue.removeFirst(); + } + } + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + if (queue.isNotEmpty) { + queue.toList(growable: false).forEach(sink.add); + } + sink.close(); + } + + @override + FutureOr onCancel() { + queue.clear(); + } + + @override + void onListen() {} + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Emits only the final [count] values emitted by the source [Stream]. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, 4, 5]) +/// .transform(TakeLastStreamTransformer(3)) +/// .listen(print); // prints 3, 4, 5 +class TakeLastStreamTransformer extends StreamTransformerBase { + /// Constructs a [StreamTransformer] which emits only the final [count] + /// events from the source [Stream]. + TakeLastStreamTransformer(this.count) { + if (count < 0) throw ArgumentError.value(count, 'count'); + } + + /// The [count] of final items emitted when the stream completes. + final int count; + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _TakeLastStreamSink(count)); +} + +/// Extends the [Stream] class with the ability receive only the final [count] +/// events from the source [Stream]. +extension TakeLastExtension on Stream { + /// Emits only the final [count] values emitted by the source [Stream]. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, 4, 5]) + /// .takeLast(3) + /// .listen(print); // prints 3, 4, 5 + Stream takeLast(int count) => + TakeLastStreamTransformer(count).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/take_until.dart b/core/reactivex/lib/src/transformers/take_until.dart new file mode 100644 index 00000000..54fe0d64 --- /dev/null +++ b/core/reactivex/lib/src/transformers/take_until.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _TakeUntilStreamSink extends ForwardingSink { + final Stream _otherStream; + StreamSubscription? _otherSubscription; + + _TakeUntilStreamSink(this._otherStream); + + @override + void onData(S data) => sink.add(data); + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() { + _otherSubscription?.cancel(); + sink.close(); + } + + @override + FutureOr onCancel() => _otherSubscription?.cancel(); + + @override + void onListen() => _otherSubscription = _otherStream + .take(1) + .listen(null, onError: sink.addError, onDone: sink.close); + + @override + void onPause() => _otherSubscription?.pause(); + + @override + void onResume() => _otherSubscription?.resume(); +} + +/// Returns the values from the source stream sequence until the other +/// stream sequence produces a value. +/// +/// ### Example +/// +/// MergeStream([ +/// Stream.fromIterable([1]), +/// TimerStream(2, Duration(minutes: 1)) +/// ]) +/// .transform(TakeUntilStreamTransformer( +/// TimerStream(3, Duration(seconds: 10)))) +/// .listen(print); // prints 1 +class TakeUntilStreamTransformer extends StreamTransformerBase { + /// The [Stream] which closes this [Stream] as soon as it emits an event. + final Stream otherStream; + + /// Constructs a [StreamTransformer] which emits events from the source [Stream], + /// until [otherStream] fires. + TakeUntilStreamTransformer(this.otherStream); + + @override + Stream bind(Stream stream) => + forwardStream(stream, () => _TakeUntilStreamSink(otherStream)); +} + +/// Extends the Stream class with the ability receive events from the source +/// Stream until another Stream produces a value. +extension TakeUntilExtension on Stream { + /// Returns the values from the source Stream sequence until the other Stream + /// sequence produces a value. + /// + /// ### Example + /// + /// MergeStream([ + /// Stream.fromIterable([1]), + /// TimerStream(2, Duration(minutes: 1)) + /// ]) + /// .takeUntil(TimerStream(3, Duration(seconds: 10))) + /// .listen(print); // prints 1 + Stream takeUntil(Stream otherStream) => + TakeUntilStreamTransformer(otherStream).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/take_while_inclusive.dart b/core/reactivex/lib/src/transformers/take_while_inclusive.dart new file mode 100644 index 00000000..8471db13 --- /dev/null +++ b/core/reactivex/lib/src/transformers/take_while_inclusive.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +class _TakeWhileInclusiveStreamSink implements EventSink { + final bool Function(S) _test; + final EventSink _outputSink; + + _TakeWhileInclusiveStreamSink(this._outputSink, this._test); + + @override + void add(S data) { + bool satisfies; + + try { + satisfies = _test(data); + } catch (e, s) { + _outputSink.addError(e, s); + // The test didn't say true. Didn't say false either, but we stop anyway. + _outputSink.close(); + return; + } + + if (satisfies) { + _outputSink.add(data); + } else { + _outputSink.add(data); + _outputSink.close(); + } + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Emits values emitted by the source Stream so long as each value +/// satisfies the given test. When the test is not satisfied by a value, it +/// will emit this value as a final event and then complete. +/// +/// ### Example +/// +/// Stream.fromIterable([2, 3, 4, 5, 6, 1, 2, 3]) +/// .transform(TakeWhileInclusiveStreamTransformer((i) => i < 4)) +/// .listen(print); // prints 2, 3, 4 +class TakeWhileInclusiveStreamTransformer + extends StreamTransformerBase { + /// Method used to test incoming events + final bool Function(S) test; + + /// Constructs a [StreamTransformer] which forwards data events while [test] + /// is successful, and includes last event that caused [test] to return false. + TakeWhileInclusiveStreamTransformer(this.test); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _TakeWhileInclusiveStreamSink(sink, test)); +} + +/// Extends the Stream class with the ability to take events while they pass +/// the condition given and include last event that doesn't pass the condition. +extension TakeWhileInclusiveExtension on Stream { + /// Emits values emitted by the source Stream so long as each value + /// satisfies the given test. When the test is not satisfied by a value, it + /// will emit this value as a final event and then complete. + /// + /// ### Example + /// + /// Stream.fromIterable([2, 3, 4, 5, 6, 1, 2, 3]) + /// .takeWhileInclusive((i) => i < 4) + /// .listen(print); // prints 2, 3, 4 + Stream takeWhileInclusive(bool Function(T) test) => + TakeWhileInclusiveStreamTransformer(test).bind(this); +} diff --git a/core/reactivex/lib/src/transformers/time_interval.dart b/core/reactivex/lib/src/transformers/time_interval.dart new file mode 100644 index 00000000..b7e3c717 --- /dev/null +++ b/core/reactivex/lib/src/transformers/time_interval.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; + +class _TimeIntervalStreamSink extends ForwardingSink> { + final _stopwatch = Stopwatch(); + + @override + void onData(S data) { + _stopwatch.stop(); + sink.add( + TimeInterval( + data, + Duration( + microseconds: _stopwatch.elapsedMicroseconds, + ), + ), + ); + _stopwatch + ..reset() + ..start(); + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() => sink.close(); + + @override + FutureOr onCancel() {} + + @override + void onListen() => _stopwatch.start(); + + @override + void onPause() {} + + @override + void onResume() {} +} + +/// Records the time interval between consecutive values in an stream +/// sequence. +/// +/// ### Example +/// +/// Stream.fromIterable([1]) +/// .transform(IntervalStreamTransformer(Duration(seconds: 1))) +/// .transform(TimeIntervalStreamTransformer()) +/// .listen(print); // prints TimeInterval{interval: 0:00:01, value: 1} +class TimeIntervalStreamTransformer + extends StreamTransformerBase> { + /// Constructs a [StreamTransformer] which emits events from the + /// source [Stream] as snapshots in the form of [TimeInterval]. + TimeIntervalStreamTransformer(); + + @override + Stream> bind(Stream stream) => + forwardStream(stream, () => _TimeIntervalStreamSink()); +} + +/// A class that represents a snapshot of the current value emitted by a +/// [Stream], at a specified interval. +class TimeInterval { + /// The interval at which this snapshot was taken + final Duration interval; + + /// The value at the moment of [interval] + final T value; + + /// Constructs a snapshot of a [Stream], containing the [Stream]'s event + /// at the specified [interval] as [value]. + TimeInterval(this.value, this.interval); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is TimeInterval && + interval == other.interval && + value == other.value; + } + + @override + int get hashCode { + return interval.hashCode ^ value.hashCode; + } + + @override + String toString() { + return 'TimeInterval{interval: $interval, value: $value}'; + } +} + +/// Extends the Stream class with the ability to record the time interval +/// between consecutive values in an stream +extension TimeIntervalExtension on Stream { + /// Records the time interval between consecutive values in a Stream sequence. + /// + /// ### Example + /// + /// Stream.fromIterable([1]) + /// .interval(Duration(seconds: 1)) + /// .timeInterval() + /// .listen(print); // prints TimeInterval{interval: 0:00:01, value: 1} + Stream> timeInterval() => + TimeIntervalStreamTransformer().bind(this); +} diff --git a/core/reactivex/lib/src/transformers/timestamp.dart b/core/reactivex/lib/src/transformers/timestamp.dart new file mode 100644 index 00000000..0564402d --- /dev/null +++ b/core/reactivex/lib/src/transformers/timestamp.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +class _TimestampStreamSink implements EventSink { + final EventSink> _outputSink; + + _TimestampStreamSink(this._outputSink); + + @override + void add(S data) { + _outputSink.add(Timestamped(DateTime.now(), data)); + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// Wraps each item emitted by the source Stream in a [Timestamped] object +/// that includes the emitted item and the time when the item was emitted. +/// +/// Example +/// +/// Stream.fromIterable([1]) +/// .transform(TimestampStreamTransformer()) +/// .listen((i) => print(i)); // prints 'TimeStamp{timestamp: XXX, value: 1}'; +class TimestampStreamTransformer + extends StreamTransformerBase> { + /// Constructs a [StreamTransformer] which emits events from the + /// source [Stream] as snapshots in the form of [Timestamped]. + TimestampStreamTransformer(); + + @override + Stream> bind(Stream stream) => + Stream.eventTransformed(stream, (sink) => _TimestampStreamSink(sink)); +} + +/// A class that represents a snapshot of the current value emitted by a +/// [Stream], at a specified timestamp. +class Timestamped { + /// The value at the moment of the [timestamp] + final T value; + + /// The time at which this snapshot was taken + final DateTime timestamp; + + /// Constructs a snapshot of a [Stream], containing the [Stream]'s event + /// at the specified [timestamp] as [value]. + Timestamped(this.timestamp, this.value); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is Timestamped && + timestamp == other.timestamp && + value == other.value; + } + + @override + int get hashCode { + return timestamp.hashCode ^ value.hashCode; + } + + @override + String toString() { + return 'TimeStamp{timestamp: $timestamp, value: $value}'; + } +} + +/// Extends the Stream class with the ability to wrap each item emitted by the +/// source Stream in a [Timestamped] object that includes the emitted item and +/// the time when the item was emitted. +extension TimeStampExtension on Stream { + /// Wraps each item emitted by the source Stream in a [Timestamped] object + /// that includes the emitted item and the time when the item was emitted. + /// + /// Example + /// + /// Stream.fromIterable([1]) + /// .timestamp() + /// .listen((i) => print(i)); // prints 'TimeStamp{timestamp: XXX, value: 1}'; + Stream> timestamp() => + TimestampStreamTransformer().bind(this); +} diff --git a/core/reactivex/lib/src/transformers/where_not_null.dart b/core/reactivex/lib/src/transformers/where_not_null.dart new file mode 100644 index 00000000..5fdcb4e6 --- /dev/null +++ b/core/reactivex/lib/src/transformers/where_not_null.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +class _WhereNotNullStreamSink implements EventSink { + final EventSink _outputSink; + + _WhereNotNullStreamSink(this._outputSink); + + @override + void add(T? event) { + if (event != null) { + _outputSink.add(event); + } + } + + @override + void addError(Object error, [StackTrace? stackTrace]) => + _outputSink.addError(error, stackTrace); + + @override + void close() => _outputSink.close(); +} + +/// Create a Stream which emits all the non-`null` elements of the Stream, +/// in their original emission order. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2, 3, null, 4, null]) +/// .transform(WhereNotNullStreamTransformer()) +/// .listen(print); // prints 1, 2, 3, 4 +/// +/// // equivalent to: +/// +/// Stream.fromIterable([1, 2, 3, null, 4, null]) +/// .transform(WhereTypeStreamTransformer()) +/// .listen(print); // prints 1, 2, 3, 4 +class WhereNotNullStreamTransformer + extends StreamTransformerBase { + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _WhereNotNullStreamSink(sink)); +} + +/// Extends the Stream class with the ability to convert the source Stream +/// to a Stream which emits all the non-`null` elements +/// of this Stream, in their original emission order. +extension WhereNotNullExtension on Stream { + /// Returns a Stream which emits all the non-`null` elements + /// of this Stream, in their original emission order. + /// + /// For a `Stream`, this method is equivalent to `.whereType()`. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2, 3, null, 4, null]) + /// .whereNotNull() + /// .listen(print); // prints 1, 2, 3, 4 + /// + /// // equivalent to: + /// + /// Stream.fromIterable([1, 2, 3, null, 4, null]) + /// .whereType() + /// .listen(print); // prints 1, 2, 3, 4 + Stream whereNotNull() => WhereNotNullStreamTransformer().bind(this); +} diff --git a/core/reactivex/lib/src/transformers/where_type.dart b/core/reactivex/lib/src/transformers/where_type.dart new file mode 100644 index 00000000..22a76a62 --- /dev/null +++ b/core/reactivex/lib/src/transformers/where_type.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +class _WhereTypeStreamSink implements EventSink { + final EventSink _outputSink; + + _WhereTypeStreamSink(this._outputSink); + + @override + void add(S data) { + if (data is T) { + _outputSink.add(data); + } + } + + @override + void addError(e, [st]) => _outputSink.addError(e, st); + + @override + void close() => _outputSink.close(); +} + +/// This transformer is a shorthand for [Stream.where] followed by [Stream.cast]. +/// +/// Events that do not match [T] are filtered out, the resulting +/// [Stream] will be of Type [T]. +/// +/// ### Example +/// +/// Stream.fromIterable([1, 'two', 3, 'four']) +/// .whereType() +/// .listen(print); // prints 1, 3 +/// +/// // as opposed to: +/// +/// Stream.fromIterable([1, 'two', 3, 'four']) +/// .where((event) => event is int) +/// .cast() +/// .listen(print); // prints 1, 3 +/// +class WhereTypeStreamTransformer extends StreamTransformerBase { + /// Constructs a [StreamTransformer] which combines [Stream.where] followed by [Stream.cast]. + WhereTypeStreamTransformer(); + + @override + Stream bind(Stream stream) => Stream.eventTransformed( + stream, (sink) => _WhereTypeStreamSink(sink)); +} + +/// Extends the Stream class with the ability to filter down events to only +/// those of a specific type. +extension WhereTypeExtension on Stream { + /// This transformer is a shorthand for [Stream.where] followed by + /// [Stream.cast]. + /// + /// Events that do not match [T] are filtered out, the resulting [Stream] will + /// be of Type [T]. + /// + /// ### Example + /// + /// Stream.fromIterable([1, 'two', 3, 'four']) + /// .whereType() + /// .listen(print); // prints 1, 3 + /// + /// #### as opposed to: + /// + /// Stream.fromIterable([1, 'two', 3, 'four']) + /// .where((event) => event is int) + /// .cast() + /// .listen(print); // prints 1, 3 + Stream whereType() => WhereTypeStreamTransformer().bind(this); +} diff --git a/core/reactivex/lib/src/transformers/with_latest_from.dart b/core/reactivex/lib/src/transformers/with_latest_from.dart new file mode 100644 index 00000000..8e3013c8 --- /dev/null +++ b/core/reactivex/lib/src/transformers/with_latest_from.dart @@ -0,0 +1,738 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/collection_extensions.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/forwarding_stream.dart'; +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +class _WithLatestFromStreamSink extends ForwardingSink { + final Iterable> _latestFromStreams; + final R Function(S t, List values) _combiner; + + bool _hasValues = false; + List? _latestValues; + late List> _subscriptions; + + _WithLatestFromStreamSink(this._latestFromStreams, this._combiner); + + @override + void onData(S data) { + if (_hasValues && _latestValues != null) { + final R combinedValue; + try { + combinedValue = _combiner(data, List.unmodifiable(_latestValues!)); + } catch (e, s) { + sink.addError(e, s); + return; + } + sink.add(combinedValue); + } + } + + @override + void onError(Object e, StackTrace st) => sink.addError(e, st); + + @override + void onDone() => sink.close(); + + @override + Future? onCancel() { + _latestValues = null; + return _subscriptions.cancelAll(); + } + + @override + void onListen() { + var count = 0; + + StreamSubscription mapper(int index, Stream stream) { + var hasValue = false; + + return stream.listen( + (value) { + if (!hasValue) { + hasValue = true; + if (++count == _subscriptions.length) { + _hasValues = true; + } + } + _latestValues![index] = value; + }, + onError: sink.addError, + ); + } + + _subscriptions = + _latestFromStreams.mapIndexed(mapper).toList(growable: false); + if (_subscriptions.isEmpty) { + _hasValues = true; + } + _latestValues = List.filled(_subscriptions.length, null); + } + + @override + void onPause() => _subscriptions.pauseAll(); + + @override + void onResume() => _subscriptions.resumeAll(); +} + +/// A StreamTransformer that emits when the source stream emits, combining +/// the latest values from the two streams using the provided function. +/// +/// If the latestFromStream has not emitted any values, this stream will not +/// emit either. +/// +/// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) +/// +/// ### Example +/// +/// Stream.fromIterable([1, 2]).transform( +/// WithLatestFromStreamTransformer( +/// Stream.fromIterable([2, 3]), (a, b) => a + b) +/// .listen(print); // prints 4 (due to the async nature of streams) +class WithLatestFromStreamTransformer + extends StreamTransformerBase { + /// A collection of [Stream]s of which the latest values will be combined. + final Iterable> latestFromStreams; + + /// The combiner Function + final R Function(S t, List values) combiner; + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from [latestFromStreams] using the provided function [fn]. + WithLatestFromStreamTransformer(this.latestFromStreams, this.combiner); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from [latestFromStreams] using a [List]. + static WithLatestFromStreamTransformer> withList( + Iterable> latestFromStreams, + ) { + return WithLatestFromStreamTransformer>( + latestFromStreams, + (s, values) => [s, ...values], + ); + } + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from [latestFromStream] using the provided function [fn]. + static WithLatestFromStreamTransformer with1( + Stream latestFromStream, + R Function(T t, S s) fn, + ) => + WithLatestFromStreamTransformer( + [latestFromStream], + (s, values) => fn(s, values[0]), + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer with2( + Stream latestFromStream1, + Stream latestFromStream2, + R Function(T t, A a, B b) fn, + ) => + WithLatestFromStreamTransformer( + [latestFromStream1, latestFromStream2], + (s, values) => fn(s, values[0] as A, values[1] as B), + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer with3( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + R Function(T t, A a, B b, C c) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer with4( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + R Function(T t, A a, B b, C c, D d) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer + with5( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + R Function(T t, A a, B b, C c, D d, E e) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer + with6( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + R Function(T t, A a, B b, C c, D d, E e, F f) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer + with7( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + R Function(T t, A a, B b, C c, D d, E e, F f, G g) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer + with8( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + Stream latestFromStream8, + R Function(T t, A a, B b, C c, D d, E e, F f, G g, H h) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + latestFromStream8, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + ); + }, + ); + + /// Constructs a [StreamTransformer] that emits when the source [Stream] emits, combining + /// the latest values from all [latestFromStream]s using the provided function [fn]. + static WithLatestFromStreamTransformer + with9( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + Stream latestFromStream8, + Stream latestFromStream9, + R Function(T t, A a, B b, C c, D d, E e, F f, G g, H h, I i) fn, + ) => + WithLatestFromStreamTransformer( + [ + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + latestFromStream8, + latestFromStream9, + ], + (s, values) { + return fn( + s, + values[0] as A, + values[1] as B, + values[2] as C, + values[3] as D, + values[4] as E, + values[5] as F, + values[6] as G, + values[7] as H, + values[8] as I, + ); + }, + ); + + @override + Stream bind(Stream stream) => forwardStream( + stream, + () => _WithLatestFromStreamSink(latestFromStreams, combiner), + ); +} + +/// Extends the Stream class with the ability to merge the source Stream with +/// the last emitted item from another Stream. +extension WithLatestFromExtensions on Stream { + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the two streams using the provided function. + /// + /// If the latestFromStream has not emitted any values, this stream will not + /// emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]).withLatestFrom( + /// Stream.fromIterable([2, 3]), (a, b) => a + b) + /// .listen(print); // prints 4 (due to the async nature of streams) + Stream withLatestFrom( + Stream latestFromStream, R Function(T t, S s) fn) => + WithLatestFromStreamTransformer.with1(latestFromStream, fn) + .bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the streams into a list. This is helpful when you need + /// to combine a dynamic number of Streams. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// Stream.fromIterable([1, 2]).withLatestFromList( + /// [ + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// ], + /// ).listen(print); // print [2, 2, 3, 4, 5, 6] (due to the async nature of streams) + /// + Stream> withLatestFromList(Iterable> latestFromStreams) => + WithLatestFromStreamTransformer.withList(latestFromStreams).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the three streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom2( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// (int a, int b, int c) => a + b + c, + /// ) + /// .listen(print); // prints 7 (due to the async nature of streams) + Stream withLatestFrom2( + Stream latestFromStream1, + Stream latestFromStream2, + R Function(T t, A a, B b) fn, + ) => + WithLatestFromStreamTransformer.with2( + latestFromStream1, + latestFromStream2, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the four streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom3( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// (int a, int b, int c, int d) => a + b + c + d, + /// ) + /// .listen(print); // prints 11 (due to the async nature of streams) + Stream withLatestFrom3( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + R Function(T t, A a, B b, C c) fn, + ) => + WithLatestFromStreamTransformer.with3( + latestFromStream1, + latestFromStream2, + latestFromStream3, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the five streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom4( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// (int a, int b, int c, int d, int e) => a + b + c + d + e, + /// ) + /// .listen(print); // prints 16 (due to the async nature of streams) + Stream withLatestFrom4( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + R Function(T t, A a, B b, C c, D d) fn, + ) => + WithLatestFromStreamTransformer.with4( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the six streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom5( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// (int a, int b, int c, int d, int e, int f) => a + b + c + d + e + f, + /// ) + /// .listen(print); // prints 22 (due to the async nature of streams) + Stream withLatestFrom5( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + R Function(T t, A a, B b, C c, D d, E e) fn, + ) => + WithLatestFromStreamTransformer.with5( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the seven streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom6( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// Stream.fromIterable([7, 8]), + /// (int a, int b, int c, int d, int e, int f, int g) => + /// a + b + c + d + e + f + g, + /// ) + /// .listen(print); // prints 29 (due to the async nature of streams) + Stream withLatestFrom6( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + R Function(T t, A a, B b, C c, D d, E e, F f) fn, + ) => + WithLatestFromStreamTransformer.with6( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the eight streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom7( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// Stream.fromIterable([7, 8]), + /// Stream.fromIterable([8, 9]), + /// (int a, int b, int c, int d, int e, int f, int g, int h) => + /// a + b + c + d + e + f + g + h, + /// ) + /// .listen(print); // prints 37 (due to the async nature of streams) + Stream withLatestFrom7( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + R Function(T t, A a, B b, C c, D d, E e, F f, G g) fn, + ) => + WithLatestFromStreamTransformer.with7( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the nine streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom8( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// Stream.fromIterable([7, 8]), + /// Stream.fromIterable([8, 9]), + /// Stream.fromIterable([9, 10]), + /// (int a, int b, int c, int d, int e, int f, int g, int h, int i) => + /// a + b + c + d + e + f + g + h + i, + /// ) + /// .listen(print); // prints 46 (due to the async nature of streams) + Stream withLatestFrom8( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + Stream latestFromStream8, + R Function(T t, A a, B b, C c, D d, E e, F f, G g, H h) fn, + ) => + WithLatestFromStreamTransformer.with8( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + latestFromStream8, + fn, + ).bind(this); + + /// Creates a Stream that emits when the source stream emits, combining the + /// latest values from the ten streams using the provided function. + /// + /// If any of latestFromStreams has not emitted any values, this stream will + /// not emit either. + /// + /// [Interactive marble diagram](http://rxmarbles.com/#withLatestFrom) + /// + /// ### Example + /// + /// Stream.fromIterable([1, 2]) + /// .withLatestFrom9( + /// Stream.fromIterable([2, 3]), + /// Stream.fromIterable([3, 4]), + /// Stream.fromIterable([4, 5]), + /// Stream.fromIterable([5, 6]), + /// Stream.fromIterable([6, 7]), + /// Stream.fromIterable([7, 8]), + /// Stream.fromIterable([8, 9]), + /// Stream.fromIterable([9, 10]), + /// Stream.fromIterable([10, 11]), + /// (int a, int b, int c, int d, int e, int f, int g, int h, int i, int j) => + /// a + b + c + d + e + f + g + h + i + j, + /// ) + /// .listen(print); // prints 46 (due to the async nature of streams) + Stream withLatestFrom9( + Stream latestFromStream1, + Stream latestFromStream2, + Stream latestFromStream3, + Stream latestFromStream4, + Stream latestFromStream5, + Stream latestFromStream6, + Stream latestFromStream7, + Stream latestFromStream8, + Stream latestFromStream9, + R Function(T t, A a, B b, C c, D d, E e, F f, G g, H h, I i) fn, + ) => + WithLatestFromStreamTransformer.with9( + latestFromStream1, + latestFromStream2, + latestFromStream3, + latestFromStream4, + latestFromStream5, + latestFromStream6, + latestFromStream7, + latestFromStream8, + latestFromStream9, + fn, + ).bind(this); +} diff --git a/core/reactivex/lib/src/utils/collection_extensions.dart b/core/reactivex/lib/src/utils/collection_extensions.dart new file mode 100644 index 00000000..a58f9eea --- /dev/null +++ b/core/reactivex/lib/src/utils/collection_extensions.dart @@ -0,0 +1,65 @@ +import 'dart:collection'; +import 'dart:math'; + +/// @internal +/// @nodoc +/// Provides extension methods on [List]. +extension ListExtensions on List { + /// @internal + /// Returns a list of values built from the elements of this list + /// and the other list with the same index + /// using the provided transform function applied to each pair of elements. + /// The returned list has length of the shortest list. + List zipWith( + List other, + R Function(T, S) transform, { + bool growable = true, + }) => + List.generate( + min(length, other.length), + (index) => transform(this[index], other[index]), + growable: growable, + ); +} + +/// @internal +/// Provides extension methods on [Iterable]. +extension IterableExtensions on Iterable { + /// @internal + /// The non-`null` results of calling [transform] on the elements of [this]. + /// + /// Returns a lazy iterable which calls [transform] + /// on the elements of this iterable in iteration order, + /// then emits only the non-`null` values. + /// + /// If [transform] throws, the iteration is terminated. + Iterable mapNotNull(R? Function(T) transform) sync* { + for (final e in this) { + final v = transform(e); + if (v != null) { + yield v; + } + } + } + + /// @internal + /// Maps each element and its index to a new value. + Iterable mapIndexed(R Function(int index, T element) transform) sync* { + var index = 0; + for (final e in this) { + yield transform(index++, e); + } + } +} + +/// @internal +/// Provides [removeFirstElements] extension method on [Queue]. +extension RemoveFirstElementsQueueExtension on Queue { + /// @internal + /// Removes the first [count] elements of this queue. + void removeFirstElements(int count) { + for (var i = 0; i < count; i++) { + removeFirst(); + } + } +} diff --git a/core/reactivex/lib/src/utils/composite_subscription.dart b/core/reactivex/lib/src/utils/composite_subscription.dart new file mode 100644 index 00000000..7745b8db --- /dev/null +++ b/core/reactivex/lib/src/utils/composite_subscription.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/subscription.dart'; + +/// Acts as a container for multiple subscriptions that can be canceled at once +/// e.g. view subscriptions in Flutter that need to be canceled on view disposal +/// +/// Can be cleared or disposed. When disposed, cannot be used again. +/// ### Example +/// // init your subscriptions +/// composite.add(stream1.listen(listener1)) +/// ..add(stream2.listen(listener1)) +/// ..add(stream3.listen(listener1)); +/// +/// // clear them all at once +/// composite.clear(); +class CompositeSubscription implements StreamSubscription { + bool _isDisposed = false; + + final List> _subscriptionsList = []; + + /// Checks if this composite is disposed. If it is, the composite can't be used again + /// and will throw an error if you try to add more subscriptions to it. + bool get isDisposed => _isDisposed; + + /// Returns the total amount of currently added [StreamSubscription]s + int get length => _subscriptionsList.length; + + /// Checks if there currently are no [StreamSubscription]s added + bool get isEmpty => _subscriptionsList.isEmpty; + + /// Checks if there currently are [StreamSubscription]s added + bool get isNotEmpty => _subscriptionsList.isNotEmpty; + + /// Whether all managed [StreamSubscription]s are currently paused. + bool get allPaused => + _subscriptionsList.isNotEmpty && + _subscriptionsList.every((s) => s.isPaused); + + /// Adds new subscription to this composite. + /// + /// Throws an exception if this composite was disposed + StreamSubscription add(StreamSubscription subscription) { + if (isDisposed) { + throw StateError( + 'This $runtimeType was disposed, consider checking `isDisposed` or try to use new instance instead'); + } + _subscriptionsList.add(subscription); + return subscription; + } + + /// Remove the subscription from this composite and cancel it if it has been removed. + Future? remove( + StreamSubscription subscription, { + bool shouldCancel = true, + }) => + _subscriptionsList.remove(subscription) && shouldCancel + ? subscription.cancel() + : null; + + /// Cancels all subscriptions added to this composite. Clears subscriptions collection. + /// + /// This composite can be reused after calling this method. + Future? clear() { + final cancelAllDone = _subscriptionsList.cancelAll(); + _subscriptionsList.clear(); + return cancelAllDone; + } + + /// Cancels all subscriptions added to this composite. Disposes this. + /// + /// This composite can't be reused after calling this method. + Future? dispose() { + final clearDone = clear(); + _isDisposed = true; + return clearDone; + } + + /// Pauses all subscriptions added to this composite. + void pauseAll([Future? resumeSignal]) => + _subscriptionsList.pauseAll(resumeSignal); + + /// Resumes all subscriptions added to this composite. + void resumeAll() => _subscriptionsList.resumeAll(); + + // implements StreamSubscription + + @override + Future cancel() => dispose() ?? Future.value(null); + + @override + bool get isPaused => allPaused; + + @override + void pause([Future? resumeSignal]) => pauseAll(resumeSignal); + + @override + void resume() => resumeAll(); + + @override + Never asFuture([E? futureValue]) => _unsupportedError(); + + @override + Never onData(void Function(Never data)? handleData) => _unsupportedError(); + + @override + Never onDone(void Function()? handleDone) => _unsupportedError(); + + @override + Never onError(Function? handleError) => _unsupportedError(); + + Never _unsupportedError() => throw UnsupportedError( + 'Cannot change handlers of CompositeSubscription.'); +} + +/// Extends the [StreamSubscription] class with the ability to be added to [CompositeSubscription] container. +extension AddToCompositeSubscriptionExtension on StreamSubscription { + /// Adds this subscription to composite container for subscriptions. + void addTo(CompositeSubscription compositeSubscription) => + compositeSubscription.add(this); +} diff --git a/core/reactivex/lib/src/utils/empty.dart b/core/reactivex/lib/src/utils/empty.dart new file mode 100644 index 00000000..04a77667 --- /dev/null +++ b/core/reactivex/lib/src/utils/empty.dart @@ -0,0 +1,18 @@ +class _Empty { + const _Empty(); + + @override + String toString() => '<>'; +} + +/// @internal +/// Sentinel object used to represent a missing value (distinct from `null`). +const Object? EMPTY = _Empty(); // ignore: constant_identifier_names + +/// @internal +/// Returns `null` if [o] is [EMPTY], otherwise returns itself. +T? unbox(Object? o) => identical(o, EMPTY) ? null : o as T; + +/// @internal +/// Returns `true` if [o] is not [EMPTY]. +bool isNotEmpty(Object? o) => !identical(o, EMPTY); diff --git a/core/reactivex/lib/src/utils/error_and_stacktrace.dart b/core/reactivex/lib/src/utils/error_and_stacktrace.dart new file mode 100644 index 00000000..33a68c9d --- /dev/null +++ b/core/reactivex/lib/src/utils/error_and_stacktrace.dart @@ -0,0 +1,28 @@ +/// An Object which acts as a tuple containing both an error and the +/// corresponding stack trace. +class ErrorAndStackTrace { + /// A reference to the wrapped error object. + final Object error; + + /// A reference to the wrapped [StackTrace] + final StackTrace? stackTrace; + + /// Constructs an object containing both an [error] and the + /// corresponding [stackTrace]. + ErrorAndStackTrace(this.error, this.stackTrace); + + @override + String toString() => + 'ErrorAndStackTrace{error: $error, stackTrace: $stackTrace}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ErrorAndStackTrace && + runtimeType == other.runtimeType && + error == other.error && + stackTrace == other.stackTrace; + + @override + int get hashCode => error.hashCode ^ stackTrace.hashCode; +} diff --git a/core/reactivex/lib/src/utils/forwarding_sink.dart b/core/reactivex/lib/src/utils/forwarding_sink.dart new file mode 100644 index 00000000..65adbd02 --- /dev/null +++ b/core/reactivex/lib/src/utils/forwarding_sink.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; + +/// A enhanced [EventSink] that allows to check if the sink is paused. +abstract class EnhancedEventSink implements EventSink { + /// Whether the subscription would need to buffer events. + bool get isPaused; +} + +/// A [Sink] that supports event hooks. +/// +/// This makes it suitable for certain rx transformers that need to +/// take action after onListen, onPause, onResume or onCancel. +/// +/// The [ForwardingSink] has been designed to handle asynchronous events from +/// [Stream]s. See, for example, [Stream.eventTransformed] which uses +/// `EventSink`s to transform events. +abstract class ForwardingSink { + EnhancedEventSink? _sink; + StreamSubscription? _subscription; + + /// The output sink. + /// @nonVirtual + /// @internal + EnhancedEventSink get sink => + _sink ?? (throw StateError('Must call setSink(sink) before accessing!')); + + /// Set the output sink. + /// @nonVirtual + /// @internal + void setSink(EnhancedEventSink sink) => _sink = sink; + + /// Set the upstream subscription + /// @nonVirtual + /// @internal + void setSubscription(StreamSubscription? subscription) => + _subscription = subscription; + + /// -------------------------------------------------------------------------- + + /// Pause the upstream subscription. + /// @nonVirtual + void pauseSubscription() => _subscription?.pause(); + + /// Resume the upstream subscription. + /// @nonVirtual + void resumeSubscription() => _subscription?.resume(); + + /// -------------------------------------------------------------------------- + + /// Handle data event + void onData(T data); + + /// Handle error event + void onError(Object error, StackTrace st); + + /// Handle close event + void onDone(); + + /// Fires when a listener subscribes on the underlying [Stream]. + /// Returns a [Future] to delay listening to source [Stream]. + FutureOr onListen(); + + /// Fires when a subscriber pauses. + void onPause(); + + /// Fires when a subscriber resumes after a pause. + void onResume(); + + /// Fires when a subscriber cancels. + FutureOr onCancel(); +} + +/// @internal +/// @nodoc +extension EventSinkExtension on EventSink { + /// @internal + /// @nodoc + void addErrorAndStackTrace(ErrorAndStackTrace errorAndSt) => + addError(errorAndSt.error, errorAndSt.stackTrace); +} diff --git a/core/reactivex/lib/src/utils/forwarding_stream.dart b/core/reactivex/lib/src/utils/forwarding_stream.dart new file mode 100644 index 00000000..9d414f41 --- /dev/null +++ b/core/reactivex/lib/src/utils/forwarding_stream.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/forwarding_sink.dart'; +import 'package:angel3_reactivex/src/utils/future.dart'; + +/// @private +/// Helper method which forwards the events from an incoming [Stream] +/// to a new [StreamController]. +/// It captures events such as onListen, onPause, onResume and onCancel, +/// which can be used in pair with a [ForwardingSink] +Stream forwardStream( + Stream stream, + ForwardingSink Function() sinkFactory, [ + bool listenOnlyOnce = false, +]) { + return stream.isBroadcast + ? listenOnlyOnce + ? _forward(stream, sinkFactory) + : _forwardMulti(stream, sinkFactory) + : _forward(stream, sinkFactory); +} + +Stream _forwardMulti( + Stream stream, ForwardingSink Function() sinkFactory) { + return Stream.multi((controller) { + final sink = sinkFactory(); + sink.setSink(_MultiControllerSink(controller)); + + StreamSubscription? subscription; + var cancelled = false; + + void listenToUpstream([void _]) { + if (cancelled) { + return; + } + subscription = stream.listen( + sink.onData, + onError: sink.onError, + onDone: sink.onDone, + ); + sink.setSubscription(subscription); + } + + final futureOrVoid = sink.onListen(); + if (futureOrVoid is Future) { + futureOrVoid.then(listenToUpstream).onError((e, s) { + if (!cancelled && !controller.isClosed) { + controller.addError(e, s); + controller.close(); + } + }); + } else { + listenToUpstream(); + } + + controller.onCancel = () { + cancelled = true; + + final future = subscription?.cancel(); + subscription = null; + sink.setSubscription(null); + + return waitTwoFutures(future, sink.onCancel()); + }; + }, isBroadcast: true); +} + +Stream _forward( + Stream stream, + ForwardingSink Function() sinkFactory, +) { + final controller = stream.isBroadcast + ? StreamController.broadcast(sync: true) + : StreamController(sync: true); + + StreamSubscription? subscription; + var cancelled = false; + late final sink = sinkFactory(); + + controller.onListen = () { + void listenToUpstream([void _]) { + if (cancelled) { + return; + } + subscription = stream.listen( + sink.onData, + onError: sink.onError, + onDone: sink.onDone, + ); + sink.setSubscription(subscription); + + if (!stream.isBroadcast) { + controller.onPause = () { + subscription!.pause(); + sink.onPause(); + }; + controller.onResume = () { + subscription!.resume(); + sink.onResume(); + }; + } + } + + sink.setSink(_EnhancedEventSink(controller)); + final futureOrVoid = sink.onListen(); + if (futureOrVoid is Future) { + futureOrVoid.then(listenToUpstream).onError((e, s) { + if (!cancelled && !controller.isClosed) { + controller.addError(e, s); + controller.close(); + } + }); + } else { + listenToUpstream(); + } + }; + controller.onCancel = () { + cancelled = true; + + final future = subscription?.cancel(); + subscription = null; + sink.setSubscription(null); + + return waitTwoFutures(future, sink.onCancel()); + }; + return controller.stream; +} + +class _MultiControllerSink implements EventSink, EnhancedEventSink { + final MultiStreamController controller; + + _MultiControllerSink(this.controller); + + @override + void add(T event) => controller.addSync(event); + + @override + void addError(Object error, [StackTrace? stackTrace]) => + controller.addErrorSync(error, stackTrace); + + @override + void close() => controller.closeSync(); + + @override + bool get isPaused => controller.isPaused; +} + +class _EnhancedEventSink implements EnhancedEventSink { + final StreamController _controller; + + _EnhancedEventSink(this._controller); + + @override + void add(T event) => _controller.add(event); + + @override + void addError(Object error, [StackTrace? stackTrace]) => + _controller.addError(error, stackTrace); + + @override + void close() => _controller.close(); + + @override + bool get isPaused => _controller.isPaused; +} diff --git a/core/reactivex/lib/src/utils/future.dart b/core/reactivex/lib/src/utils/future.dart new file mode 100644 index 00000000..77e241f8 --- /dev/null +++ b/core/reactivex/lib/src/utils/future.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +/// @internal +/// An optimized version of [Future.wait]. +FutureOr waitTwoFutures(Future? f1, FutureOr f2) => f1 == null + ? f2 + : f2 is Future + ? Future.wait([f1, f2]).then(_ignore) + : f1; + +/// @internal +/// An optimized version of [Future.wait]. +Future? waitFuturesList(List> futures) { + switch (futures.length) { + case 0: + return null; + case 1: + return futures[0]; + default: + return Future.wait(futures).then(_ignore); + } +} + +/// Helper function to ignore future callback +void _ignore(Object? _) {} diff --git a/core/reactivex/lib/src/utils/min_max.dart b/core/reactivex/lib/src/utils/min_max.dart new file mode 100644 index 00000000..729d44cd --- /dev/null +++ b/core/reactivex/lib/src/utils/min_max.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +/// @private +/// Helper method which find max value or min value in a stream +/// +/// When the stream is done, the returned future is completed with +/// the largest value or smallest value at that time. +/// +/// If the stream is empty, the returned future is completed with +/// an error. +/// If the stream emits an error, or the call to [comparator] throws, +/// the returned future is completed with that error, +/// and processing is stopped. +Future minMax(Stream stream, bool findMin, Comparator? comparator) { + var completer = Completer(); + var seenFirst = false; + + late StreamSubscription subscription; + late T accumulator; + late Comparator comparatorNotNull; + + Future cancelAndCompleteError(Object e, StackTrace st) async { + await subscription.cancel(); + + completer.completeError(e, st); + } + + void onData(T element) async { + if (seenFirst) { + try { + accumulator = findMin + ? (comparatorNotNull(element, accumulator) < 0 + ? element + : accumulator) + : (comparatorNotNull(element, accumulator) > 0 + ? element + : accumulator); + } catch (e, st) { + await cancelAndCompleteError(e, st); + } + return; + } + + accumulator = element; + seenFirst = true; + try { + comparatorNotNull = comparator ?? + () { + if (element is Comparable) { + return Comparable.compare as Comparator; + } else { + throw StateError( + 'Please provide a comparator for type $T, because it is not comparable'); + } + }(); + } catch (e, st) { + await cancelAndCompleteError(e, st); + } + } + + void onDone() { + if (seenFirst) { + completer.complete(accumulator); + } else { + completer.completeError(StateError('No element')); + } + } + + subscription = stream.listen( + onData, + onError: completer.completeError, + onDone: onDone, + cancelOnError: true, + ); + return completer.future; +} diff --git a/core/reactivex/lib/src/utils/notification.dart b/core/reactivex/lib/src/utils/notification.dart new file mode 100644 index 00000000..fec6172f --- /dev/null +++ b/core/reactivex/lib/src/utils/notification.dart @@ -0,0 +1,169 @@ +import 'package:angel3_reactivex/src/utils/error_and_stacktrace.dart'; + +/// The type of event used in [StreamNotification] +enum NotificationKind { + /// Specifies a data event + data, + + /// Specifies a done event + done, + + /// Specifies an error event + error +} + +/// A class that encapsulates the [NotificationKind] of event, value of the event in case of +/// onData, or the Error in the case of onError. + +/// A container object that wraps the [NotificationKind] of event (OnData, OnDone, OnError), +/// and the item or error that was emitted. In the case of onDone, no data is +/// emitted as part of the [StreamNotification]. +abstract class StreamNotification { + /// References the [NotificationKind] of this [StreamNotification] event. + final NotificationKind kind; + + const StreamNotification._(this.kind); + + /// Constructs a [StreamNotification] with [NotificationKind.data] and wraps a [value] + factory StreamNotification.data(T value) => DataNotification(value); + + /// Constructs a [StreamNotification] with [NotificationKind.done]. + const factory StreamNotification.done() = DoneNotification; + + /// Constructs a [StreamNotification] with [NotificationKind.error] and wraps an [error] and [stackTrace] + factory StreamNotification.error(Object error, [StackTrace? stackTrace]) => + ErrorNotification._internal(error, stackTrace); +} + +/// Provides extension methods on [StreamNotification]. +extension StreamNotificationExtensions on StreamNotification { + /// A test to determine if this [StreamNotification] wraps a data event. + bool get isData => kind == NotificationKind.data; + + /// A test to determine if this [StreamNotification] wraps a done event. + bool get isDone => kind == NotificationKind.done; + + /// A test to determine if this [StreamNotification] wraps an error event. + bool get isError => kind == NotificationKind.error; + + /// Returns data if [kind] is [NotificationKind.data], + /// otherwise throws a [TypeError] error. + /// See also [dataValueOrNull]. + T get requireDataValue => (this as DataNotification).value; + + /// Returns data if [kind] is [NotificationKind.data], + /// otherwise returns null. + T? get dataValueOrNull { + final self = this; + return self is DataNotification ? self.value : null; + } + + /// Returns error and stack trace if [kind] is [NotificationKind.error], + /// otherwise throws a [TypeError] error. + ErrorAndStackTrace get requireErrorAndStackTrace => + (this as ErrorNotification).errorAndStackTrace; + + /// Returns error and stack trace if [kind] is [NotificationKind.error], + /// otherwise returns null. + ErrorAndStackTrace? get errorAndStackTraceOrNull { + final self = this; + return self is ErrorNotification ? self.errorAndStackTrace : null; + } + + /// Invokes the appropriate function on the [StreamNotification] based on the [kind]. + @pragma('vm:prefer-inline') + @pragma('dart2js:prefer-inline') + R when({ + required R Function(T value) data, + required R Function() done, + required R Function(ErrorAndStackTrace) error, + }) { + final self = this; + if (self is DataNotification) { + return data(self.value); + } + + if (self is DoneNotification) { + return done(); + } + + if (self is ErrorNotification) { + return error(self.errorAndStackTrace); + } + + throw StateError('Unknown notification $self'); + } +} + +/// A notification representing a data event from a [Stream]. +class DataNotification extends StreamNotification { + /// The value of the data event. + final T value; + + /// Constructs a [DataNotification] with the provided [value]. + const DataNotification(this.value) : super._(NotificationKind.data); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DataNotification && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + String toString() => 'DataNotification{value: $value}'; +} + +/// A notification representing a done event from a [Stream]. +class DoneNotification extends StreamNotification { + /// Constructs a [DoneNotification]. + const DoneNotification() : super._(NotificationKind.done); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DoneNotification && runtimeType == other.runtimeType; + + @override + int get hashCode => 0; + + @override + String toString() => 'DoneNotification{}'; +} + +/// A notification representing an error event from a [Stream]. +class ErrorNotification extends StreamNotification { + /// The wrapped error and stack trace, if applicable + final ErrorAndStackTrace errorAndStackTrace; + + /// The error of the error event. + Object get error => errorAndStackTrace.error; + + /// The stack trace of the error event, if available. + StackTrace? get stackTrace => errorAndStackTrace.stackTrace; + + /// Constructs an [ErrorNotification] with the provided [errorAndStackTrace]. + const ErrorNotification(this.errorAndStackTrace) + : super._(NotificationKind.error); + + /// Constructs an [ErrorNotification] with the provided [error] and [stackTrace]. + factory ErrorNotification._internal(Object error, StackTrace? stackTrace) => + ErrorNotification(ErrorAndStackTrace(error, stackTrace)); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ErrorNotification && + runtimeType == other.runtimeType && + errorAndStackTrace == other.errorAndStackTrace; + + @override + int get hashCode => errorAndStackTrace.hashCode; + + @override + String toString() => + 'ErrorNotification{error: $error, stackTrace: $stackTrace}'; +} diff --git a/core/reactivex/lib/src/utils/subscription.dart b/core/reactivex/lib/src/utils/subscription.dart new file mode 100644 index 00000000..d3500d9e --- /dev/null +++ b/core/reactivex/lib/src/utils/subscription.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/utils/future.dart'; + +/// @internal +/// Extensions for [Iterable] of [StreamSubscription]s. +extension StreamSubscriptionsIterableExtensions + on Iterable> { + /// @internal + /// Pause all subscriptions. + void pauseAll([Future? resumeSignal]) { + for (final s in this) { + s.pause(resumeSignal); + } + } + + /// @internal + /// Resume all subscriptions. + void resumeAll() { + for (final s in this) { + s.resume(); + } + } +} + +/// @internal +/// Extensions for [Iterable] of [StreamSubscription]s. +extension StreamSubscriptionsIterableExtension + on Iterable> { + /// @internal + /// Cancel all subscriptions. + Future? cancelAll() => + waitFuturesList([for (final s in this) s.cancel()]); +} diff --git a/core/reactivex/lib/streams.dart b/core/reactivex/lib/streams.dart new file mode 100644 index 00000000..79f57b3d --- /dev/null +++ b/core/reactivex/lib/streams.dart @@ -0,0 +1,23 @@ +library rx_streams; + +export 'src/streams/combine_latest.dart'; +export 'src/streams/concat.dart'; +export 'src/streams/concat_eager.dart'; +export 'src/streams/connectable_stream.dart'; +export 'src/streams/defer.dart'; +export 'src/streams/fork_join.dart'; +export 'src/streams/from_callable.dart'; +export 'src/streams/merge.dart'; +export 'src/streams/never.dart'; +export 'src/streams/race.dart'; +export 'src/streams/range.dart'; +export 'src/streams/repeat.dart'; +export 'src/streams/replay_stream.dart'; +export 'src/streams/retry.dart'; +export 'src/streams/retry_when.dart'; +export 'src/streams/sequence_equal.dart'; +export 'src/streams/switch_latest.dart'; +export 'src/streams/timer.dart'; +export 'src/streams/using.dart'; +export 'src/streams/value_stream.dart'; +export 'src/streams/zip.dart'; diff --git a/core/reactivex/lib/subjects.dart b/core/reactivex/lib/subjects.dart new file mode 100644 index 00000000..77bc4725 --- /dev/null +++ b/core/reactivex/lib/subjects.dart @@ -0,0 +1,6 @@ +library rx_subjects; + +export 'src/subjects/behavior_subject.dart'; +export 'src/subjects/publish_subject.dart'; +export 'src/subjects/replay_subject.dart'; +export 'src/subjects/subject.dart'; diff --git a/core/reactivex/lib/transformers.dart b/core/reactivex/lib/transformers.dart new file mode 100644 index 00000000..e6ff9717 --- /dev/null +++ b/core/reactivex/lib/transformers.dart @@ -0,0 +1,42 @@ +library rx_transformers; + +export 'src/transformers/backpressure/buffer.dart'; +export 'src/transformers/backpressure/debounce.dart'; +export 'src/transformers/backpressure/pairwise.dart'; +export 'src/transformers/backpressure/sample.dart'; +export 'src/transformers/backpressure/throttle.dart'; +export 'src/transformers/backpressure/window.dart'; +export 'src/transformers/default_if_empty.dart'; +export 'src/transformers/delay.dart'; +export 'src/transformers/delay_when.dart'; +export 'src/transformers/dematerialize.dart'; +export 'src/transformers/distinct_unique.dart'; +export 'src/transformers/do.dart'; +export 'src/transformers/end_with.dart'; +export 'src/transformers/end_with_many.dart'; +export 'src/transformers/exhaust_map.dart'; +export 'src/transformers/flat_map.dart'; +export 'src/transformers/group_by.dart'; +export 'src/transformers/ignore_elements.dart'; +export 'src/transformers/interval.dart'; +export 'src/transformers/map_not_null.dart'; +export 'src/transformers/map_to.dart'; +export 'src/transformers/materialize.dart'; +export 'src/transformers/max.dart'; +export 'src/transformers/min.dart'; +export 'src/transformers/on_error_resume.dart'; +export 'src/transformers/scan.dart'; +export 'src/transformers/skip_last.dart'; +export 'src/transformers/skip_until.dart'; +export 'src/transformers/start_with.dart'; +export 'src/transformers/start_with_many.dart'; +export 'src/transformers/switch_if_empty.dart'; +export 'src/transformers/switch_map.dart'; +export 'src/transformers/take_last.dart'; +export 'src/transformers/take_until.dart'; +export 'src/transformers/take_while_inclusive.dart'; +export 'src/transformers/time_interval.dart'; +export 'src/transformers/timestamp.dart'; +export 'src/transformers/where_not_null.dart'; +export 'src/transformers/where_type.dart'; +export 'src/transformers/with_latest_from.dart'; diff --git a/core/reactivex/lib/utils.dart b/core/reactivex/lib/utils.dart new file mode 100644 index 00000000..58925167 --- /dev/null +++ b/core/reactivex/lib/utils.dart @@ -0,0 +1,5 @@ +library rx_utils; + +export 'src/utils/composite_subscription.dart'; +export 'src/utils/error_and_stacktrace.dart'; +export 'src/utils/notification.dart'; diff --git a/core/reactivex/pubspec.yaml b/core/reactivex/pubspec.yaml new file mode 100644 index 00000000..e54c0bf9 --- /dev/null +++ b/core/reactivex/pubspec.yaml @@ -0,0 +1,25 @@ +name: angel3_reactivex +version: 0.28.0 +description: > + angel3_reactivex is an implementation of the popular ReactiveX api for asynchronous + programming, leveraging the native Dart Streams api. +repository: https://github.com/ReactiveX/angel3_reactivex + +topics: + - angel3_reactivex + - reactive-programming + - streams + - observables + - rx + +environment: + sdk: '>=2.12.0 <4.0.0' + +dev_dependencies: + lints: ^1.0.1 + stack_trace: ^1.10.0 + test: ^1.17.12 + +screenshots: + - description: The angel3_reactivex package logo. + path: screenshots/logo.png diff --git a/core/reactivex/screenshots/logo.png b/core/reactivex/screenshots/logo.png new file mode 100644 index 00000000..1ba0f821 Binary files /dev/null and b/core/reactivex/screenshots/logo.png differ diff --git a/core/reactivex/test/rxdart_test.dart b/core/reactivex/test/rxdart_test.dart new file mode 100644 index 00000000..ef41ddc8 --- /dev/null +++ b/core/reactivex/test/rxdart_test.dart @@ -0,0 +1,187 @@ +library test.rx; + +import 'streams/combine_latest_test.dart' as combine_latest_test; +import 'streams/concat_eager_test.dart' as concat_eager_test; +import 'streams/concat_test.dart' as concat_test; +import 'streams/defer_test.dart' as defer_test; +import 'streams/fork_join_test.dart' as fork_join_test; +import 'streams/from_callable_test.dart' as from_callable_test; +import 'streams/merge_test.dart' as merge_test; +import 'streams/never_test.dart' as never_test; +import 'streams/publish_connectable_stream_test.dart' + as publish_connectable_stream_test; +import 'streams/race_test.dart' as race_test; +import 'streams/range_test.dart' as range_test; +import 'streams/repeat_test.dart' as repeat_test; +import 'streams/replay_connectable_stream_test.dart' + as replay_connectable_stream_test; +import 'streams/retry_test.dart' as retry_test; +import 'streams/retry_when_test.dart' as retry_when_test; +import 'streams/sequence_equals_test.dart' as sequence_equals_test; +import 'streams/switch_latest_test.dart' as switch_latest_test; +import 'streams/timer_test.dart' as timer_test; +import 'streams/using_test.dart' as using_test; +import 'streams/value_connectable_stream_test.dart' + as value_connectable_stream_test; +import 'streams/zip_test.dart' as zip_test; +import 'subject/behavior_subject_test.dart' as behaviour_subject_test; +import 'subject/publish_subject_test.dart' as publish_subject_test; +import 'subject/replay_subject_test.dart' as replay_subject_test; +import 'transformers/backpressure/buffer_count_test.dart' as buffer_count_test; +import 'transformers/backpressure/buffer_test.dart' as buffer_test; +import 'transformers/backpressure/buffer_test_test.dart' as buffer_test_test; +import 'transformers/backpressure/buffer_time_test.dart' as buffer_time_test; +import 'transformers/backpressure/debounce_test.dart' as debounce_test; +import 'transformers/backpressure/debounce_time_test.dart' + as debounce_time_test; +import 'transformers/backpressure/pairwise_test.dart' as pairwise_test; +import 'transformers/backpressure/sample_test.dart' as sample_test; +import 'transformers/backpressure/sample_time_test.dart' as sample_time_test; +import 'transformers/backpressure/throttle_test.dart' as throttle_test; +import 'transformers/backpressure/throttle_time_test.dart' + as throttle_time_test; +import 'transformers/backpressure/window_count_test.dart' as window_count_test; +import 'transformers/backpressure/window_test.dart' as window_test; +import 'transformers/backpressure/window_test_test.dart' as window_test_test; +import 'transformers/backpressure/window_time_test.dart' as window_time_test; +import 'transformers/concat_with_test.dart' as concat_with_test; +import 'transformers/default_if_empty_test.dart' as default_if_empty_test; +import 'transformers/delay_test.dart' as delay_test; +import 'transformers/delay_when_test.dart' as delay_when_test; +import 'transformers/dematerialize_test.dart' as dematerialize_test; +import 'transformers/distinct_test.dart' as distinct_test; +import 'transformers/distinct_unique_test.dart' as distinct_unique_test; +import 'transformers/do_test.dart' as do_test; +import 'transformers/end_with_many_test.dart' as end_with_many_test; +import 'transformers/end_with_test.dart' as end_with_test; +import 'transformers/exhaust_map_test.dart' as exhaust_map_test; +import 'transformers/flat_map_iterable_test.dart' as flat_map_iterable_test; +import 'transformers/flat_map_test.dart' as flat_map_test; +import 'transformers/group_by_test.dart' as group_by_test; +import 'transformers/ignore_elements_test.dart' as ignore_elements_test; +import 'transformers/interval_test.dart' as interval_test; +import 'transformers/join_test.dart' as join_test; +import 'transformers/map_not_null_test.dart' as map_not_null_test; +import 'transformers/map_to_test.dart' as map_to_test; +import 'transformers/materialize_test.dart' as materialize_test; +import 'transformers/merge_with_test.dart' as merge_with_test; +import 'transformers/on_error_return_test.dart' as on_error_resume_test; +import 'transformers/on_error_return_test.dart' as on_error_return_test; +import 'transformers/on_error_return_with_test.dart' + as on_error_return_with_test; +import 'transformers/scan_test.dart' as scan_test; +import 'transformers/skip_last_test.dart' as skip_last_test; +import 'transformers/skip_until_test.dart' as skip_until_test; +import 'transformers/start_with_many_test.dart' as start_with_many_test; +import 'transformers/start_with_test.dart' as start_with_test; +import 'transformers/switch_if_empty_test.dart' as switch_if_empty_test; +import 'transformers/switch_map_test.dart' as switch_map_test; +import 'transformers/take_last_test.dart' as take_last_test; +import 'transformers/take_until_test.dart' as take_until_test; +import 'transformers/take_while_inclusive_test.dart' + as take_while_inclusive_test; +import 'transformers/time_interval_test.dart' as time_interval_test; +import 'transformers/timeout_test.dart' as timeout_test; +import 'transformers/timestamp_test.dart' as timestamp_test; +import 'transformers/where_not_null_test.dart' as where_not_null_test; +import 'transformers/where_type_test.dart' as where_type_test; +import 'transformers/with_latest_from_test.dart' as with_latest_from_test; +import 'transformers/zip_with_test.dart' as zip_with_test; +import 'utils/composite_subscription_test.dart' as composite_subscription_test; +import 'utils/notification_test.dart' as notification_test; + +void main() { + // Streams + combine_latest_test.main(); + concat_eager_test.main(); + concat_test.main(); + defer_test.main(); + fork_join_test.main(); + from_callable_test.main(); + merge_test.main(); + never_test.main(); + range_test.main(); + race_test.main(); + repeat_test.main(); + retry_test.main(); + retry_when_test.main(); + sequence_equals_test.main(); + switch_latest_test.main(); + using_test.main(); + zip_test.main(); + + // StreamTransformers + concat_with_test.main(); + default_if_empty_test.main(); + delay_test.main(); + delay_when_test.main(); + dematerialize_test.main(); + distinct_test.main(); + distinct_unique_test.main(); + do_test.main(); + end_with_test.main(); + end_with_many_test.main(); + exhaust_map_test.main(); + flat_map_test.main(); + flat_map_iterable_test.main(); + group_by_test.main(); + ignore_elements_test.main(); + interval_test.main(); + join_test.main(); + map_not_null_test.main(); + map_to_test.main(); + materialize_test.main(); + merge_with_test.main(); + on_error_resume_test.main(); + on_error_return_test.main(); + on_error_return_with_test.main(); + scan_test.main(); + skip_last_test.main(); + skip_until_test.main(); + start_with_many_test.main(); + start_with_test.main(); + switch_if_empty_test.main(); + switch_map_test.main(); + take_last_test.main(); + take_until_test.main(); + take_while_inclusive_test.main(); + time_interval_test.main(); + timeout_test.main(); + timestamp_test.main(); + timer_test.main(); + where_not_null_test.main(); + where_type_test.main(); + with_latest_from_test.main(); + zip_with_test.main(); + + // Backpressure + buffer_test.main(); + buffer_count_test.main(); + buffer_test_test.main(); + buffer_time_test.main(); + debounce_test.main(); + debounce_time_test.main(); + pairwise_test.main(); + sample_test.main(); + sample_time_test.main(); + throttle_test.main(); + throttle_time_test.main(); + window_test.main(); + window_count_test.main(); + window_test_test.main(); + window_time_test.main(); + + // Subjects + behaviour_subject_test.main(); + publish_subject_test.main(); + replay_subject_test.main(); + + // Connectable Streams + value_connectable_stream_test.main(); + replay_connectable_stream_test.main(); + publish_connectable_stream_test.main(); + + // Utilities + composite_subscription_test.main(); + notification_test.main(); +} diff --git a/core/reactivex/test/streams/combine_latest_test.dart b/core/reactivex/test/streams/combine_latest_test.dart new file mode 100644 index 00000000..bd03e098 --- /dev/null +++ b/core/reactivex/test/streams/combine_latest_test.dart @@ -0,0 +1,394 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +Stream get streamA => + Stream.periodic(const Duration(milliseconds: 1), (int count) => count) + .take(3); + +Stream get streamB => Stream.fromIterable(const [1, 2, 3, 4]); + +Stream get streamC { + final controller = StreamController() + ..add(true) + ..close(); + + return controller.stream; +} + +void main() { + test('Rx.combineLatestList', () async { + final combined = Rx.combineLatestList([ + Stream.fromIterable([1, 2, 3]), + Stream.value(2), + Stream.value(3), + ]); + + expect( + combined, + emitsInOrder([ + [1, 2, 3], + [2, 2, 3], + [3, 2, 3], + ]), + ); + }); + + test('Rx.combineLatestList.iterate.once', () async { + var iterationCount = 0; + + final combined = Rx.combineLatestList(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + combined, + emitsInOrder([ + [1, 2, 3], + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); + + test('Rx.combineLatestList.empty', () async { + final combined = Rx.combineLatestList([]); + expect(combined, emitsDone); + }); + + test('Rx.combineLatest', () async { + final combined = Rx.combineLatest( + [ + Stream.fromIterable([1, 2, 3]), + Stream.value(2), + Stream.value(3), + ], + (values) => values.fold(0, (acc, val) => acc + val), + ); + + expect( + combined, + emitsInOrder([6, 7, 8]), + ); + }); + + test('Rx.combineLatest3', () async { + const expectedOutput = ['0 4 true', '1 4 true', '2 4 true']; + var count = 0; + + final stream = Rx.combineLatest3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) { + return '$aValue $bValue $cValue'; + }); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result.compareTo(expectedOutput[count++]), 0); + }, count: 3)); + }); + + test('Rx.combineLatest3.single.subscription', () async { + final stream = Rx.combineLatest3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) { + return '$aValue $bValue $cValue'; + }); + + stream.listen(null); + await expectLater(() => stream.listen((_) {}), throwsA(isStateError)); + }); + + test('Rx.combineLatest2', () async { + const expected = [ + [1, 2], + [2, 2] + ]; + var count = 0; + + var a = Stream.fromIterable(const [1, 2]), b = Stream.value(2); + + final stream = + Rx.combineLatest2(a, b, (int first, int second) => [first, second]); + + stream.listen(expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.combineLatest2.throws', () async { + var a = Stream.value(1), b = Stream.value(2); + + final stream = Rx.combineLatest2(a, b, (int first, int second) { + throw Exception(); + }); + + stream.listen(null, onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.combineLatest3', () async { + const expected = [1, '2', 3.0]; + + var a = Stream.value(1), + b = Stream.value('2'), + c = Stream.value(3.0); + + final stream = Rx.combineLatest3(a, b, c, + (int first, String second, double third) => [first, second, third]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest4', () async { + const expected = [1, 2, 3, 4]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4); + + final stream = Rx.combineLatest4( + a, + b, + c, + d, + (int first, int second, int third, int fourth) => + [first, second, third, fourth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest5', () async { + const expected = [1, 2, 3, 4, 5]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5); + + final stream = Rx.combineLatest5( + a, + b, + c, + d, + e, + (int first, int second, int third, int fourth, int fifth) => + [first, second, third, fourth, fifth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest6', () async { + const expected = [1, 2, 3, 4, 5, 6]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6); + + final stream = Rx.combineLatest6( + a, + b, + c, + d, + e, + f, + (int first, int second, int third, int fourth, int fifth, int sixth) => + [first, second, third, fourth, fifth, sixth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest7', () async { + const expected = [1, 2, 3, 4, 5, 6, 7]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7); + + final stream = Rx.combineLatest7( + a, + b, + c, + d, + e, + f, + g, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh) => + [first, second, third, fourth, fifth, sixth, seventh]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest8', () async { + const expected = [1, 2, 3, 4, 5, 6, 7, 8]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8); + + final stream = Rx.combineLatest8( + a, + b, + c, + d, + e, + f, + g, + h, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth) => + [first, second, third, fourth, fifth, sixth, seventh, eighth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest9', () async { + const expected = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8), + i = Stream.value(9); + + final stream = Rx.combineLatest9( + a, + b, + c, + d, + e, + f, + g, + h, + i, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth, int ninth) => + [ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth, + ninth + ]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.combineLatest.asBroadcastStream', () async { + final stream = Rx.combineLatest3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) { + return '$aValue $bValue $cValue'; + }).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.combineLatest.error.shouldThrowA', () async { + final streamWithError = Rx.combineLatest4(Stream.value(1), Stream.value(1), + Stream.value(1), Stream.error(Exception()), + (int aValue, int bValue, int cValue, dynamic _) { + return '$aValue $bValue $cValue $_'; + }); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.combineLatest.error.shouldThrowB', () async { + final streamWithError = + Rx.combineLatest3(Stream.value(1), Stream.value(1), Stream.value(1), + (int aValue, int bValue, int cValue) { + throw Exception('oh noes!'); + }); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + /*test('Rx.combineLatest.error.shouldThrowC', () { + expect( + () => Rx.combineLatest3(Stream.value(1), + Stream.just(1), Stream.value(1), null), + throwsArgumentError); + }); + + test('Rx.combineLatest.error.shouldThrowD', () { + expect(() => CombineLatestStream(null, null), throwsArgumentError); + }); + + test('Rx.combineLatest.error.shouldThrowE', () { + expect(() => CombineLatestStream(>[], null), + throwsArgumentError); + });*/ + + test('Rx.combineLatest.pause.resume', () async { + final first = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [1, 2, 3, 4][index]), + second = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [5, 6, 7, 8][index]), + last = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [9, 10, 11, 12][index]); + + late StreamSubscription> subscription; + // ignore: deprecated_member_use + subscription = Rx.combineLatest3( + first, second, last, (int a, int b, int c) => [a, b, c]) + .listen(expectAsync1((value) { + expect(value.elementAt(0), 1); + expect(value.elementAt(1), 5); + expect(value.elementAt(2), 9); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); +} diff --git a/core/reactivex/test/streams/concat_eager_test.dart b/core/reactivex/test/streams/concat_eager_test.dart new file mode 100644 index 00000000..bb77624b --- /dev/null +++ b/core/reactivex/test/streams/concat_eager_test.dart @@ -0,0 +1,185 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +List> _getStreams() { + var a = Stream.fromIterable(const [0, 1, 2]), + b = Stream.fromIterable(const [3, 4, 5]); + + return [a, b]; +} + +List> _getStreamsIncludingEmpty() { + var a = Stream.fromIterable(const [0, 1, 2]), + b = Stream.fromIterable(const [3, 4, 5]), + c = Stream.empty(); + + return [c, a, b]; +} + +void main() { + test('Rx.concatEager', () async { + const expectedOutput = [0, 1, 2, 3, 4, 5]; + var count = 0; + + final stream = Rx.concatEager(_getStreams()); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concatEager.single', () async { + final stream = Rx.concatEager([ + Stream.fromIterable([1, 2, 3, 4, 5]) + ]); + + await expectLater(stream, emitsInOrder([1, 2, 3, 4, 5, emitsDone])); + }); + + test('Rx.concatEager.eagerlySubscription', () async { + var subscribed2 = false; + var subscribed3 = false; + + final stream = Rx.concatEager([ + Rx.timer(1, Duration(milliseconds: 100)).doOnDone( + expectAsync0(() => expect(subscribed2 && subscribed3, true))), + Rx.timer([2, 3, 4], Duration(milliseconds: 100)) + .exhaustMap((v) => Stream.fromIterable(v)) + .doOnListen(() => subscribed2 = true) + .doOnDone(expectAsync0(() => expect(subscribed3, true))), + Rx.timer(5, Duration(milliseconds: 100)) + .doOnListen(() => subscribed3 = true), + ]); + + await expectLater( + stream, + emitsInOrder([ + 1, + 2, + 3, + 4, + 5, + emitsDone, + ]), + ); + }); + + test('Rx.concatEager.single.subscription', () async { + final stream = Rx.concatEager(_getStreams()); + + stream.listen(null); + await expectLater(() => stream.listen((_) {}), throwsA(isStateError)); + }); + + test('Rx.concatEager.withEmptyStream', () async { + const expectedOutput = [0, 1, 2, 3, 4, 5]; + var count = 0; + + final stream = Rx.concatEager(_getStreamsIncludingEmpty()); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concatEager.withBroadcastStreams', () async { + const expectedOutput = [1, 2, 3, 4, 99, 98, 97, 96, 999, 998, 997]; + final ctrlA = StreamController.broadcast(), + ctrlB = StreamController.broadcast(), + ctrlC = StreamController.broadcast(); + var x = 0, y = 100, z = 1000, count = 0; + + Timer.periodic(const Duration(milliseconds: 10), (_) { + ctrlA.add(++x); + ctrlB.add(--y); + + if (x <= 3) ctrlC.add(--z); + + if (x == 3) ctrlC.close(); + + if (x == 4) { + _.cancel(); + + ctrlA.close(); + ctrlB.close(); + } + }); + + final stream = Rx.concatEager([ctrlA.stream, ctrlB.stream, ctrlC.stream]); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concatEager.asBroadcastStream', () async { + final stream = Rx.concatEager(_getStreams()).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.concatEager.error.shouldThrowA', () async { + final streamWithError = + Rx.concatEager(_getStreams()..add(Stream.error(Exception()))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.concatEager.pause.resume', () async { + final first = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [1, 2, 3, 4][index]), + second = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [5, 6, 7, 8][index]), + last = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [9, 10, 11, 12][index]); + + late StreamSubscription subscription; + // ignore: deprecated_member_use + subscription = + Rx.concatEager([first, second, last]).listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); + + test('Rx.concatEager.empty', () { + expect(Rx.concatEager(const []), emitsDone); + }); + + test('Rx.concatEager.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.concatEager(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([ + 1, + 2, + 3, + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); +} diff --git a/core/reactivex/test/streams/concat_test.dart b/core/reactivex/test/streams/concat_test.dart new file mode 100644 index 00000000..daacc24c --- /dev/null +++ b/core/reactivex/test/streams/concat_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +List> _getStreams() { + var a = Stream.fromIterable(const [0, 1, 2]), + b = Stream.fromIterable(const [3, 4, 5]); + + return [a, b]; +} + +List> _getStreamsIncludingEmpty() { + var a = Stream.fromIterable(const [0, 1, 2]), + b = Stream.fromIterable(const [3, 4, 5]), + c = Stream.empty(); + + return [c, a, b]; +} + +void main() { + test('Rx.concat', () async { + const expectedOutput = [0, 1, 2, 3, 4, 5]; + var count = 0; + + final stream = Rx.concat(_getStreams()); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concatEager.single.subscription', () async { + final stream = Rx.concat(_getStreams()); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.concat.withEmptyStream', () async { + const expectedOutput = [0, 1, 2, 3, 4, 5]; + var count = 0; + + final stream = Rx.concat(_getStreamsIncludingEmpty()); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concat.withBroadcastStreams', () async { + const expectedOutput = [1, 2, 3, 4]; + final ctrlA = StreamController.broadcast(), + ctrlB = StreamController.broadcast(), + ctrlC = StreamController.broadcast(); + var x = 0, y = 100, z = 1000, count = 0; + + Timer.periodic(const Duration(milliseconds: 1), (_) { + ctrlA.add(++x); + ctrlB.add(--y); + + if (x <= 3) ctrlC.add(--z); + + if (x == 3) ctrlC.close(); + + if (x == 4) { + _.cancel(); + + ctrlA.close(); + ctrlB.close(); + } + }); + + final stream = Rx.concat([ctrlA.stream, ctrlB.stream, ctrlC.stream]); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.concat.asBroadcastStream', () async { + final stream = Rx.concat(_getStreams()).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.concat.error.shouldThrowA', () async { + final streamWithError = + Rx.concat(_getStreams()..add(Stream.error(Exception()))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.concat.empty', () { + expect(Rx.concat(const []), emitsDone); + }); + + test('Rx.concat.single', () { + expect( + Rx.concat([Stream.value(1)]), + emitsInOrder([1, emitsDone]), + ); + }); + + test('Rx.concat.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.concat(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([ + 1, + 2, + 3, + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); +} diff --git a/core/reactivex/test/streams/defer_test.dart b/core/reactivex/test/streams/defer_test.dart new file mode 100644 index 00000000..5ff00cc1 --- /dev/null +++ b/core/reactivex/test/streams/defer_test.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.defer', () async { + const value = 1; + + final stream = _getDeferStream(); + + stream.listen(expectAsync1((actual) { + expect(actual, value); + }, count: 1)); + }); + + test('Rx.defer.multiple.listeners', () async { + const value = 1; + + final stream = _getBroadcastDeferStream(); + + stream.listen(expectAsync1((actual) { + expect(actual, value); + }, count: 1)); + + stream.listen(expectAsync1((actual) { + expect(actual, value); + }, count: 1)); + }); + + test('Rx.defer.streamFactory.called', () async { + var count = 0; + + Stream streamFactory() { + ++count; + return Stream.value(1); + } + + var deferStream = DeferStream( + streamFactory, + reusable: false, + ); + + expect(count, 0); + + deferStream.listen( + expectAsync1((_) { + expect(count, 1); + }), + ); + }); + + test('Rx.defer.reusable', () async { + const value = 1; + + final stream = Rx.defer( + () => Stream.fromFuture( + Future.delayed( + Duration(seconds: 1), + () => value, + ), + ), + reusable: true, + ); + + stream.listen( + expectAsync1( + (actual) => expect(actual, value), + count: 1, + ), + ); + stream.listen( + expectAsync1( + (actual) => expect(actual, value), + count: 1, + ), + ); + }); + + test('Rx.defer.single.subscription', () async { + final stream = _getDeferStream(); + + try { + stream.listen(null); + stream.listen(null); + expect(true, false); + } catch (e) { + expect(e, isStateError); + } + }); + + test('Rx.defer.error.shouldThrow.A', () async { + final streamWithError = Rx.defer(() => _getErroneousStream()); + + streamWithError.listen(null, + onError: expectAsync1((Exception e) { + expect(e, isException); + }, count: 1)); + }); + + test('Rx.defer.error.shouldThrow.B', () { + final deferStream1 = Rx.defer(() => throw Exception()); + expect( + deferStream1, + emitsInOrder([emitsError(isException), emitsDone]), + ); + + final deferStream2 = Rx.defer(() => throw Exception(), reusable: true); + expect( + deferStream2, + emitsInOrder([emitsError(isException), emitsDone]), + ); + }); +} + +Stream _getDeferStream() => Rx.defer(() => Stream.value(1)); + +Stream _getBroadcastDeferStream() => + Rx.defer(() => Stream.value(1)).asBroadcastStream(); + +Stream _getErroneousStream() { + final controller = StreamController(); + + controller.addError(Exception()); + controller.close(); + + return controller.stream; +} diff --git a/core/reactivex/test/streams/fork_join_test.dart b/core/reactivex/test/streams/fork_join_test.dart new file mode 100644 index 00000000..5708581f --- /dev/null +++ b/core/reactivex/test/streams/fork_join_test.dart @@ -0,0 +1,452 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +Stream get streamA => + Stream.periodic(const Duration(milliseconds: 1), (int count) => count) + .take(3); + +Stream get streamB => Stream.fromIterable(const [1, 2, 3, 4]); + +Stream get streamC { + final controller = StreamController() + ..add(true) + ..close(); + + return controller.stream; +} + +void main() { + test('Rx.forkJoinList', () async { + final combined = Rx.forkJoinList([ + Stream.fromIterable([1, 2, 3]), + Stream.value(2), + Stream.value(3), + ]); + + await expectLater( + combined, + emitsInOrder([ + [3, 2, 3], + emitsDone + ]), + ); + }); + + test('Rx.forkJoin.nullable', () { + expect( + ForkJoinStream.join2( + Stream.value(null), + Stream.value(1), + (a, b) => '$a $b', + ), + emitsInOrder([ + 'null 1', + emitsDone, + ]), + ); + }); + + test('Rx.forkJoin.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.forkJoinList(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([ + [1, 2, 3], + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); + + test('Rx.forkJoin.empty', () { + expect(Rx.forkJoinList([]), emitsDone); + }); + + test('Rx.forkJoinList.singleStream', () async { + final combined = Rx.forkJoinList([ + Stream.fromIterable([1, 2, 3]) + ]); + + await expectLater( + combined, + emitsInOrder([ + [3], + emitsDone + ]), + ); + }); + + test('Rx.forkJoin', () async { + final combined = Rx.forkJoin( + [ + Stream.fromIterable([1, 2, 3]), + Stream.value(2), + Stream.value(3), + ], + (values) => values.fold(0, (acc, val) => acc + val), + ); + + await expectLater( + combined, + emitsInOrder([8, emitsDone]), + ); + }); + + test('Rx.forkJoin3', () async { + final stream = Rx.forkJoin3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) => '$aValue $bValue $cValue'); + + await expectLater(stream, emitsInOrder(['2 4 true', emitsDone])); + }); + + test('Rx.forkJoin3.single.subscription', () async { + final stream = Rx.forkJoin3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) => '$aValue $bValue $cValue'); + + await expectLater( + stream, + emitsInOrder(['2 4 true', emitsDone]), + ); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.forkJoin2', () async { + var a = Stream.fromIterable(const [1, 2]), b = Stream.value(2); + + final stream = + Rx.forkJoin2(a, b, (int first, int second) => [first, second]); + + await expectLater( + stream, + emitsInOrder([ + [2, 2], + emitsDone + ])); + }); + + test('Rx.forkJoin2.throws', () async { + var a = Stream.value(1), b = Stream.value(2); + + final stream = Rx.forkJoin2(a, b, (int first, int second) { + throw Exception(); + }); + + stream.listen(null, onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.forkJoin3', () async { + var a = Stream.value(1), + b = Stream.value('2'), + c = Stream.value(3.0); + + final stream = Rx.forkJoin3(a, b, c, + (int first, String second, double third) => [first, second, third]); + + await expectLater( + stream, + emitsInOrder([ + const [1, '2', 3.0], + emitsDone + ])); + }); + + test('Rx.forkJoin4', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4); + + final stream = Rx.forkJoin4( + a, + b, + c, + d, + (int first, int second, int third, int fourth) => + [first, second, third, fourth]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4], + emitsDone + ])); + }); + + test('Rx.forkJoin5', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5); + + final stream = Rx.forkJoin5( + a, + b, + c, + d, + e, + (int first, int second, int third, int fourth, int fifth) => + [first, second, third, fourth, fifth]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4, 5], + emitsDone + ])); + }); + + test('Rx.forkJoin6', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6); + + final stream = Rx.combineLatest6( + a, + b, + c, + d, + e, + f, + (int first, int second, int third, int fourth, int fifth, int sixth) => + [first, second, third, fourth, fifth, sixth]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4, 5, 6], + emitsDone + ])); + }); + + test('Rx.forkJoin7', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7); + + final stream = Rx.forkJoin7( + a, + b, + c, + d, + e, + f, + g, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh) => + [first, second, third, fourth, fifth, sixth, seventh]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4, 5, 6, 7], + emitsDone + ])); + }); + + test('Rx.forkJoin8', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8); + + final stream = Rx.forkJoin8( + a, + b, + c, + d, + e, + f, + g, + h, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth) => + [first, second, third, fourth, fifth, sixth, seventh, eighth]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4, 5, 6, 7, 8], + emitsDone + ])); + }); + + test('Rx.forkJoin9', () async { + var a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8), + i = Stream.value(9); + + final stream = Rx.forkJoin9( + a, + b, + c, + d, + e, + f, + g, + h, + i, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth, int ninth) => + [ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth, + ninth + ]); + + await expectLater( + stream, + emitsInOrder([ + const [1, 2, 3, 4, 5, 6, 7, 8, 9], + emitsDone + ])); + }); + + test('Rx.forkJoin.asBroadcastStream', () async { + final stream = Rx.forkJoin3(streamA, streamB, streamC, + (int aValue, int bValue, bool cValue) => '$aValue $bValue $cValue') + .asBroadcastStream(); + +// listen twice on same stream + stream.listen(null); + stream.listen(null); +// code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.forkJoin.error.shouldThrowA', () async { + final streamWithError = Rx.forkJoin4( + Stream.value(1), + Stream.value(1), + Stream.value(1), + Stream.error(Exception()), + (int aValue, int bValue, int cValue, dynamic _) => + '$aValue $bValue $cValue $_'); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + }), cancelOnError: true); + }); + + test('Rx.forkJoin.error.shouldThrowB', () async { + final streamWithError = + Rx.forkJoin3(Stream.value(1), Stream.value(1), Stream.value(1), + (int aValue, int bValue, int cValue) { + throw Exception('oh noes!'); + }); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.forkJoin.pause.resume', () async { + final first = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [1, 2, 3, 4][index]).take(4), + second = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [5, 6, 7, 8][index]).take(4), + last = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [9, 10, 11, 12][index]).take(4); + + late StreamSubscription> subscription; + subscription = + Rx.forkJoin3(first, second, last, (int a, int b, int c) => [a, b, c]) + .listen(expectAsync1((value) { + expect(value.elementAt(0), 4); + expect(value.elementAt(1), 8); + expect(value.elementAt(2), 12); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); + + test('Rx.forkJoin.completed', () async { + final stream = Rx.forkJoin2( + Stream.empty(), + Stream.value(1), + (int a, int b) => a + b, + ); + await expectLater( + stream, + emitsInOrder([emitsError(isStateError), emitsDone]), + ); + }); + + test('Rx.forkJoin.error.shouldThrowC', () async { + final stream = Rx.forkJoin2( + Stream.value(1), + Stream.error(Exception()).concatWith([ + Rx.timer( + 2, + const Duration(milliseconds: 100), + ) + ]), + (int a, int b) => a + b, + ); + await expectLater( + stream, + emitsInOrder([emitsError(isException), 3, emitsDone]), + ); + }); + + test('Rx.forkJoin.error.shouldThrowD', () async { + final stream = Rx.forkJoin2( + Stream.value(1), + Stream.error(Exception()).concatWith([ + Rx.timer( + 2, + const Duration(milliseconds: 100), + ) + ]), + (int a, int b) => a + b, + ); + + stream.listen( + expectAsync1((value) {}, count: 0), + onError: expectAsync2( + (Object e, StackTrace s) => expect(e, isException), + count: 1, + ), + cancelOnError: true, + ); + }); +} diff --git a/core/reactivex/test/streams/from_callable_test.dart b/core/reactivex/test/streams/from_callable_test.dart new file mode 100644 index 00000000..69b7dca7 --- /dev/null +++ b/core/reactivex/test/streams/from_callable_test.dart @@ -0,0 +1,130 @@ +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.fromCallable.sync', () { + var called = false; + + var stream = Rx.fromCallable(() { + called = true; + return 2; + }); + + expect(called, false); + expectLater(stream, emitsInOrder([2, emitsDone])); + expect(called, true); + }); + + test('Rx.fromCallable.async', () { + var called = false; + + var stream = FromCallableStream(() async { + called = true; + await Future.delayed(const Duration(milliseconds: 10)); + return 2; + }); + + expect(called, false); + expectLater(stream, emitsInOrder([2, emitsDone])); + expect(called, true); + }); + + test('Rx.fromCallable.reusable', () { + var stream = Rx.fromCallable(() => 2, reusable: true); + expect(stream.isBroadcast, isTrue); + + stream.listen(null); + stream.listen(null); + + expect(true, true); + }); + + test('Rx.fromCallable.singleSubscription', () { + { + var stream = Rx.fromCallable(() => + Future.delayed(const Duration(milliseconds: 10), () => 'Value')); + + expect(stream.isBroadcast, isFalse); + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + } + + { + var stream = Rx.fromCallable(() => Future.error(Exception())); + + expect(stream.isBroadcast, isFalse); + stream.listen(null, onError: (Object e) {}); + expect( + () => stream.listen(null, onError: (Object e) {}), throwsStateError); + } + }); + + test('Rx.fromCallable.asBroadcastStream', () async { + final stream = Rx.fromCallable(() => 2).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.fromCallable.sync.shouldThrow', () { + var stream = Rx.fromCallable(() => throw Exception()); + + expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + }); + + test('Rx.fromCallable.async.shouldThrow', () { + { + var stream = Rx.fromCallable(() async => throw Exception()); + + expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + } + + { + var stream = Rx.fromCallable(() => Future.error(Exception())); + + expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + } + }); + + test('Rx.fromCallable.sync.pause.resume', () { + var stream = Rx.fromCallable(() => 'Value'); + + stream + .listen( + expectAsync1( + (v) => expect(v, 'Value'), + count: 1, + ), + ) + .pause(Future.delayed(const Duration(milliseconds: 50))); + }); + + test('Rx.fromCallable.async.pause.resume', () { + var stream = Rx.fromCallable(() async { + await Future.delayed(const Duration(milliseconds: 10)); + return 'Value'; + }); + + stream + .listen( + expectAsync1( + (v) => expect(v, 'Value'), + count: 1, + ), + ) + .pause(Future.delayed(const Duration(milliseconds: 50))); + }); +} diff --git a/core/reactivex/test/streams/merge_test.dart b/core/reactivex/test/streams/merge_test.dart new file mode 100644 index 00000000..b70975e7 --- /dev/null +++ b/core/reactivex/test/streams/merge_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +List> _getStreams() { + var a = Stream.periodic(const Duration(milliseconds: 1), (count) => count) + .take(3), + b = Stream.fromIterable(const [1, 2, 3, 4]); + + return [a, b]; +} + +void main() { + test('Rx.merge', () async { + final stream = Rx.merge(_getStreams()); + + await expectLater(stream, emitsInOrder(const [1, 2, 3, 4, 0, 1, 2])); + }); + + test('Rx.merge.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.merge(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([ + 1, + 2, + 3, + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); + + test('Rx.merge.single.subscription', () async { + final stream = Rx.merge(_getStreams()); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.merge.asBroadcastStream', () async { + final stream = Rx.merge(_getStreams()).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.merge.error.shouldThrowA', () async { + final streamWithError = + Rx.merge(_getStreams()..add(Stream.error(Exception()))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.merge.pause.resume', () async { + final first = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [1, 2, 3, 4][index]), + second = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [5, 6, 7, 8][index]), + last = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [9, 10, 11, 12][index]); + + late StreamSubscription subscription; + // ignore: deprecated_member_use + subscription = Rx.merge([first, second, last]).listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); + + test('Rx.merge.empty', () { + expect(Rx.merge(const []), emitsDone); + }); +} diff --git a/core/reactivex/test/streams/never_test.dart b/core/reactivex/test/streams/never_test.dart new file mode 100644 index 00000000..42549637 --- /dev/null +++ b/core/reactivex/test/streams/never_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('NeverStream', () async { + var onDataCalled = false, onDoneCalled = false, onErrorCalled = false; + + final stream = NeverStream(); + + final subscription = stream.listen( + expectAsync1((_) { + onDataCalled = true; + }, count: 0), + onError: expectAsync2((Exception e, StackTrace s) { + onErrorCalled = false; + }, count: 0), + onDone: expectAsync0(() { + onDataCalled = true; + }, count: 0)); + + await Future.delayed(Duration(milliseconds: 10)); + + await subscription.cancel(); + + // We do not expect onData, onDone, nor onError to be called, as [never] + // streams emit no items or errors, and they do not terminate + await expectLater(onDataCalled, isFalse); + await expectLater(onDoneCalled, isFalse); + await expectLater(onErrorCalled, isFalse); + }); + + test('NeverStream.single.subscription', () async { + final stream = NeverStream(); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.never', () async { + var onDataCalled = false, onDoneCalled = false, onErrorCalled = false; + + final stream = Rx.never(); + + final subscription = stream.listen( + expectAsync1((_) { + onDataCalled = true; + }, count: 0), + onError: expectAsync2((Exception e, StackTrace s) { + onErrorCalled = false; + }, count: 0), + onDone: expectAsync0(() { + onDataCalled = true; + }, count: 0)); + + await Future.delayed(Duration(milliseconds: 10)); + + await subscription.cancel(); + + // We do not expect onData, onDone, nor onError to be called, as [never] + // streams emit no items or errors, and they do not terminate + await expectLater(onDataCalled, isFalse); + await expectLater(onDoneCalled, isFalse); + await expectLater(onErrorCalled, isFalse); + }); +} diff --git a/core/reactivex/test/streams/publish_connectable_stream_test.dart b/core/reactivex/test/streams/publish_connectable_stream_test.dart new file mode 100644 index 00000000..4e1e793e --- /dev/null +++ b/core/reactivex/test/streams/publish_connectable_stream_test.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +class MockStream extends Stream { + final Stream stream; + var listenCount = 0; + + MockStream(this.stream); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + ++listenCount; + return stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} + +void main() { + group('PublishConnectableStream', () { + test('should not emit before connecting', () { + final stream = MockStream(Stream.fromIterable(const [1, 2, 3])); + final connectableStream = PublishConnectableStream(stream); + + expect(stream.listenCount, 0); + connectableStream.connect(); + expect(stream.listenCount, 1); + }); + + test('should begin emitting items after connection', () { + final ConnectableStream stream = PublishConnectableStream( + Stream.fromIterable([1, 2, 3])); + + stream.connect(); + + expect(stream, emitsInOrder([1, 2, 3])); + }); + + test('stops emitting after the connection is cancelled', () async { + final ConnectableStream stream = + Stream.fromIterable([1, 2, 3]).publishValue(); + + stream.connect().cancel(); // ignore: unawaited_futures + + expect(stream, neverEmits(anything)); + }); + + test('multicasts a single-subscription stream', () async { + final stream = PublishConnectableStream( + Stream.fromIterable(const [1, 2, 3]), + ).autoConnect(); + + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + }); + + test('can multicast streams', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).publish(); + + stream.connect(); + + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + }); + + test('refcount automatically connects', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).share(); + + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('provide a function to autoconnect that stops listening', () async { + final stream = Stream.fromIterable(const [1, 2, 3]) + .publish() + .autoConnect(connection: (subscription) => subscription.cancel()); + + expect(await stream.isEmpty, true); + }); + + test('refCount cancels source subscription when no listeners remain', + () async { + var isCanceled = false; + + final controller = + StreamController(onCancel: () => isCanceled = true); + final stream = controller.stream.share(); + + StreamSubscription subscription; + subscription = stream.listen(null); + + await subscription.cancel(); + expect(isCanceled, true); + }); + + test('can close share() stream', () async { + final isCanceled = Completer(); + + final controller = StreamController(); + controller.stream + .share() + .doOnCancel(() => isCanceled.complete()) + .listen(null); + + controller.add(true); + await Future.delayed(Duration.zero); + await controller.close(); + + expect(isCanceled.future, completes); + }); + + test( + 'throws StateError when mixing autoConnect, connect and refCount together', + () { + PublishConnectableStream stream() => Stream.value(1).publish(); + + expect( + () => stream() + ..autoConnect() + ..connect(), + throwsStateError, + ); + expect( + () => stream() + ..autoConnect() + ..refCount(), + throwsStateError, + ); + expect( + () => stream() + ..connect() + ..refCount(), + throwsStateError, + ); + }); + + test('calling autoConnect() multiple times returns the same value', () { + final s = Stream.value(1).publish(); + expect(s.autoConnect(), same(s.autoConnect())); + expect(s.autoConnect(), same(s.autoConnect())); + }); + + test('calling connect() multiple times returns the same value', () { + final s = Stream.value(1).publish(); + expect(s.connect(), same(s.connect())); + expect(s.connect(), same(s.connect())); + }); + + test('calling refCount() multiple times returns the same value', () { + final s = Stream.value(1).publish(); + expect(s.refCount(), same(s.refCount())); + expect(s.refCount(), same(s.refCount())); + }); + }); +} diff --git a/core/reactivex/test/streams/race_test.dart b/core/reactivex/test/streams/race_test.dart new file mode 100644 index 00000000..ef932e40 --- /dev/null +++ b/core/reactivex/test/streams/race_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +Stream getDelayedStream(int delay, int value) async* { + final completer = Completer(); + + Timer(Duration(milliseconds: delay), () => completer.complete()); + + await completer.future; + + yield value; + yield value + 1; + yield value + 2; +} + +void main() { + test('Rx.race', () async { + final first = getDelayedStream(50, 1), + second = getDelayedStream(60, 2), + last = getDelayedStream(70, 3); + var expected = 1; + + Rx.race([first, second, last]).listen(expectAsync1((result) { + // test to see if the combined output matches + expect(result.compareTo(expected++), 0); + }, count: 3)); + }); + + test('Rx.race.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.race(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([1, emitsDone]), + ); + expect(iterationCount, 1); + }); + + test('Rx.race.single.subscription', () async { + final first = getDelayedStream(50, 1); + + final stream = Rx.race([first]); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.race.asBroadcastStream', () async { + final first = getDelayedStream(50, 1), + second = getDelayedStream(60, 2), + last = getDelayedStream(70, 3); + + final stream = Rx.race([first, second, last]).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.race.shouldThrowB', () async { + final stream = Rx.race([Stream.error(Exception('oh noes!'))]); + + // listen twice on same stream + stream.listen(null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException))); + }); + + test('Rx.race.pause.resume', () async { + final first = getDelayedStream(50, 1), + second = getDelayedStream(60, 2), + last = getDelayedStream(70, 3); + + late StreamSubscription subscription; + // ignore: deprecated_member_use + subscription = Rx.race([first, second, last]).listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); + + test('Rx.race.empty', () { + expect(Rx.race(const []), emitsDone); + }); + + test('Rx.race.single', () { + expect( + Rx.race([Stream.value(1)]), + emitsInOrder([ + 1, + emitsDone, + ]), + ); + }); + + test('Rx.race.cancel.throws', () async { + Stream stream() { + final controller = StreamController(); + controller.onCancel = () async { + throw Exception('Exception when cancelling!'); + }; + + return Rx.race([ + controller.stream, + Rx.concat([ + Rx.timer(1, const Duration(milliseconds: 100)), + Rx.timer(2, const Duration(milliseconds: 100)), + ]), + ]); + } + + await expectLater( + stream(), + emitsInOrder([1, emitsError(isException), 2, emitsDone]), + ); + + await expectLater( + stream().take(1), + emitsInOrder([1, emitsDone]), + ); + }); +} diff --git a/core/reactivex/test/streams/range_test.dart b/core/reactivex/test/streams/range_test.dart new file mode 100644 index 00000000..e57739f1 --- /dev/null +++ b/core/reactivex/test/streams/range_test.dart @@ -0,0 +1,52 @@ +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('RangeStream', () async { + final expected = const [1, 2, 3]; + var count = 0; + + final stream = RangeStream(1, 3); + + stream.listen(expectAsync1((actual) { + expect(actual, expected[count++]); + }, count: expected.length)); + }); + + test('RangeStream.single.subscription', () async { + final stream = RangeStream(1, 5); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('RangeStream.single', () async { + final stream = RangeStream(1, 1); + + stream.listen(expectAsync1((actual) { + expect(actual, 1); + }, count: 1)); + }); + + test('RangeStream.reverse', () async { + final expected = const [3, 2, 1]; + var count = 0; + + final stream = RangeStream(3, 1); + + stream.listen(expectAsync1((actual) { + expect(actual, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.range', () async { + final expected = const [1, 2, 3]; + var count = 0; + + final stream = Rx.range(1, 3); + + stream.listen(expectAsync1((actual) { + expect(actual, expected[count++]); + }, count: expected.length)); + }); +} diff --git a/core/reactivex/test/streams/repeat_test.dart b/core/reactivex/test/streams/repeat_test.dart new file mode 100644 index 00000000..f16196f3 --- /dev/null +++ b/core/reactivex/test/streams/repeat_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.repeat', () async { + const retries = 3; + + await expectLater(Rx.repeat(_getRepeatStream('A'), retries), + emitsInOrder(['A0', 'A1', 'A2', emitsDone])); + }); + + test('RepeatStream', () async { + const retries = 3; + + await expectLater(RepeatStream(_getRepeatStream('A'), retries), + emitsInOrder(['A0', 'A1', 'A2', emitsDone])); + }); + + test('RepeatStream.onDone', () async { + const retries = 0; + + await expectLater(RepeatStream(_getRepeatStream('A'), retries), emitsDone); + }); + + test('RepeatStream.infinite.repeats', () async { + await expectLater( + RepeatStream(_getRepeatStream('A')), emitsThrough('A100')); + }); + + test('RepeatStream.single.subscription', () async { + const retries = 3; + + final stream = RepeatStream(_getRepeatStream('A'), retries); + + try { + stream.listen(null); + stream.listen(null); + } catch (e) { + await expectLater(e, isStateError); + } + }); + + test('RepeatStream.asBroadcastStream', () async { + const retries = 3; + + final stream = + RepeatStream(_getRepeatStream('A'), retries).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('RepeatStream.error.shouldThrow', () async { + final streamWithError = RepeatStream(_getErroneusRepeatStream('A'), 2); + + await expectLater( + streamWithError, + emitsInOrder([ + 'A0', + emitsError(TypeMatcher()), + 'A0', + emitsError(TypeMatcher()), + emitsDone + ])); + }); + + test('RepeatStream.pause.resume', () async { + late StreamSubscription subscription; + const retries = 3; + + subscription = RepeatStream(_getRepeatStream('A'), retries) + .listen(expectAsync1((result) { + expect(result, 'A0'); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); +} + +Stream Function(int) _getRepeatStream(String symbol) => + (int repeatIndex) async* { + yield await Future.delayed( + const Duration(milliseconds: 20), () => '$symbol$repeatIndex'); + }; + +Stream Function(int) _getErroneusRepeatStream(String symbol) => + (int repeatIndex) { + return Stream.value('A0') + // Emit the error + .concatWith([Stream.error(Error())]); + }; diff --git a/core/reactivex/test/streams/replay_connectable_stream_test.dart b/core/reactivex/test/streams/replay_connectable_stream_test.dart new file mode 100644 index 00000000..54b1527b --- /dev/null +++ b/core/reactivex/test/streams/replay_connectable_stream_test.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +class MockStream extends Stream { + final Stream stream; + var listenCount = 0; + + MockStream(this.stream); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + ++listenCount; + return stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} + +void main() { + group('ReplayConnectableStream', () { + test('should not emit before connecting', () { + final stream = MockStream(Stream.fromIterable(const [1, 2, 3])); + final connectableStream = ReplayConnectableStream(stream); + + expect(stream.listenCount, 0); + connectableStream.connect(); + expect(stream.listenCount, 1); + }); + + test('should begin emitting items after connection', () { + const items = [1, 2, 3]; + final stream = ReplayConnectableStream(Stream.fromIterable(items)); + + stream.connect(); + + expect(stream, emitsInOrder(items)); + stream.listen(expectAsync1((int i) { + expect(stream.values, items.sublist(0, i)); + }, count: items.length)); + }); + + test('stops emitting after the connection is cancelled', () async { + final ConnectableStream stream = + Stream.fromIterable([1, 2, 3]).publishReplay(); + + stream.connect().cancel(); // ignore: unawaited_futures + + expect(stream, neverEmits(anything)); + }); + + test('stops emitting after the last subscriber unsubscribes', () async { + final Stream stream = + Stream.fromIterable([1, 2, 3]).shareReplay(); + + stream.listen(null).cancel(); // ignore: unawaited_futures + + expect(stream, neverEmits(anything)); + }); + + test('keeps emitting with an active subscription', () async { + final Stream stream = + Stream.fromIterable([1, 2, 3]).shareReplay(); + + stream.listen(null); + stream.listen(null).cancel(); // ignore: unawaited_futures + + expect(stream, emitsInOrder([1, 2, 3])); + }); + + test('multicasts a single-subscription stream', () async { + final Stream stream = ReplayConnectableStream( + Stream.fromIterable([1, 2, 3]), + ).autoConnect(); + + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + }); + + test('replays the max number of items', () async { + final Stream stream = ReplayConnectableStream( + Stream.fromIterable([1, 2, 3]), + maxSize: 2, + ).autoConnect(); + + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + expect(stream, emitsInOrder([1, 2, 3])); + + await Future.delayed(Duration(milliseconds: 200)); + + expect(stream, emitsInOrder([2, 3])); + }); + + test('can multicast streams', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareReplay(); + + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('only holds a certain number of values', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareReplay(); + + expect(stream.values, const []); + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('provides access to all items', () async { + const items = [1, 2, 3]; + var count = 0; + final stream = Stream.fromIterable(const [1, 2, 3]).shareReplay(); + + stream.listen(expectAsync1((int data) { + expect(data, items[count]); + count++; + if (count == items.length) { + expect(stream.values, items); + } + }, count: items.length)); + }); + + test('provides access to a certain number of items', () async { + const items = [1, 2, 3]; + var count = 0; + final stream = + Stream.fromIterable(const [1, 2, 3]).shareReplay(maxSize: 2); + + stream.listen(expectAsync1((data) { + expect(data, items[count]); + count++; + if (count == items.length) { + expect(stream.values, const [2, 3]); + } + }, count: items.length)); + }); + + test('provide a function to autoconnect that stops listening', () async { + final stream = Stream.fromIterable(const [1, 2, 3]) + .publishReplay() + .autoConnect(connection: (subscription) => subscription.cancel()); + + expect(await stream.isEmpty, true); + }); + + test('refCount cancels source subscription when no listeners remain', + () async { + var isCanceled = false; + + final controller = + StreamController(onCancel: () => isCanceled = true); + final stream = controller.stream.shareReplay(); + + StreamSubscription subscription; + subscription = stream.listen(null); + + await subscription.cancel(); + expect(isCanceled, true); + }); + + test('can close shareReplay() stream', () async { + final isCanceled = Completer(); + + final controller = StreamController(); + controller.stream + .shareReplay() + .doOnCancel(() => isCanceled.complete()) + .listen(null); + + controller.add(true); + await Future.delayed(Duration.zero); + await controller.close(); + + expect(isCanceled.future, completes); + }); + + test( + 'throws StateError when mixing autoConnect, connect and refCount together', + () { + ReplayConnectableStream stream() => + Stream.value(1).publishReplay(maxSize: 1); + + expect( + () => stream() + ..autoConnect() + ..connect(), + throwsStateError, + ); + expect( + () => stream() + ..autoConnect() + ..refCount(), + throwsStateError, + ); + + expect( + () => stream() + ..connect() + ..refCount(), + throwsStateError, + ); + }); + + test('calling autoConnect() multiple times returns the same value', () { + final s = Stream.value(1).publishReplay(maxSize: 1); + expect(s.autoConnect(), same(s.autoConnect())); + expect(s.autoConnect(), same(s.autoConnect())); + }); + + test('calling connect() multiple times returns the same value', () { + final s = Stream.value(1).publishReplay(maxSize: 1); + expect(s.connect(), same(s.connect())); + expect(s.connect(), same(s.connect())); + }); + + test('calling refCount() multiple times returns the same value', () { + final s = Stream.value(1).publishReplay(maxSize: 1); + expect(s.refCount(), same(s.refCount())); + expect(s.refCount(), same(s.refCount())); + }); + }); +} diff --git a/core/reactivex/test/streams/retry_test.dart b/core/reactivex/test/streams/retry_test.dart new file mode 100644 index 00000000..10ee4fdd --- /dev/null +++ b/core/reactivex/test/streams/retry_test.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.retry', () async { + const retries = 3; + + await expectLater(Rx.retry(_getRetryStream(retries), retries), + emitsInOrder([1, emitsDone])); + }); + + test('RetryStream', () async { + const retries = 3; + + await expectLater(RetryStream(_getRetryStream(retries), retries), + emitsInOrder([1, emitsDone])); + }); + + test('RetryStream.onDone', () async { + const retries = 3; + + await expectLater(RetryStream(_getRetryStream(retries), retries), + emitsInOrder([1, emitsDone])); + }); + + test('RetryStream.infinite.retries', () async { + await expectLater(RetryStream(_getRetryStream(1000)), + emitsInOrder([1, emitsDone])); + }); + + test('RetryStream.emits.original.items', () async { + const retries = 3; + + await expectLater(RetryStream(_getStreamWithExtras(retries), retries), + emitsInOrder([1, 1, 1, 2, emitsDone])); + }); + + test('RetryStream.single.subscription', () async { + const retries = 3; + + final stream = RetryStream(_getRetryStream(retries), retries); + + try { + stream.listen(null); + stream.listen(null); + } catch (e) { + await expectLater(e, isStateError); + } + }); + + test('RetryStream.asBroadcastStream', () async { + const retries = 3; + + final stream = + RetryStream(_getRetryStream(retries), retries).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('RetryStream.error.shouldThrow', () async { + final streamWithError = RetryStream(_getRetryStream(3), 2); + + await expectLater( + streamWithError, + emitsInOrder( + [ + emitsError(isA()), + emitsError(isA()), + emitsError(isA()), + emitsDone, + ], + ), + ); + }); + + test('RetryStream.error.capturesErrors', () { + RetryStream(_getRetryStream(3), 2).listen( + expectAsync1((_) {}, count: 0), + onError: expectAsync2( + (Object e, StackTrace st) { + expect(e, isA()); + expect(st, isNotNull); + }, + count: 3, + ), + onDone: expectAsync0(() {}, count: 1), + ); + }); + + test('RetryStream.pause.resume', () async { + late StreamSubscription subscription; + const retries = 3; + + subscription = RetryStream(_getRetryStream(retries), retries) + .listen(expectAsync1((result) { + expect(result, 1); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); +} + +Stream Function() _getRetryStream(int failCount) { + var count = 0; + + return () { + if (count < failCount) { + count++; + return Stream.error(Error(), StackTrace.fromString('S')); + } else { + return Stream.value(1); + } + }; +} + +Stream Function() _getStreamWithExtras(int failCount) { + var count = 0; + + return () { + if (count < failCount) { + count++; + + // Emit first item + return Stream.value(1) + // Emit the error + .concatWith([Stream.error(Error())]) + // Emit an extra item, testing that it is not included + .concatWith([Stream.value(1)]); + } else { + return Stream.value(2); + } + }; +} diff --git a/core/reactivex/test/streams/retry_when_test.dart b/core/reactivex/test/streams/retry_when_test.dart new file mode 100644 index 00000000..3d139b6c --- /dev/null +++ b/core/reactivex/test/streams/retry_when_test.dart @@ -0,0 +1,224 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.retryWhen', () { + expect( + Rx.retryWhen(_sourceStream(3), _alwaysThrow), + emitsInOrder([0, 1, 2, emitsDone]), + ); + }); + + test('RetryWhenStream', () { + expect( + RetryWhenStream(_sourceStream(3), _alwaysThrow), + emitsInOrder([0, 1, 2, emitsDone]), + ); + }); + + test('RetryWhenStream.onDone', () { + expect( + RetryWhenStream(_sourceStream(3), _alwaysThrow), + emitsInOrder([0, 1, 2, emitsDone]), + ); + }); + + test('RetryWhenStream.infinite.retries', () { + expect( + RetryWhenStream(_sourceStream(1000, 2), _neverThrow).take(6), + emitsInOrder([0, 1, 0, 1, 0, 1, emitsDone]), + ); + }); + + test('RetryWhenStream.emits.original.items', () { + const retries = 3; + + expect( + RetryWhenStream(_getStreamWithExtras(retries), _neverThrow).take(6), + emitsInOrder([1, 1, 1, 2, emitsDone]), + ); + }); + + test('RetryWhenStream.single.subscription', () { + final stream = RetryWhenStream(_sourceStream(3), _neverThrow); + try { + stream.listen(null); + stream.listen(null); + } catch (e) { + expect(e, isStateError); + } + }); + + test('RetryWhenStream.asBroadcastStream', () { + final stream = + RetryWhenStream(_sourceStream(3), _neverThrow).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + expect(stream.isBroadcast, isTrue); + }); + + test('RetryWhenStream.error.shouldThrow', () { + final streamWithError = RetryWhenStream(_sourceStream(3, 0), _alwaysThrow); + + expect( + streamWithError, + emitsInOrder( + [ + emitsError(0), + emitsError(isA()), + emitsDone, + ], + ), + ); + }); + + test('RetryWhenStream.error.capturesErrors', () async { + final streamWithError = RetryWhenStream(_sourceStream(3, 0), _alwaysThrow); + + await expectLater( + streamWithError, + emitsInOrder([ + emitsError(0), + emitsError(isA()), + emitsDone, + ]), + ); + }); + + test('RetryWhenStream.pause.resume', () async { + late StreamSubscription subscription; + + subscription = RetryWhenStream(_sourceStream(3), _neverThrow) + .listen(expectAsync1((result) { + expect(result, 0); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('RetryWhenStream.cancel.ensureSubStreamCancels', () async { + var isCancelled = false, didStopEmitting = true; + Stream subStream(Object e, StackTrace s) => + Stream.periodic(const Duration(milliseconds: 100), (count) => count) + .doOnData((_) { + if (isCancelled) { + didStopEmitting = false; + } + }); + final subscription = + RetryWhenStream(_sourceStream(3, 0), subStream).listen(null); + + await Future.delayed(const Duration(milliseconds: 250)); + + await subscription.cancel(); + isCancelled = true; + + await Future.delayed(const Duration(milliseconds: 250)); + + expect(didStopEmitting, isTrue); + }); + + test('RetryWhenStream.retryStream.throws.originError', () { + final error = 1; + final stream = Rx.retryWhen( + _sourceStream(3, error), + (error, stackTrace) => Stream.error(error), + ); + expect( + stream, + emitsInOrder([ + 0, + emitsError(error), + emitsDone, + ]), + ); + }); + + test('RetryWhenStream.streamFactory.throws.originError', () { + final error = 1; + final stream = Rx.retryWhen( + _sourceStream(3, error), + (error, stackTrace) => throw error, + ); + expect( + stream, + emitsInOrder([ + 0, + emitsError(error), + emitsDone, + ]), + ); + }); +} + +Stream Function() _sourceStream(int i, [int? throwAt]) { + return throwAt == null + ? () => Stream.fromIterable(range(i)) + : () => + Stream.fromIterable(range(i)).map((i) => i == throwAt ? throw i : i); +} + +Stream _alwaysThrow(dynamic e, StackTrace s) => + Stream.error(Error(), StackTrace.fromString('S')); + +Stream _neverThrow(dynamic e, StackTrace s) => Stream.value(null); + +Stream Function() _getStreamWithExtras(int failCount) { + var count = 0; + + return () { + if (count < failCount) { + count++; + + // Emit first item + return Stream.value(1) + // Emit the error + .concatWith([Stream.error(Error())]) + // Emit an extra item, testing that it is not included + .concatWith([Stream.value(1)]); + } else { + return Stream.value(2); + } + }; +} + +/// Returns an [Iterable] sequence of [int]s. +/// +/// If only one argument is provided, [startOrStop] is the upper bound for +/// the sequence. If two or more arguments are provided, [stop] is the upper +/// bound. +/// +/// The sequence starts at 0 if one argument is provided, or [startOrStop] if +/// two or more arguments are provided. The sequence increments by 1, or [step] +/// if provided. [step] can be negative, in which case the sequence counts down +/// from the starting point and [stop] must be less than the starting point so +/// that it becomes the lower bound. +Iterable range(int startOrStop, [int? stop, int? step]) sync* { + final start = stop == null ? 0 : startOrStop; + stop ??= startOrStop; + step ??= 1; + + if (step == 0) throw ArgumentError('step cannot be 0'); + if (step > 0 && stop < start) { + throw ArgumentError('if step is positive,' + ' stop must be greater than start'); + } + if (step < 0 && stop > start) { + throw ArgumentError('if step is negative,' + ' stop must be less than start'); + } + + for (var value = start; + step < 0 ? value > stop : value < stop; + value += step) { + yield value; + } +} diff --git a/core/reactivex/test/streams/sequence_equals_test.dart b/core/reactivex/test/streams/sequence_equals_test.dart new file mode 100644 index 00000000..4cbc1d77 --- /dev/null +++ b/core/reactivex/test/streams/sequence_equals_test.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.sequenceEqual.equals', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 4, 5])); + + await expectLater(stream, emitsInOrder([true, emitsDone])); + }); + + test('Rx.sequenceEqual.diffTime.equals', () async { + final stream = Rx.sequenceEqual( + Stream.periodic(const Duration(milliseconds: 100), (i) => i + 1) + .take(5), + Stream.fromIterable(const [1, 2, 3, 4, 5])); + + await expectLater(stream, emitsInOrder([true, emitsDone])); + }); + + test('Rx.sequenceEqual.equals.customCompare.equals', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 1, 1, 1, 1]), + Stream.fromIterable(const [2, 2, 2, 2, 2]), + equals: (int? a, int? b) => true); + + await expectLater(stream, emitsInOrder([true, emitsDone])); + }); + + test('Rx.sequenceEqual.diffTime.notEquals', () async { + final stream = Rx.sequenceEqual( + Stream.periodic(const Duration(milliseconds: 100), (i) => i + 1) + .take(5), + Stream.fromIterable(const [1, 1, 1, 1, 1])); + + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.notEquals', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 5, 4])); + + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.equals.customCompare.notEquals', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 1, 1, 1, 1]), + Stream.fromIterable(const [1, 1, 1, 1, 1]), + equals: (int? a, int? b) => false); + + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.notEquals.differentLength', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 4, 5, 6])); + + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.notEquals.differentLength.customCompare.notEquals', + () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 4, 5, 6]), + equals: (int? a, int? b) => true); + + // expect false, + // even if the equals handler always returns true, + // the emitted events length is different + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.equals.errors', () async { + final stream = Rx.sequenceEqual( + Stream.error(ArgumentError('error A')), + Stream.error(ArgumentError('error A')), + errorEquals: (e1, e2) => e1.error.toString() == e2.error.toString(), + ); + + await expectLater(stream, emitsInOrder([true, emitsDone])); + }); + + test('Rx.sequenceEqual.notEquals.errors', () async { + final stream = Rx.sequenceEqual( + Stream.error(ArgumentError('error A')), + Stream.error(ArgumentError('error B')), + errorEquals: (e1, e2) => e1.error.toString() == e2.error.toString(), + ); + + await expectLater(stream, emitsInOrder([false, emitsDone])); + }); + + test('Rx.sequenceEqual.single.subscription', () async { + final stream = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 4, 5])); + + await expectLater(stream, emitsInOrder([true, emitsDone])); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.sequenceEqual.asBroadcastStream', () async { + final future = Rx.sequenceEqual(Stream.fromIterable(const [1, 2, 3, 4, 5]), + Stream.fromIterable(const [1, 2, 3, 4, 5])) + .asBroadcastStream() + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); +} diff --git a/core/reactivex/test/streams/switch_latest_test.dart b/core/reactivex/test/streams/switch_latest_test.dart new file mode 100644 index 00000000..ea3f0552 --- /dev/null +++ b/core/reactivex/test/streams/switch_latest_test.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + group('SwitchLatest', () { + test('emits all values from an emitted Stream', () { + expect( + Rx.switchLatest( + Stream.value( + Stream.fromIterable(const ['A', 'B', 'C']), + ), + ), + emitsInOrder(['A', 'B', 'C', emitsDone]), + ); + }); + + test('only emits values from the latest emitted stream', () { + expect( + Rx.switchLatest(testStream), + emits('C'), + ); + }); + + test('emits errors from the higher order Stream to the listener', () { + expect( + Rx.switchLatest( + Stream>.error(Exception()), + ), + emitsError(isException), + ); + }); + + test('emits errors from the emitted Stream to the listener', () { + expect( + Rx.switchLatest(errorStream), + emitsError(isException), + ); + }); + + test('closes after the last event from the last emitted Stream', () { + expect( + Rx.switchLatest(testStream), + emitsThrough(emitsDone), + ); + }); + + test('closes if the higher order stream is empty', () { + expect( + Rx.switchLatest( + Stream>.empty(), + ), + emitsThrough(emitsDone), + ); + }); + + test('is single subscription', () { + final stream = SwitchLatestStream(testStream); + + expect(stream, emits('C')); + expect(() => stream.listen(null), throwsStateError); + }); + + test('can be paused and resumed', () { + // ignore: cancel_subscriptions + final subscription = + Rx.switchLatest(testStream).listen(expectAsync1((result) { + expect(result, 'C'); + })); + + subscription.pause(); + subscription.resume(); + }); + }); +} + +Stream> get testStream => Stream.fromIterable([ + Rx.timer('A', Duration(seconds: 2)), + Rx.timer('B', Duration(seconds: 1)), + Stream.value('C'), + ]); + +Stream> get errorStream => Stream.fromIterable([ + Rx.timer('A', Duration(seconds: 2)), + Rx.timer('B', Duration(seconds: 1)), + Stream.error(Exception()), + ]); diff --git a/core/reactivex/test/streams/timer_test.dart b/core/reactivex/test/streams/timer_test.dart new file mode 100644 index 00000000..4f5f62ec --- /dev/null +++ b/core/reactivex/test/streams/timer_test.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('TimerStream', () async { + const value = 1; + + final stream = TimerStream(value, Duration(milliseconds: 1)); + + await expectLater(stream, emitsInOrder([value, emitsDone])); + }); + + test('TimerStream.single.subscription', () async { + final stream = TimerStream(1, Duration(milliseconds: 1)); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('TimerStream.pause.resume.A', () async { + const value = 1; + late StreamSubscription subscription; + + final stream = TimerStream(value, Duration(milliseconds: 1)); + + subscription = stream.listen(expectAsync1((actual) { + expect(actual, value); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('TimerStream.pause.resume.B', () async { + const seconds = 2; + const delay = 1; + + var stream = Rx.timer(99, const Duration(seconds: seconds)); + var stopwatch = Stopwatch()..start(); + var subscription = stream.listen(expectAsync1((_) { + stopwatch.stop(); + expect(stopwatch.elapsed.inSeconds, seconds + delay); + })); + + await Future.delayed(const Duration(milliseconds: 100)); + subscription.pause(); + subscription.pause(); + + await Future.delayed(const Duration(seconds: delay)); + + subscription.resume(); + subscription.resume(); + subscription.resume(); + }); + + test('TimerStream.pause.resume.C', () async { + const value = 1; + const delta = Duration(milliseconds: 100); + const duration = Duration(seconds: 1); + final stream = TimerStream(value, duration); + + var elapses = Duration.zero; + late Stopwatch watch; + + void startWatch() => watch = Stopwatch()..start(); + + Future delay() => + Future.delayed(const Duration(milliseconds: 200)); + + void stopWatch() => elapses = elapses + watch.elapsed; + + final subscription = stream.listen(expectAsync1((actual) { + expect(actual, value); + + stopWatch(); + expect( + duration - delta <= elapses && elapses <= duration + delta, + isTrue, + ); + })); + startWatch(); + + await delay(); + + subscription.pause(); + stopWatch(); + + await delay(); + + subscription.resume(); + startWatch(); + }); + + test('TimerStream.single.subscription', () async { + final stream = TimerStream(null, Duration(milliseconds: 1)); + + try { + stream.listen(null); + stream.listen(null); + } catch (e) { + await expectLater(e, isStateError); + } + }); + + test('TimerStream.cancel', () async { + const value = 1; + StreamSubscription subscription; + + final stream = TimerStream(value, Duration(milliseconds: 1)); + + subscription = stream.listen( + expectAsync1((_) { + expect(true, isFalse); + }, count: 0), + onError: expectAsync2((Exception e, StackTrace s) { + expect(true, isFalse); + }, count: 0), + onDone: expectAsync0(() { + expect(true, isFalse); + }, count: 0)); + + await subscription.cancel(); + }); + + test('Rx.timer', () async { + const value = 1; + + final stream = Rx.timer(value, Duration(milliseconds: 5)); + + stream.listen(expectAsync1((actual) { + expect(actual, value); + }), onDone: expectAsync0(() { + expect(true, isTrue); + })); + }); +} diff --git a/core/reactivex/test/streams/using_test.dart b/core/reactivex/test/streams/using_test.dart new file mode 100644 index 00000000..fcce2845 --- /dev/null +++ b/core/reactivex/test/streams/using_test.dart @@ -0,0 +1,378 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +const resourceDuration = Duration(milliseconds: 5); + +class MockResource { + var _closed = false; + + bool get isClosed => _closed; + + MockResource(); + + Future close() { + if (_closed) { + throw StateError('Resource has already been closed.'); + } + _closed = true; + return Future.delayed(resourceDuration); + } + + void closeSync() { + if (_closed) { + throw StateError('Resource has already been closed.'); + } + _closed = true; + } +} + +enum Close { + sync, + async, +} + +enum Create { + sync, + async, +} + +void main() async { + for (final close in Close.values) { + for (final create in Create.values) { + final groupPrefix = + 'Rx.using.${create.toString().toLowerCase()}.${close.toString().toLowerCase()}'; + + group(groupPrefix, () { + late MockResource resource; + var isResourceCreated = false; + + late FutureOr Function() resourceFactory; + late FutureOr Function() resourceFactoryThrows; + + late FutureOr Function(MockResource) disposer; + late FutureOr Function(MockResource) disposerThrows; + + setUp(() { + isResourceCreated = false; + + resourceFactory = () { + switch (create) { + case Create.sync: + isResourceCreated = true; + return resource = MockResource(); + case Create.async: + return Future.delayed( + resourceDuration, + () { + isResourceCreated = true; + return resource = MockResource(); + }, + ); + } + }; + + resourceFactoryThrows = () { + switch (create) { + case Create.sync: + throw Exception(); + case Create.async: + return Future.delayed( + resourceDuration, + () => throw Exception(), + ); + } + }; + + disposer = (resource) { + switch (close) { + case Close.async: + return resource.close(); + case Close.sync: + // ignore: unnecessary_cast + return resource.closeSync() as FutureOr; + } + }; + + disposerThrows = (resource) { + switch (close) { + case Close.async: + return Future.delayed( + resourceDuration, + () => throw Exception(), + ); + case Close.sync: + throw Exception(); + } + }; + }); + + test('$groupPrefix.done', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Stream.value(resource) + .flatMap((_) => Stream.fromIterable([1, 2, 3])), + disposer: disposer, + ); + + await expectLater( + stream, + emitsInOrder([ + 1, + 2, + 3, + emitsDone, + ]), + ); + + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.resourceFactory.throws', () async { + var calledStreamFactory = false; + var callDisposer = false; + + final stream = Rx.using( + resourceFactory: resourceFactoryThrows, + streamFactory: (resource) { + calledStreamFactory = true; + return Rx.range(0, 3); + }, + disposer: (resource) { + callDisposer = true; + return disposer(resource); + }, + ); + + await expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + + expect(isResourceCreated, false); + expect(calledStreamFactory, false); + expect(callDisposer, false); + }); + + test('$groupPrefix.disposer.throws', () async { + final subscription = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Rx.timer(0, resourceDuration), + disposer: disposerThrows, + ).listen(null); + + if (create == Create.async) { + await Future.delayed(resourceDuration * 1.2); + } + + await expectLater( + subscription.cancel(), + throwsException, + ); + }); + + test('$groupPrefix.streamFactory.throws', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => throw Exception(), + disposer: disposer, + ); + + await expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.streamFactory.errors', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Stream.error(Exception()), + disposer: disposer, + ); + + await expectLater( + stream, + emitsInOrder([emitsError(isException), emitsDone]), + ); + + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.cancel.delayed', () async { + const duration = Duration(milliseconds: 200); + + final subscription = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Rx.concat([ + Rx.timer(0, duration), + Stream.error(Exception()), + ]), + disposer: disposer, + ).listen( + null, + cancelOnError: false, + ); + + // ensure the stream has started + await Future.delayed(resourceDuration + duration ~/ 2); + await subscription.cancel(); + await Future.delayed(resourceDuration * 1.2); + + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.cancel.immediately', () async { + final subscription = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Rx.concat([ + Rx.timer(0, const Duration(milliseconds: 10)), + Stream.error(Exception()), + ]), + disposer: disposer, + ).listen( + expectAsync1((v) => expect(true, false), count: 0), + onError: expectAsync2( + (Object e, StackTrace stackTrace) => expect(true, false), + count: 0, + ), + onDone: expectAsync0(() => expect(true, false), count: 0), + ); + + await subscription.cancel(); + await Future.delayed(resourceDuration * 2); + + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.errors.continueOnError', () async { + Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Rx.concat([ + Rx.timer(0, resourceDuration * 2), + Stream.error(Exception()) + ]), + disposer: disposer, + ).listen( + null, + onError: (Object e, StackTrace s) {}, + cancelOnError: false, + ); + + await Future.delayed(resourceDuration * 1.2); + expect(isResourceCreated, true); + expect(resource.isClosed, false); + }); + + test('$groupPrefix.errors.cancelOnError', () async { + Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Stream.error(Exception()), + disposer: disposer, + ).listen( + null, + onError: (Object e, StackTrace s) {}, + cancelOnError: true, + ); + + await Future.delayed(resourceDuration * 1.2); + expect(isResourceCreated, true); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.single.subscription', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Rx.range(0, 3), + disposer: disposer, + ); + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + }); + + test('$groupPrefix.asBroadcastStream', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Stream.periodic( + const Duration(milliseconds: 50), + (i) => i, + ), + disposer: disposer, + ).asBroadcastStream(onCancel: (s) => s.cancel()); + + final s1 = stream.listen(null); + final s2 = stream.listen(null); + + // can reach here + expect(true, true); + + await Future.delayed(resourceDuration * 1.2); + await s1.cancel(); + await s2.cancel(); + expect(resource.isClosed, true); + }); + + test('$groupPrefix.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) => Stream.periodic( + const Duration(milliseconds: 20), + (i) => i, + ), + disposer: disposer, + ).listen( + expectAsync1( + (value) { + subscription.cancel(); + expect(value, 0); + }, + count: 1, + ), + ); + + subscription + .pause(Future.delayed(const Duration(milliseconds: 50))); + }); + + test('$groupPrefix.disposer.order', () async { + final stream = Rx.using( + resourceFactory: resourceFactory, + streamFactory: (resource) { + final controller = StreamController(); + + controller.onListen = () { + controller.add(1); + controller.add(2); + controller.close(); + }; + + controller.onCancel = () async { + expect(resource.isClosed, false); + await Future.delayed(resourceDuration * 10); + expect(resource.isClosed, false); + }; + + return controller.stream; + }, + disposer: disposer, + ).take(1); + + await expectLater( + stream, + emitsInOrder([1, emitsDone]), + ); + }); + }); + } + } +} diff --git a/core/reactivex/test/streams/value_connectable_stream_test.dart b/core/reactivex/test/streams/value_connectable_stream_test.dart new file mode 100644 index 00000000..e27def12 --- /dev/null +++ b/core/reactivex/test/streams/value_connectable_stream_test.dart @@ -0,0 +1,295 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +class MockStream extends Stream { + final Stream stream; + var listenCount = 0; + + MockStream(this.stream); + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + ++listenCount; + return stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} + +void main() { + group('BehaviorConnectableStream', () { + test('should not emit before connecting', () { + final stream = MockStream(Stream.fromIterable(const [1, 2, 3])); + final connectableStream = ValueConnectableStream(stream); + + expect(stream.listenCount, 0); + connectableStream.connect(); + expect(stream.listenCount, 1); + }); + + test('should begin emitting items after connection', () { + var count = 0; + const items = [1, 2, 3]; + final stream = ValueConnectableStream(Stream.fromIterable(items)); + + stream.connect(); + + expect(stream, emitsInOrder(items)); + stream.listen(expectAsync1((i) { + expect(stream.value, items[count]); + count++; + }, count: items.length)); + }); + + test('stops emitting after the connection is cancelled', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).publishValue(); + + stream.connect().cancel(); // ignore: unawaited_futures + + expect(stream, neverEmits(anything)); + }); + + test('stops emitting after the last subscriber unsubscribes', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + + stream.listen(null).cancel(); // ignore: unawaited_futures + + expect(stream, neverEmits(anything)); + }); + + test('keeps emitting with an active subscription', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + + stream.listen(null); + stream.listen(null).cancel(); // ignore: unawaited_futures + + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('multicasts a single-subscription stream', () async { + final stream = ValueConnectableStream( + Stream.fromIterable(const [1, 2, 3]), + ).autoConnect(); + + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('replays the latest item', () async { + final stream = ValueConnectableStream( + Stream.fromIterable(const [1, 2, 3]), + ).autoConnect(); + + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + + await Future.delayed(Duration(milliseconds: 200)); + + expect(stream, emits(3)); + }); + + test('replays the seeded item', () async { + final stream = + ValueConnectableStream.seeded(StreamController().stream, 3) + .autoConnect(); + + expect(stream, emitsInOrder(const [3])); + expect(stream, emitsInOrder(const [3])); + expect(stream, emitsInOrder(const [3])); + + await Future.delayed(Duration(milliseconds: 200)); + + expect(stream, emits(3)); + }); + + test('replays the seeded null item', () async { + final stream = + ValueConnectableStream.seeded(StreamController().stream, null) + .autoConnect(); + + expect(stream, emitsInOrder(const [null])); + expect(stream, emitsInOrder(const [null])); + expect(stream, emitsInOrder(const [null])); + + await Future.delayed(Duration(milliseconds: 200)); + + expect(stream, emits(null)); + }); + + test('can multicast streams', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + expect(stream, emitsInOrder(const [1, 2, 3])); + }); + + test('transform Stream with initial value', () async { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValueSeeded(0); + + expect(stream.value, 0); + expect(stream, emitsInOrder(const [0, 1, 2, 3])); + }); + + test('provides access to the latest value', () async { + const items = [1, 2, 3]; + var count = 0; + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + + stream.listen(expectAsync1((data) { + expect(data, items[count]); + count++; + if (count == items.length) { + expect(stream.value, 3); + } + }, count: items.length)); + }); + + test('provides access to the latest error', () async { + final source = StreamController(); + final stream = ValueConnectableStream(source.stream).autoConnect(); + + source.sink.add(1); + source.sink.add(2); + source.sink.add(3); + source.sink.addError(Exception('error')); + + stream.listen( + null, + onError: expectAsync1((Object error) { + expect(stream.valueOrNull, 3); + expect(stream.value, 3); + expect(stream.hasValue, isTrue); + + expect(stream.errorOrNull, error); + expect(stream.error, error); + expect(stream.hasError, isTrue); + }), + ); + }); + + test('provide a function to autoconnect that stops listening', () async { + final stream = Stream.fromIterable(const [1, 2, 3]) + .publishValue() + .autoConnect(connection: (subscription) => subscription.cancel()); + + expect(await stream.isEmpty, true); + }); + + test('refCount cancels source subscription when no listeners remain', + () async { + { + var isCanceled = false; + + final controller = + StreamController(onCancel: () => isCanceled = true); + final stream = controller.stream.shareValue(); + + StreamSubscription subscription; + subscription = stream.listen(null); + + await subscription.cancel(); + expect(isCanceled, true); + } + + { + var isCanceled = false; + + final controller = + StreamController(onCancel: () => isCanceled = true); + final stream = controller.stream.shareValueSeeded(null); + + StreamSubscription subscription; + subscription = stream.listen(null); + + await subscription.cancel(); + expect(isCanceled, true); + } + }); + + test('can close shareValue() stream', () async { + { + final isCanceled = Completer(); + + final controller = StreamController(); + controller.stream + .shareValue() + .doOnCancel(() => isCanceled.complete()) + .listen(null); + + controller.add(true); + await Future.delayed(Duration.zero); + await controller.close(); + + await expectLater(isCanceled.future, completes); + } + + { + final isCanceled = Completer(); + + final controller = StreamController(); + controller.stream + .shareValueSeeded(false) + .doOnCancel(() => isCanceled.complete()) + .listen(null); + + controller.add(true); + await Future.delayed(Duration.zero); + await controller.close(); + + await expectLater(isCanceled.future, completes); + } + }); + + test( + 'throws StateError when mixing autoConnect, connect and refCount together', + () { + ValueConnectableStream stream() => Stream.value(1).publishValue(); + + expect( + () => stream() + ..autoConnect() + ..connect(), + throwsStateError, + ); + expect( + () => stream() + ..autoConnect() + ..refCount(), + throwsStateError, + ); + expect( + () => stream() + ..connect() + ..refCount(), + throwsStateError, + ); + }); + + test('calling autoConnect() multiple times returns the same value', () { + final s = Stream.value(1).publishValueSeeded(1); + expect(s.autoConnect(), same(s.autoConnect())); + expect(s.autoConnect(), same(s.autoConnect())); + }); + + test('calling connect() multiple times returns the same value', () { + final s = Stream.value(1).publishValueSeeded(1); + expect(s.connect(), same(s.connect())); + expect(s.connect(), same(s.connect())); + }); + + test('calling refCount() multiple times returns the same value', () { + final s = Stream.value(1).publishValueSeeded(1); + expect(s.refCount(), same(s.refCount())); + expect(s.refCount(), same(s.refCount())); + }); + }); +} diff --git a/core/reactivex/test/streams/zip_test.dart b/core/reactivex/test/streams/zip_test.dart new file mode 100644 index 00000000..feb79491 --- /dev/null +++ b/core/reactivex/test/streams/zip_test.dart @@ -0,0 +1,395 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.zip', () async { + expect( + Rx.zip([ + Stream.fromIterable(['A1', 'B1']), + Stream.fromIterable(['A2', 'B2', 'C2']), + ], (values) => values.first + values.last), + emitsInOrder(['A1A2', 'B1B2', emitsDone]), + ); + }); + + test('Rx.zip.empty', () { + expect(Rx.zipList([]), emitsDone); + }); + + test('Rx.zip.single', () { + expect( + Rx.zipList([Stream.value(1)]), + emitsInOrder([ + [1], + emitsDone + ]), + ); + }); + + test('Rx.zip.iterate.once', () async { + var iterationCount = 0; + + final stream = Rx.zipList(() sync* { + ++iterationCount; + yield Stream.value(1); + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + stream, + emitsInOrder([ + [1, 2, 3], + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); + + test('Rx.zipList', () async { + expect( + Rx.zipList([ + Stream.fromIterable(['A1', 'B1']), + Stream.fromIterable(['A2', 'B2', 'C2']), + Stream.fromIterable(['A3', 'B3', 'C3']), + ]), + emitsInOrder([ + ['A1', 'A2', 'A3'], + ['B1', 'B2', 'B3'], + emitsDone + ]), + ); + }); + + test('Rx.zipBasics', () async { + const expectedOutput = [ + [0, 1, true], + [1, 2, false], + [2, 3, true], + [3, 4, false] + ]; + var count = 0; + + final testStream = StreamController() + ..add(true) + ..add(false) + ..add(true) + ..add(false) + ..add(true) + ..close(); // ignore: unawaited_futures + + final stream = Rx.zip3( + Stream.periodic(const Duration(milliseconds: 1), (count) => count) + .take(4), + Stream.fromIterable(const [1, 2, 3, 4, 5, 6, 7, 8, 9]), + testStream.stream, + (int a, int b, bool c) => [a, b, c]); + + stream.listen(expectAsync1((result) { + // test to see if the combined output matches + for (var i = 0, len = result.length; i < len; i++) { + expect(result[i], expectedOutput[count][i]); + } + + count++; + }, count: expectedOutput.length)); + }); + + test('Rx.zipTwo', () async { + const expected = [1, 2]; + + // A purposely emits 2 items, b only 1 + final a = Stream.fromIterable(const [1, 2]), b = Stream.value(2); + + final stream = Rx.zip2(a, b, (int first, int second) => [first, second]); + + // Explicitly adding count: 1. It's important here, and tests the difference + // between zip and combineLatest. If this was combineLatest, the count would + // be two, and a second List would be emitted. + stream.listen(expectAsync1((result) { + expect(result, expected); + }, count: 1)); + }); + + test('Rx.zip3', () async { + // Verify the ability to pass through various types with safety + const expected = [1, '2', 3.0]; + + final a = Stream.value(1), b = Stream.value('2'), c = Stream.value(3.0); + + final stream = Rx.zip3(a, b, c, + (int first, String second, double third) => [first, second, third]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip4', () async { + const expected = [1, 2, 3, 4]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4); + + final stream = Rx.zip4( + a, + b, + c, + d, + (int first, int second, int third, int fourth) => + [first, second, third, fourth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip5', () async { + const expected = [1, 2, 3, 4, 5]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5); + + final stream = Rx.zip5( + a, + b, + c, + d, + e, + (int first, int second, int third, int fourth, int fifth) => + [first, second, third, fourth, fifth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip6', () async { + const expected = [1, 2, 3, 4, 5, 6]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6); + + final stream = Rx.zip6( + a, + b, + c, + d, + e, + f, + (int first, int second, int third, int fourth, int fifth, int sixth) => + [first, second, third, fourth, fifth, sixth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip7', () async { + const expected = [1, 2, 3, 4, 5, 6, 7]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7); + + final stream = Rx.zip7( + a, + b, + c, + d, + e, + f, + g, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh) => + [first, second, third, fourth, fifth, sixth, seventh]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip8', () async { + const expected = [1, 2, 3, 4, 5, 6, 7, 8]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8); + + final stream = Rx.zip8( + a, + b, + c, + d, + e, + f, + g, + h, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth) => + [first, second, third, fourth, fifth, sixth, seventh, eighth]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip9', () async { + const expected = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + + final a = Stream.value(1), + b = Stream.value(2), + c = Stream.value(3), + d = Stream.value(4), + e = Stream.value(5), + f = Stream.value(6), + g = Stream.value(7), + h = Stream.value(8), + i = Stream.value(9); + + final stream = Rx.zip9( + a, + b, + c, + d, + e, + f, + g, + h, + i, + (int first, int second, int third, int fourth, int fifth, int sixth, + int seventh, int eighth, int ninth) => + [ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth, + ninth + ]); + + stream.listen(expectAsync1((result) { + expect(result, expected); + })); + }); + + test('Rx.zip.single.subscription', () async { + final stream = + Rx.zip2(Stream.value(1), Stream.value(1), (int a, int b) => a + b); + + stream.listen(null); + await expectLater(() => stream.listen(null), throwsA(isStateError)); + }); + + test('Rx.zip.asBroadcastStream', () async { + final testStream = StreamController() + ..add(true) + ..add(false) + ..add(true) + ..add(false) + ..add(true) + ..close(); // ignore: unawaited_futures + + final stream = Rx.zip3( + Stream.periodic(const Duration(milliseconds: 1), (count) => count) + .take(4), + Stream.fromIterable(const [1, 2, 3, 4, 5, 6, 7, 8, 9]), + testStream.stream, + (int a, int b, bool c) => [a, b, c]).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.zip.error.shouldThrowA', () async { + final streamWithError = Rx.zip2( + Stream.value(1), + Stream.value(2), + (int a, int b) => throw Exception(), + ); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + /*test('Rx.zip.error.shouldThrowB', () { + expect( + () => Rx.zip2( + Stream.value(1), null, (int a, _) => null), + throwsArgumentError); + }); + + test('Rx.zip.error.shouldThrowC', () { + expect(() => ZipStream(null, () {}), throwsArgumentError); + }); + + test('Rx.zip.error.shouldThrowD', () { + expect(() => ZipStream(>[], () {}), + throwsArgumentError); + });*/ + + test('Rx.zip.pause.resume.A', () async { + late StreamSubscription subscription; + final stream = + Rx.zip2(Stream.value(1), Stream.value(2), (int a, int b) => a + b); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 3); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.zip.pause.resume.B', () async { + final first = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [1, 2, 3, 4][index]), + second = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [5, 6, 7, 8][index]), + last = Stream.periodic(const Duration(milliseconds: 10), + (index) => const [9, 10, 11, 12][index]); + + late StreamSubscription> subscription; + subscription = + Rx.zip3(first, second, last, (num a, num b, num c) => [a, b, c]) + .listen(expectAsync1((value) { + expect(value.elementAt(0), 1); + expect(value.elementAt(1), 5); + expect(value.elementAt(2), 9); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(Future.delayed(const Duration(milliseconds: 80))); + }); +} diff --git a/core/reactivex/test/subject/behavior_subject_test.dart b/core/reactivex/test/subject/behavior_subject_test.dart new file mode 100644 index 00000000..5f51cb3c --- /dev/null +++ b/core/reactivex/test/subject/behavior_subject_test.dart @@ -0,0 +1,1475 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + final throwsValueStreamError = throwsA(isA()); + + group('BehaviorSubject', () { + test('emits the most recently emitted item to every subscriber', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + }); + + test('emits the most recently emitted null item to every subscriber', + () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(null); + + seeded.add(1); + seeded.add(2); + seeded.add(null); + + await expectLater(unseeded.stream, emits(isNull)); + await expectLater(unseeded.stream, emits(isNull)); + await expectLater(unseeded.stream, emits(isNull)); + + await expectLater(seeded.stream, emits(isNull)); + await expectLater(seeded.stream, emits(isNull)); + await expectLater(seeded.stream, emits(isNull)); + }); + + test( + 'emits the most recently emitted item to every subscriber that subscribe to the subject directly', + () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + + await expectLater(unseeded, emits(3)); + await expectLater(unseeded, emits(3)); + await expectLater(unseeded, emits(3)); + + await expectLater(seeded, emits(3)); + await expectLater(seeded, emits(3)); + await expectLater(seeded, emits(3)); + }); + + test('emits errors to every subscriber', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + unseeded.addError(Exception('oh noes!')); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + seeded.addError(Exception('oh noes!')); + + await expectLater(unseeded.stream, emitsError(isException)); + await expectLater(unseeded.stream, emitsError(isException)); + await expectLater(unseeded.stream, emitsError(isException)); + + await expectLater(seeded.stream, emitsError(isException)); + await expectLater(seeded.stream, emitsError(isException)); + await expectLater(seeded.stream, emitsError(isException)); + }); + + test('emits event after error to every subscriber', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.addError(Exception('oh noes!')); + unseeded.add(3); + + seeded.add(1); + seeded.add(2); + seeded.addError(Exception('oh noes!')); + seeded.add(3); + + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + }); + + test('emits errors to every subscriber', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + final exception = Exception('oh noes!'); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + unseeded.addError(exception); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + seeded.addError(exception); + + expect(unseeded.value, 3); + expect(unseeded.valueOrNull, 3); + expect(unseeded.hasValue, true); + + expect(unseeded.error, exception); + expect(unseeded.errorOrNull, exception); + expect(unseeded.hasError, true); + + await expectLater(unseeded, emitsError(exception)); + await expectLater(unseeded, emitsError(exception)); + await expectLater(unseeded, emitsError(exception)); + + expect(seeded.value, 3); + expect(seeded.valueOrNull, 3); + expect(seeded.hasValue, true); + + expect(seeded.error, exception); + expect(seeded.errorOrNull, exception); + expect(seeded.hasError, true); + + await expectLater(seeded, emitsError(exception)); + await expectLater(seeded, emitsError(exception)); + await expectLater(seeded, emitsError(exception)); + }); + + test('can synchronously get the latest value', () { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + + expect(unseeded.value, 3); + expect(unseeded.valueOrNull, 3); + expect(unseeded.hasValue, true); + + expect(seeded.value, 3); + expect(seeded.valueOrNull, 3); + expect(seeded.hasValue, true); + }); + + test('can synchronously get the latest null value', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(null); + + seeded.add(1); + seeded.add(2); + seeded.add(null); + + expect(unseeded.value, isNull); + expect(unseeded.valueOrNull, isNull); + expect(unseeded.hasValue, true); + + expect(seeded.value, isNull); + expect(seeded.valueOrNull, isNull); + expect(seeded.hasValue, true); + }); + + test('emits the seed item if no new items have been emitted', () async { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + await expectLater(subject.stream, emits(1)); + await expectLater(subject.stream, emits(1)); + await expectLater(subject.stream, emits(1)); + }); + + test('emits the null seed item if no new items have been emitted', + () async { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(null); + + await expectLater(subject.stream, emits(isNull)); + await expectLater(subject.stream, emits(isNull)); + await expectLater(subject.stream, emits(isNull)); + }); + + test('can synchronously get the initial value', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + expect(subject.value, 1); + expect(subject.valueOrNull, 1); + expect(subject.hasValue, true); + }); + + test('can synchronously get the initial null value', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(null); + + expect(subject.value, null); + expect(subject.valueOrNull, null); + expect(subject.hasValue, true); + }); + + test('initial value is null when no value has been emitted', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(() => subject.value, throwsValueStreamError); + expect(subject.valueOrNull, null); + expect(subject.hasValue, false); + }); + + test('emits done event to listeners when the subject is closed', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + await expectLater(unseeded.isClosed, isFalse); + await expectLater(seeded.isClosed, isFalse); + + unseeded.add(1); + scheduleMicrotask(() => unseeded.close()); + + seeded.add(1); + scheduleMicrotask(() => seeded.close()); + + await expectLater(unseeded.stream, emitsInOrder([1, emitsDone])); + await expectLater(unseeded.isClosed, isTrue); + + await expectLater(seeded.stream, emitsInOrder([1, emitsDone])); + await expectLater(seeded.isClosed, isTrue); + }); + + test('emits error events to subscribers', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + scheduleMicrotask(() => unseeded.addError(Exception())); + scheduleMicrotask(() => seeded.addError(Exception())); + + await expectLater(unseeded.stream, emitsError(isException)); + await expectLater(seeded.stream, emitsError(isException)); + }); + + test('replays the previously emitted items from addStream', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + await unseeded.addStream(Stream.fromIterable(const [1, 2, 3])); + await seeded.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + await expectLater(unseeded.stream, emits(3)); + + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + await expectLater(seeded.stream, emits(3)); + }); + + test('replays the previously emitted errors from addStream', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + await unseeded.addStream(Stream.error('error'), + cancelOnError: false); + await seeded.addStream(Stream.error('error'), cancelOnError: false); + + await expectLater(unseeded.stream, emitsError('error')); + await expectLater(unseeded.stream, emitsError('error')); + }); + + test('allows items to be added once addStream is complete', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + await subject.addStream(Stream.fromIterable(const [1, 2])); + subject.add(3); + + await expectLater(subject.stream, emits(3)); + }); + + test('allows items to be added once addStream completes with an error', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + unawaited(subject + .addStream(Stream.error(Exception()), cancelOnError: true) + .whenComplete(() => subject.add(1))); + + await expectLater(subject.stream, + emitsInOrder([emitsError(isException), emits(1)])); + }); + + test('does not allow events to be added when addStream is active', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.add(1), throwsStateError); + }); + + test('does not allow errors to be added when addStream is active', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addError(Error()), throwsStateError); + }); + + test('does not allow subject to be closed when addStream is active', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.close(), throwsStateError); + }); + + test( + 'does not allow addStream to add items when previous addStream is active', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addStream(Stream.fromIterable(const [1])), + throwsStateError); + }); + + test('returns onListen callback set in constructor', () async { + void testOnListen() {} + // ignore: close_sinks + final subject = BehaviorSubject(onListen: testOnListen); + + await expectLater(subject.onListen, testOnListen); + }); + + test('sets onListen callback', () async { + void testOnListen() {} + // ignore: close_sinks + final subject = BehaviorSubject(); + + await expectLater(subject.onListen, isNull); + + subject.onListen = testOnListen; + + await expectLater(subject.onListen, testOnListen); + }); + + test('returns onCancel callback set in constructor', () async { + Future onCancel() => Future.value(null); + // ignore: close_sinks + final subject = BehaviorSubject(onCancel: onCancel); + + await expectLater(subject.onCancel, onCancel); + }); + + test('sets onCancel callback', () async { + void testOnCancel() {} + // ignore: close_sinks + final subject = BehaviorSubject(); + + await expectLater(subject.onCancel, isNull); + + subject.onCancel = testOnCancel; + + await expectLater(subject.onCancel, testOnCancel); + }); + + test('reports if a listener is present', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + await expectLater(subject.hasListener, isFalse); + + subject.stream.listen(null); + + await expectLater(subject.hasListener, isTrue); + }); + + test('onPause unsupported', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(subject.isPaused, isFalse); + expect(() => subject.onPause, throwsUnsupportedError); + expect(() => subject.onPause = () {}, throwsUnsupportedError); + }); + + test('onResume unsupported', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(() => subject.onResume, throwsUnsupportedError); + expect(() => subject.onResume = () {}, throwsUnsupportedError); + }); + + test('returns controller sink', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + await expectLater(subject.sink, TypeMatcher>()); + }); + + test('correctly closes done Future', () async { + final subject = BehaviorSubject(); + + scheduleMicrotask(() => subject.close()); + + await expectLater(subject.done, completes); + }); + + test('can be listened to multiple times', () async { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + final stream = subject.stream; + + await expectLater(stream, emits(1)); + await expectLater(stream, emits(1)); + }); + + test('always returns the same stream', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + await expectLater(subject.stream, equals(subject.stream)); + }); + + test('adding to sink has same behavior as adding to Subject itself', + () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + subject.sink.add(1); + + expect(subject.value, 1); + + subject.sink.add(2); + subject.sink.add(3); + + await expectLater(subject.stream, emits(3)); + await expectLater(subject.stream, emits(3)); + await expectLater(subject.stream, emits(3)); + }); + + test('setter `value=` has same behavior as adding to Subject', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + + subject.value = 1; + + expect(subject.value, 1); + + subject.value = 2; + subject.value = 3; + + await expectLater(subject.stream, emits(3)); + await expectLater(subject.stream, emits(3)); + await expectLater(subject.stream, emits(3)); + }); + + test('is always treated as a broadcast Stream', () async { + // ignore: close_sinks + final subject = BehaviorSubject(); + final stream = subject.asyncMap((event) => Future.value(event)); + + expect(subject.isBroadcast, isTrue); + expect(stream.isBroadcast, isTrue); + }); + + test('hasValue returns false for an empty subject', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(subject.hasValue, isFalse); + }); + + test('hasValue returns true for a seeded subject with non-null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + expect(subject.hasValue, isTrue); + }); + + test('hasValue returns true for a seeded subject with null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(null); + + expect(subject.hasValue, isTrue); + }); + + test('hasValue returns true for an unseeded subject after an emission', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + subject.add(1); + + expect(subject.hasValue, isTrue); + }); + + test('hasError returns false for an empty subject', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(subject.hasError, isFalse); + }); + + test('hasError returns false for a seeded subject with non-null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + expect(subject.hasError, isFalse); + }); + + test('hasError returns false for a seeded subject with null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(null); + + expect(subject.hasError, isFalse); + }); + + test('hasError returns false for an unseeded subject after an emission', + () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + subject.add(1); + + expect(subject.hasError, isFalse); + }); + + test('hasError returns true for an unseeded subject after addError', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + subject.add(1); + subject.addError('error'); + + expect(subject.hasError, isTrue); + }); + + test('hasError returns true for a seeded subject after addError', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + subject.addError('error'); + + expect(subject.hasError, isTrue); + }); + + test('error returns null for an empty subject', () { + // ignore: close_sinks + final subject = BehaviorSubject(); + + expect(subject.hasError, isFalse); + expect(subject.errorOrNull, isNull); + expect(() => subject.error, throwsValueStreamError); + }); + + test('error returns null for a seeded subject with non-null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(1); + + expect(subject.hasError, isFalse); + expect(subject.errorOrNull, isNull); + expect(() => subject.error, throwsValueStreamError); + }); + + test('error returns null for a seeded subject with null seed', () { + // ignore: close_sinks + final subject = BehaviorSubject.seeded(null); + + expect(subject.hasError, isFalse); + expect(subject.errorOrNull, isNull); + expect(() => subject.error, throwsValueStreamError); + }); + + test('can synchronously get the latest error', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.add(3); + expect(unseeded.hasError, isFalse); + expect(unseeded.errorOrNull, isNull); + expect(() => unseeded.error, throwsValueStreamError); + + unseeded.addError(Exception('oh noes!')); + expect(unseeded.hasError, isTrue); + expect(unseeded.errorOrNull, isException); + expect(unseeded.error, isException); + + seeded.add(1); + seeded.add(2); + seeded.add(3); + expect(seeded.hasError, isFalse); + expect(seeded.errorOrNull, isNull); + expect(() => seeded.error, throwsValueStreamError); + + seeded.addError(Exception('oh noes!')); + expect(seeded.hasError, isTrue); + expect(seeded.errorOrNull, isException); + expect(seeded.error, isException); + }); + + test('emits event after error to every subscriber', () async { + // ignore: close_sinks + final unseeded = BehaviorSubject(), + // ignore: close_sinks + seeded = BehaviorSubject.seeded(0); + + unseeded.add(1); + unseeded.add(2); + unseeded.addError(Exception('oh noes!')); + expect(unseeded.hasError, isTrue); + expect(unseeded.errorOrNull, isException); + expect(unseeded.error, isException); + unseeded.add(3); + expect(unseeded.hasError, isTrue); + expect(unseeded.errorOrNull, isException); + expect(unseeded.error, isException); + + seeded.add(1); + seeded.add(2); + seeded.addError(Exception('oh noes!')); + expect(seeded.hasError, isTrue); + expect(seeded.errorOrNull, isException); + expect(seeded.error, isException); + seeded.add(3); + expect(seeded.hasError, isTrue); + expect(seeded.errorOrNull, isException); + expect(seeded.error, isException); + }); + + test( + 'issue/350: emits duplicate values when listening multiple times and starting with an Error', + () async { + final subject = BehaviorSubject(); + + subject.addError('error'); + + await subject.close(); + + await expectLater(subject, + emitsInOrder([emitsError('error'), emitsDone])); + await expectLater(subject, + emitsInOrder([emitsError('error'), emitsDone])); + await expectLater(subject, + emitsInOrder([emitsError('error'), emitsDone])); + }); + + test('issue/419: sync behavior', () async { + final subject = BehaviorSubject.seeded(1, sync: true); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null); + + expect(mappedStream.value, equals(1)); + + await subject.close(); + }, skip: true); + + test('issue/419: sync throughput', () async { + final subject = BehaviorSubject.seeded(1, sync: true); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null); + + subject.add(2); + + expect(mappedStream.value, equals(2)); + + await subject.close(); + }, skip: true); + + test('issue/419: async behavior', () async { + final subject = BehaviorSubject.seeded(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null, + onDone: () => expect(mappedStream.value, equals(1))); + + expect(() => mappedStream.value, throwsValueStreamError); + expect(mappedStream.valueOrNull, isNull); + expect(mappedStream.hasValue, false); + + await subject.close(); + }); + + test('issue/419: async throughput', () async { + final subject = BehaviorSubject.seeded(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null, + onDone: () => expect(mappedStream.value, equals(2))); + + subject.add(2); + + expect(() => mappedStream.value, throwsValueStreamError); + expect(mappedStream.valueOrNull, isNull); + expect(mappedStream.hasValue, false); + + await subject.close(); + }); + + test('issue/477: get first after cancelled', () async { + final a = BehaviorSubject.seeded('a'); + final bug = a.switchMap((v) => BehaviorSubject.seeded('b')); + await bug.listen(null).cancel(); + expect(await bug.first, 'b'); + }); + + test('issue/477: get first multiple times', () async { + final a = BehaviorSubject.seeded('a'); + final bug = a.switchMap((_) => BehaviorSubject.seeded('b')); + bug.listen(null); + expect(await bug.first, 'b'); + expect(await bug.first, 'b'); + }); + + test('issue/478: get first multiple times', () async { + final a = BehaviorSubject.seeded('a'); + final b = BehaviorSubject.seeded('b'); + final bug = + Rx.combineLatest2(a, b, (String _a, String _b) => 'ab').shareValue(); + expect(await bug.first, 'ab'); + expect(await bug.first, 'ab'); + }); + + test('angel3_reactivex #477/#500 - a', () async { + final a = BehaviorSubject.seeded('a') + .switchMap((_) => BehaviorSubject.seeded('a')) + ..listen(print); + await pumpEventQueue(); + expect(await a.first, 'a'); + }); + + test('angel3_reactivex #477/#500 - b', () async { + final b = BehaviorSubject.seeded('b') + .map((_) => 'b') + .switchMap((_) => BehaviorSubject.seeded('b')) + ..listen(print); + await pumpEventQueue(); + expect(await b.first, 'b'); + }); + + test('issue/587', () async { + final source = BehaviorSubject.seeded('source'); + final switched = + source.switchMap((value) => BehaviorSubject.seeded('switched')); + var i = 0; + switched.listen((_) => i++); + expect(await switched.first, 'switched'); + expect(i, 1); + expect(await switched.first, 'switched'); + expect(i, 1); + }); + + test('do not update latest value after closed', () { + final seeded = BehaviorSubject.seeded(0); + final unseeded = BehaviorSubject(); + + seeded.add(1); + unseeded.add(1); + + expect(seeded.value, 1); + expect(unseeded.value, 1); + + seeded.close(); + unseeded.close(); + + expect(() => seeded.add(2), throwsStateError); + expect(() => unseeded.add(2), throwsStateError); + expect(() => seeded.addError(Exception()), throwsStateError); + expect(() => unseeded.addError(Exception()), throwsStateError); + + expect(seeded.value, 1); + expect(unseeded.value, 1); + }); + + group('override built-in', () { + test('where', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.where((event) => event.isOdd); + expect(stream, emitsInOrder([1, 3])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.where((event) => event.isOdd); + expect(stream, emitsInOrder([1, 3])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + } + }); + + test('map', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var mapped = behaviorSubject.map((event) => event + 1); + expect(mapped, emitsInOrder([2, 3])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var mapped = behaviorSubject.map((event) => event + 1); + expect(mapped, emitsInOrder([2, 3])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('asyncMap', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var mapped = + behaviorSubject.asyncMap((event) => Future.value(event + 1)); + expect(mapped, emitsInOrder([2, 3])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var mapped = + behaviorSubject.asyncMap((event) => Future.value(event + 1)); + expect(mapped, emitsInOrder([2, 3])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('asyncExpand', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = + behaviorSubject.asyncExpand((event) => Stream.value(event + 1)); + expect(stream, emitsInOrder([2, 3])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = + behaviorSubject.asyncExpand((event) => Stream.value(event + 1)); + expect(stream, emitsInOrder([2, 3])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('handleError', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.handleError( + expectAsync1( + (dynamic e) => expect(e, isException), + count: 1, + ), + ); + + expect( + stream, + emitsInOrder([1, 2]), + ); + + behaviorSubject.addError(Exception()); + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.handleError( + expectAsync1( + (dynamic e) => expect(e, isException), + count: 1, + ), + ); + + expect( + stream, + emitsInOrder([1, 2]), + ); + + behaviorSubject.add(1); + behaviorSubject.addError(Exception()); + behaviorSubject.add(2); + } + }); + + test('expand', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.expand((event) => [event + 1]); + expect(stream, emitsInOrder([2, 3])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.expand((event) => [event + 1]); + expect(stream, emitsInOrder([2, 3])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('transform', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.transform( + IntervalStreamTransformer(const Duration(milliseconds: 100))); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.transform( + IntervalStreamTransformer(const Duration(milliseconds: 100))); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('cast', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.cast(); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.cast(); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + } + }); + + test('take', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.take(2); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.take(2); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + } + }); + + test('takeWhile', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.takeWhile((element) => element <= 2); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.takeWhile((element) => element <= 2); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + } + }); + + test('skip', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.skip(2); + expect(stream, emitsInOrder([3, 4])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.skip(2); + expect(stream, emitsInOrder([3, 4])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + }); + + test('skipWhile', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.skipWhile((element) => element < 3); + expect(stream, emitsInOrder([3, 4])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.skipWhile((element) => element < 3); + expect(stream, emitsInOrder([3, 4])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + }); + + test('distinct', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject.distinct(); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(2); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject.distinct(); + expect(stream, emitsInOrder([1, 2])); + + behaviorSubject.add(1); + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(2); + } + }); + + test('timeout', () { + { + var behaviorSubject = BehaviorSubject.seeded(1); + + var stream = behaviorSubject + .interval(const Duration(milliseconds: 100)) + .timeout( + const Duration(milliseconds: 70), + onTimeout: expectAsync1( + (EventSink sink) {}, + count: 4, + ), + ); + + expect(stream, emitsInOrder([1, 2, 3, 4])); + + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + + { + var behaviorSubject = BehaviorSubject(); + + var stream = behaviorSubject + .interval(const Duration(milliseconds: 100)) + .timeout( + const Duration(milliseconds: 70), + onTimeout: expectAsync1( + (EventSink sink) {}, + count: 4, + ), + ); + + expect(stream, emitsInOrder([1, 2, 3, 4])); + + behaviorSubject.add(1); + behaviorSubject.add(2); + behaviorSubject.add(3); + behaviorSubject.add(4); + } + }); + }); + + test('stream returns a read-only stream', () async { + final subject = BehaviorSubject()..add(1); + + // streams returned by BehaviorSubject are read-only stream, + // ie. they don't support adding events. + expect(subject.stream, isNot(isA>())); + expect(subject.stream, isNot(isA>())); + + expect( + subject.stream, + isA>().having( + (v) => v.value, + 'BehaviorSubject.stream.value', + 1, + ), + ); + + // BehaviorSubject.stream is a broadcast stream + { + final stream = subject.stream; + expect(stream.isBroadcast, isTrue); + await expectLater(stream, emitsInOrder([1])); + await expectLater(stream, emitsInOrder([1])); + } + + // streams returned by the same subject are considered equal, + // but not identical + expect(identical(subject.stream, subject.stream), isFalse); + expect(subject.stream == subject.stream, isTrue); + }); + + group('lastEventOrNull', () { + test('empty subject', () { + final s = BehaviorSubject(); + expect(s.lastEventOrNull, isNull); + expect(s.isLastEventValue, isFalse); + expect(s.isLastEventError, isFalse); + + // the stream has the same value as the subject + expect(s.stream.lastEventOrNull, isNull); + expect(s.stream.isLastEventValue, isFalse); + expect(s.stream.isLastEventError, isFalse); + }); + + test('subject with value', () { + final s = BehaviorSubject.seeded(42); + expect( + s.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.isLastEventValue, isTrue); + expect(s.isLastEventError, isFalse); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.stream.isLastEventValue, isTrue); + expect(s.stream.isLastEventError, isFalse); + }); + + test('subject with error', () { + final s = BehaviorSubject(); + final exception = Exception(); + s.addError(exception, StackTrace.empty); + + expect( + s.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.isLastEventValue, isFalse); + expect(s.isLastEventError, isTrue); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.stream.isLastEventValue, isFalse); + expect(s.stream.isLastEventError, isTrue); + }); + + test('add error and then value', () { + final s = BehaviorSubject(); + final exception = Exception(); + s.addError(exception, StackTrace.empty); + s.add(42); + + expect( + s.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.isLastEventValue, isTrue); + expect(s.isLastEventError, isFalse); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.stream.isLastEventValue, isTrue); + expect(s.stream.isLastEventError, isFalse); + }); + + test('add value and then error', () { + final s = BehaviorSubject(); + s.add(42); + final exception = Exception(); + s.addError(exception, StackTrace.empty); + + expect( + s.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.isLastEventValue, isFalse); + expect(s.isLastEventError, isTrue); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.stream.isLastEventValue, isFalse); + expect(s.stream.isLastEventError, isTrue); + }); + + test('add value and then close', () async { + final s = BehaviorSubject(); + s.add(42); + await s.close(); + + expect( + s.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.isLastEventValue, isTrue); + expect(s.isLastEventError, isFalse); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.data(42), + ); + expect(s.stream.isLastEventValue, isTrue); + expect(s.stream.isLastEventError, isFalse); + }); + + test('add error and then close', () async { + final s = BehaviorSubject(); + final exception = Exception(); + s.addError(exception, StackTrace.empty); + await s.close(); + + expect( + s.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.isLastEventValue, isFalse); + expect(s.isLastEventError, isTrue); + + // the stream has the same value as the subject + expect( + s.stream.lastEventOrNull, + StreamNotification.error(exception, StackTrace.empty), + ); + expect(s.stream.isLastEventValue, isFalse); + expect(s.stream.isLastEventError, isTrue); + }); + }); + + group('errorAndStackTraceOrNull', () { + test('empty subject', () { + final s = BehaviorSubject(); + expect(s.errorAndStackTraceOrNull, isNull); + + // the stream has the same value as the subject + expect(s.stream.errorAndStackTraceOrNull, isNull); + }); + + test('seeded subject', () { + final s = BehaviorSubject.seeded(42); + expect(s.errorAndStackTraceOrNull, isNull); + + // the stream has the same value as the subject + expect(s.stream.errorAndStackTraceOrNull, isNull); + }); + + test('subject with error and stack trace', () { + final s = BehaviorSubject(); + final exception = Exception(); + s.addError(exception, StackTrace.empty); + + expect( + s.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, StackTrace.empty), + ); + + // the stream has the same value as the subject + expect( + s.stream.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, StackTrace.empty), + ); + }); + + test('subject with error', () { + final s = BehaviorSubject(); + final exception = Exception(); + s.addError(exception); + + expect( + s.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, null), + ); + + // the stream has the same value as the subject + expect( + s.stream.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, null), + ); + }); + + test('seeded subject and close', () { + final s = BehaviorSubject.seeded(42)..close(); + + expect(s.errorAndStackTraceOrNull, isNull); + + // the stream has the same value as the subject + expect(s.stream.errorAndStackTraceOrNull, isNull); + }); + + test('error and close', () { + final s = BehaviorSubject(); + final exception = Exception(); + s + ..addError(exception) + ..close(); + + expect( + s.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, null), + ); + + // the stream has the same value as the subject + expect( + s.stream.errorAndStackTraceOrNull, + ErrorAndStackTrace(exception, null), + ); + }); + }); + }); +} diff --git a/core/reactivex/test/subject/publish_subject_test.dart b/core/reactivex/test/subject/publish_subject_test.dart new file mode 100644 index 00000000..15611094 --- /dev/null +++ b/core/reactivex/test/subject/publish_subject_test.dart @@ -0,0 +1,323 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:angel3_reactivex/subjects.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + group('PublishSubject', () { + test('emits items to every subscriber', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask(() { + subject.add(1); + subject.add(2); + subject.add(3); + subject.close(); + }); + + await expectLater( + subject.stream, emitsInOrder([1, 2, 3, emitsDone])); + }); + + test( + 'emits items to every subscriber that subscribe directly to the Subject', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask(() { + subject.add(1); + subject.add(2); + subject.add(3); + subject.close(); + }); + + await expectLater(subject, emitsInOrder([1, 2, 3, emitsDone])); + }); + + test('emits done event to listeners when the subject is closed', () async { + final subject = PublishSubject(); + + await expectLater(subject.isClosed, isFalse); + + scheduleMicrotask(() => subject.add(1)); + scheduleMicrotask(() => subject.close()); + + await expectLater(subject.stream, emitsInOrder([1, emitsDone])); + await expectLater(subject.isClosed, isTrue); + }); + + test( + 'emits done event to listeners when the subject is closed (listen directly on Subject)', + () async { + final subject = PublishSubject(); + + await expectLater(subject.isClosed, isFalse); + + scheduleMicrotask(() => subject.add(1)); + scheduleMicrotask(() => subject.close()); + + await expectLater(subject, emitsInOrder([1, emitsDone])); + await expectLater(subject.isClosed, isTrue); + }); + + test('emits error events to subscribers', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask(() => subject.addError(Exception())); + + await expectLater(subject.stream, emitsError(isException)); + }); + + test('emits error events to subscribers (listen directly on Subject)', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask(() => subject.addError(Exception())); + + await expectLater(subject, emitsError(isException)); + }); + + test('emits the items from addStream', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask( + () => subject.addStream(Stream.fromIterable(const [1, 2, 3]))); + + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + }); + + test('allows items to be added once addStream is complete', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + await subject.addStream(Stream.fromIterable(const [1, 2])); + scheduleMicrotask(() => subject.add(3)); + + await expectLater(subject.stream, emits(3)); + }); + + test('allows items to be added once addStream completes with an error', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + unawaited(subject + .addStream(Stream.error(Exception()), cancelOnError: true) + .whenComplete(() => subject.add(1))); + + await expectLater(subject.stream, + emitsInOrder([emitsError(isException), emits(1)])); + }); + + test('does not allow events to be added when addStream is active', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.add(1), throwsStateError); + }); + + test('does not allow errors to be added when addStream is active', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addError(Error()), throwsStateError); + }); + + test('does not allow subject to be closed when addStream is active', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.close(), throwsStateError); + }); + + test( + 'does not allow addStream to add items when previous addStream is active', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addStream(Stream.fromIterable(const [1])), + throwsStateError); + }); + + test('returns onListen callback set in constructor', () async { + void testOnListen() {} + // ignore: close_sinks + final subject = PublishSubject(onListen: testOnListen); + + await expectLater(subject.onListen, testOnListen); + }); + + test('sets onListen callback', () async { + void testOnListen() {} + // ignore: close_sinks + final subject = PublishSubject(); + + await expectLater(subject.onListen, isNull); + + subject.onListen = testOnListen; + + await expectLater(subject.onListen, testOnListen); + }); + + test('returns onCancel callback set in constructor', () async { + Future onCancel() => Future.value(null); + // ignore: close_sinks + final subject = PublishSubject(onCancel: onCancel); + + await expectLater(subject.onCancel, onCancel); + }); + + test('sets onCancel callback', () async { + void testOnCancel() {} + // ignore: close_sinks + final subject = PublishSubject(); + + await expectLater(subject.onCancel, isNull); + + subject.onCancel = testOnCancel; + + await expectLater(subject.onCancel, testOnCancel); + }); + + test('reports if a listener is present', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + await expectLater(subject.hasListener, isFalse); + + subject.stream.listen(null); + + await expectLater(subject.hasListener, isTrue); + }); + + test('onPause unsupported', () { + // ignore: close_sinks + final subject = PublishSubject(); + + expect(subject.isPaused, isFalse); + expect(() => subject.onPause, throwsUnsupportedError); + expect(() => subject.onPause = () {}, throwsUnsupportedError); + }); + + test('onResume unsupported', () { + // ignore: close_sinks + final subject = PublishSubject(); + + expect(() => subject.onResume, throwsUnsupportedError); + expect(() => subject.onResume = () {}, throwsUnsupportedError); + }); + + test('returns controller sink', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + await expectLater(subject.sink, TypeMatcher>()); + }); + + test('correctly closes done Future', () async { + final subject = PublishSubject(); + + scheduleMicrotask(() => subject.close()); + + await expectLater(subject.done, completes); + }); + + test('can be listened to multiple times', () async { + // ignore: close_sinks + final subject = PublishSubject(); + final stream = subject.stream; + + scheduleMicrotask(() => subject.add(1)); + await expectLater(stream, emits(1)); + + scheduleMicrotask(() => subject.add(2)); + await expectLater(stream, emits(2)); + }); + + test('always returns the same stream', () async { + // ignore: close_sinks + final subject = PublishSubject(); + + await expectLater(subject.stream, equals(subject.stream)); + }); + + test('adding to sink has same behavior as adding to Subject itself', + () async { + // ignore: close_sinks + final subject = PublishSubject(); + + scheduleMicrotask(() { + subject.sink.add(1); + subject.sink.add(2); + subject.sink.add(3); + subject.sink.close(); + }); + + await expectLater( + subject.stream, emitsInOrder([1, 2, 3, emitsDone])); + }); + + test('is always treated as a broadcast Stream', () async { + // ignore: close_sinks + final subject = PublishSubject(); + final stream = subject.asyncMap((event) => Future.value(event)); + + expect(subject.isBroadcast, isTrue); + expect(stream.isBroadcast, isTrue); + }); + + test('stream returns a read-only stream', () async { + final subject = PublishSubject(); + + // streams returned by PublishSubject are read-only stream, + // ie. they don't support adding events. + expect(subject.stream, isNot(isA>())); + expect(subject.stream, isNot(isA>())); + + // PublishSubject.stream is a broadcast stream + { + final stream = subject.stream; + expect(stream.isBroadcast, isTrue); + + scheduleMicrotask(() => subject.add(1)); + await expectLater(stream, emitsInOrder([1])); + + scheduleMicrotask(() => subject.add(1)); + await expectLater(stream, emitsInOrder([1])); + } + + // streams returned by the same subject are considered equal, + // but not identical + expect(identical(subject.stream, subject.stream), isFalse); + expect(subject.stream == subject.stream, isTrue); + }); + }); +} diff --git a/core/reactivex/test/subject/replay_subject_test.dart b/core/reactivex/test/subject/replay_subject_test.dart new file mode 100644 index 00000000..1d9e9490 --- /dev/null +++ b/core/reactivex/test/subject/replay_subject_test.dart @@ -0,0 +1,478 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +// ignore_for_file: close_sinks + +void main() { + group('ReplaySubject', () { + test('replays the previously emitted items to every subscriber', () async { + final subject = ReplaySubject(); + + subject.add(1); + subject.add(2); + subject.add(3); + + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + }); + + test( + 'replays the previously emitted items to every subscriber, includes null', + () async { + final subject = ReplaySubject(); + + subject.add(null); + subject.add(1); + subject.add(2); + subject.add(3); + subject.add(null); + + await expectLater( + subject.stream, + emitsInOrder(const [null, 1, 2, 3, null]), + ); + await expectLater( + subject.stream, + emitsInOrder(const [null, 1, 2, 3, null]), + ); + await expectLater( + subject.stream, + emitsInOrder(const [null, 1, 2, 3, null]), + ); + }); + + test('replays the previously emitted errors to every subscriber', () async { + final subject = ReplaySubject(); + + subject.addError(Exception()); + subject.addError(Exception()); + subject.addError(Exception()); + + await expectLater( + subject.stream, + emitsInOrder([ + emitsError(isException), + emitsError(isException), + emitsError(isException) + ])); + await expectLater( + subject.stream, + emitsInOrder([ + emitsError(isException), + emitsError(isException), + emitsError(isException) + ])); + await expectLater( + subject.stream, + emitsInOrder([ + emitsError(isException), + emitsError(isException), + emitsError(isException) + ])); + }); + + test( + 'replays the previously emitted items to every subscriber that directly subscribes to the Subject', + () async { + final subject = ReplaySubject(); + + subject.add(1); + subject.add(2); + subject.add(3); + + await expectLater(subject, emitsInOrder(const [1, 2, 3])); + await expectLater(subject, emitsInOrder(const [1, 2, 3])); + await expectLater(subject, emitsInOrder(const [1, 2, 3])); + }); + + test( + 'replays the previously emitted items and errors to every subscriber that directly subscribes to the Subject', + () async { + final subject = ReplaySubject(); + + subject.add(1); + subject.addError(Exception()); + subject.addError(Exception()); + subject.add(2); + + await expectLater( + subject, + emitsInOrder([ + 1, + emitsError(isException), + emitsError(isException), + 2 + ])); + await expectLater( + subject, + emitsInOrder([ + 1, + emitsError(isException), + emitsError(isException), + 2 + ])); + await expectLater( + subject, + emitsInOrder([ + 1, + emitsError(isException), + emitsError(isException), + 2 + ])); + }); + + test('synchronously get the previous items', () async { + final subject = ReplaySubject(); + + subject.add(1); + subject.add(2); + subject.add(3); + + await expectLater(subject.values, const [1, 2, 3]); + }); + + test('synchronously get the previous errors', () { + final subject = ReplaySubject(); + final e1 = Exception(), e2 = Exception(), e3 = Exception(); + final stackTrace = StackTrace.fromString('#'); + + subject.addError(e1); + subject.addError(e2, stackTrace); + subject.addError(e3); + + expect( + subject.errors, + containsAllInOrder([e1, e2, e3]), + ); + expect( + subject.stackTraces, + containsAllInOrder([null, stackTrace, null]), + ); + }); + + test('replays the most recently emitted items up to a max size', () async { + final subject = ReplaySubject(maxSize: 2); + + subject.add(1); // Should be dropped + subject.add(2); + subject.add(3); + + await expectLater(subject.stream, emitsInOrder(const [2, 3])); + await expectLater(subject.stream, emitsInOrder(const [2, 3])); + await expectLater(subject.stream, emitsInOrder(const [2, 3])); + }); + + test('emits done event to listeners when the subject is closed', () async { + final subject = ReplaySubject(); + + await expectLater(subject.isClosed, isFalse); + + subject.add(1); + scheduleMicrotask(() => subject.close()); + + await expectLater(subject.stream, emitsInOrder([1, emitsDone])); + await expectLater(subject.isClosed, isTrue); + }); + + test('emits error events to subscribers', () async { + final subject = ReplaySubject(); + + scheduleMicrotask(() => subject.addError(Exception())); + + await expectLater(subject.stream, emitsError(isException)); + }); + + test('replays the previously emitted items from addStream', () async { + final subject = ReplaySubject(); + + await subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + }); + + test('allows items to be added once addStream is complete', () async { + final subject = ReplaySubject(); + + await subject.addStream(Stream.fromIterable(const [1, 2])); + subject.add(3); + + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + }); + + test('allows items to be added once addStream completes with an error', + () async { + final subject = ReplaySubject(); + + unawaited(subject + .addStream(Stream.error(Exception()), cancelOnError: true) + .whenComplete(() => subject.add(1))); + + await expectLater(subject.stream, + emitsInOrder([emitsError(isException), emits(1)])); + }); + + test('does not allow events to be added when addStream is active', + () async { + final subject = ReplaySubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.add(1), throwsStateError); + }); + + test('does not allow errors to be added when addStream is active', + () async { + final subject = ReplaySubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addError(Error()), throwsStateError); + }); + + test('does not allow subject to be closed when addStream is active', + () async { + final subject = ReplaySubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.close(), throwsStateError); + }); + + test( + 'does not allow addStream to add items when previous addStream is active', + () async { + final subject = ReplaySubject(); + + // Purposely don't wait for the future to complete, then try to add items + // ignore: unawaited_futures + subject.addStream(Stream.fromIterable(const [1, 2, 3])); + + await expectLater(() => subject.addStream(Stream.fromIterable(const [1])), + throwsStateError); + }); + + test('returns onListen callback set in constructor', () async { + void testOnListen() {} + + final subject = ReplaySubject(onListen: testOnListen); + + await expectLater(subject.onListen, testOnListen); + }); + + test('sets onListen callback', () async { + void testOnListen() {} + + final subject = ReplaySubject(); + + await expectLater(subject.onListen, isNull); + + subject.onListen = testOnListen; + + await expectLater(subject.onListen, testOnListen); + }); + + test('returns onCancel callback set in constructor', () async { + Future onCancel() => Future.value(null); + + final subject = ReplaySubject(onCancel: onCancel); + + await expectLater(subject.onCancel, onCancel); + }); + + test('sets onCancel callback', () async { + void testOnCancel() {} + + final subject = ReplaySubject(); + + await expectLater(subject.onCancel, isNull); + + subject.onCancel = testOnCancel; + + await expectLater(subject.onCancel, testOnCancel); + }); + + test('reports if a listener is present', () async { + final subject = ReplaySubject(); + + await expectLater(subject.hasListener, isFalse); + + subject.stream.listen(null); + + await expectLater(subject.hasListener, isTrue); + }); + + test('onPause unsupported', () { + final subject = ReplaySubject(); + + expect(subject.isPaused, isFalse); + expect(() => subject.onPause, throwsUnsupportedError); + expect(() => subject.onPause = () {}, throwsUnsupportedError); + }); + + test('onResume unsupported', () { + final subject = ReplaySubject(); + + expect(() => subject.onResume, throwsUnsupportedError); + expect(() => subject.onResume = () {}, throwsUnsupportedError); + }); + + test('returns controller sink', () async { + final subject = ReplaySubject(); + + await expectLater(subject.sink, TypeMatcher>()); + }); + + test('correctly closes done Future', () async { + final subject = ReplaySubject(); + + scheduleMicrotask(subject.close); + + await expectLater(subject.done, completes); + }); + + test('can be listened to multiple times', () async { + final subject = ReplaySubject(); + final stream = subject.stream; + + subject.add(1); + subject.add(2); + + await expectLater(stream, emitsInOrder(const [1, 2])); + await expectLater(stream, emitsInOrder(const [1, 2])); + }); + + test('always returns the same stream', () async { + final subject = ReplaySubject(); + + await expectLater(subject.stream, equals(subject.stream)); + }); + + test('adding to sink has same behavior as adding to Subject itself', + () async { + final subject = ReplaySubject(); + + subject.sink.add(1); + subject.sink.add(2); + subject.sink.add(3); + + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + await expectLater(subject.stream, emitsInOrder(const [1, 2, 3])); + }); + + test('is always treated as a broadcast Stream', () async { + final subject = ReplaySubject(); + final stream = subject.asyncMap((event) => Future.value(event)); + + expect(subject.isBroadcast, isTrue); + expect(stream.isBroadcast, isTrue); + }); + + test('issue/419: sync behavior', () async { + final subject = ReplaySubject(sync: true)..add(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null); + + expect(mappedStream.value, equals(1)); + + await subject.close(); + }, skip: true); + + test('issue/419: sync throughput', () async { + final subject = ReplaySubject(sync: true)..add(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null); + + subject.add(2); + + expect(mappedStream.value, equals(2)); + + await subject.close(); + }, skip: true); + + test('issue/419: async behavior', () async { + final subject = ReplaySubject()..add(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null, + onDone: () => expect(mappedStream.value, equals(1))); + + expect(mappedStream.valueOrNull, isNull); + + await subject.close(); + }); + + test('issue/419: async throughput', () async { + final subject = ReplaySubject()..add(1); + final mappedStream = subject.map((event) => event).shareValue(); + + mappedStream.listen(null, + onDone: () => expect(mappedStream.value, equals(2))); + + subject.add(2); + + expect(mappedStream.valueOrNull, isNull); + + await subject.close(); + }); + + test('do not update buffer after closed', () { + final subject = ReplaySubject(); + + subject.add(1); + expect(subject.values, [1]); + + subject.close(); + + expect(() => subject.add(2), throwsStateError); + expect(() => subject.addError(Exception()), throwsStateError); + expect(subject.values, [1]); + }); + + test('stream returns a read-only stream', () async { + final subject = ReplaySubject()..add(1); + + // streams returned by ReplaySubject are read-only stream, + // ie. they don't support adding events. + expect(subject.stream, isNot(isA>())); + expect(subject.stream, isNot(isA>())); + + expect( + subject.stream, + isA>().having( + (v) => v.values, + 'ReplaySubject.stream.values', + [1], + ), + ); + + // ReplaySubject.stream is a broadcast stream + { + final stream = subject.stream; + expect(stream.isBroadcast, isTrue); + await expectLater(stream, emitsInOrder([1])); + await expectLater(stream, emitsInOrder([1])); + } + + // streams returned by the same subject are considered equal, + // but not identical + expect(identical(subject.stream, subject.stream), isFalse); + expect(subject.stream == subject.stream, isTrue); + }); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/buffer_count_test.dart b/core/reactivex/test/transformers/backpressure/buffer_count_test.dart new file mode 100644 index 00000000..5e05ade2 --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/buffer_count_test.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + test('Rx.bufferCount.noStartBufferEvery', () async { + await expectLater( + Rx.range(1, 4).bufferCount(2), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.bufferCount.noStartBufferEvery.includesEventOnClose', () async { + await expectLater( + Rx.range(1, 5).bufferCount(2), + emitsInOrder([ + const [1, 2], + const [3, 4], + const [5], + emitsDone + ])); + }); + + test('Rx.bufferCount.startBufferEvery.count2startBufferEvery1', () async { + await expectLater( + Rx.range(1, 4).bufferCount(2, 1), + emitsInOrder([ + const [1, 2], + const [2, 3], + const [3, 4], + const [4], + emitsDone + ])); + }); + + test('Rx.bufferCount.startBufferEvery.count3startBufferEvery2', () async { + await expectLater( + Rx.range(1, 8).bufferCount(3, 2), + emitsInOrder([ + const [1, 2, 3], + const [3, 4, 5], + const [5, 6, 7], + const [7, 8], + emitsDone + ])); + }); + + test('Rx.bufferCount.startBufferEvery.count3startBufferEvery4', () async { + await expectLater( + Rx.range(1, 8).bufferCount(3, 4), + emitsInOrder([ + const [1, 2, 3], + const [5, 6, 7], + emitsDone + ])); + }); + + test('Rx.bufferCount.reusable', () async { + final transformer = BufferCountStreamTransformer(2); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]).transform(transformer), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]).transform(transformer), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.bufferCount.asBroadcastStream', () async { + final stream = Stream.fromIterable(const [1, 2, 3, 4]) + .asBroadcastStream() + .bufferCount(2); + + // listen twice on same stream + await expectLater( + stream, + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater(stream, emitsInOrder([emitsDone])); + }); + + test('Rx.bufferCount.error.shouldThrowA', () async { + await expectLater(Stream.error(Exception()).bufferCount(2), + emitsError(isException)); + }); + + test( + 'Rx.bufferCount.shouldThrow.invalidCount.negative', + () { + expect(() => Stream.fromIterable(const [1, 2, 3, 4]).bufferCount(-1), + throwsArgumentError); + }, + ); + + test('Rx.bufferCount.startBufferEvery.shouldThrow.invalidStartBufferEvery', + () { + expect(() => Stream.fromIterable(const [1, 2, 3, 4]).bufferCount(2, -1), + throwsArgumentError); + }); + + test('Rx.bufferCount.nullable', () { + nullableTest>( + (s) => s.bufferCount(1), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/buffer_test.dart b/core/reactivex/test/transformers/backpressure/buffer_test.dart new file mode 100644 index 00000000..095d6578 --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/buffer_test.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream getStream(int n) async* { + var k = 0; + + while (k < n) { + await Future.delayed(const Duration(milliseconds: 100)); + + yield k++; + } +} + +void main() { + test('Rx.buffer', () async { + await expectLater( + getStream(4).buffer( + Stream.periodic(const Duration(milliseconds: 160)).take(3)), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.buffer.sampleBeforeEvent.shouldEmit', () async { + await expectLater( + Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)) + .then((_) => 'end')).startWith('start').buffer( + Stream.periodic(const Duration(milliseconds: 40)).take(10)), + emitsInOrder([ + const ['start'], // after 40ms + const [], // 80ms + const [], // 120ms + const [], // 160ms + const ['end'], // done + emitsDone + ])); + }); + + test('Rx.buffer.shouldClose', () async { + final controller = StreamController() + ..add(0) + ..add(1) + ..add(2) + ..add(3); + + scheduleMicrotask(controller.close); + + await expectLater( + controller.stream + .buffer(Stream.periodic(const Duration(seconds: 3))) + .take(1), + emitsInOrder([ + const [0, 1, 2, 3], // done + emitsDone + ])); + }); + + test('Rx.buffer.reusable', () async { + final transformer = BufferStreamTransformer((_) => + Stream.periodic(const Duration(milliseconds: 160)) + .take(3) + .asBroadcastStream()); + + await expectLater( + getStream(4).transform(transformer), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + + await expectLater( + getStream(4).transform(transformer), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.buffer.asBroadcastStream', () async { + final stream = getStream(4).asBroadcastStream().buffer( + Stream.periodic(const Duration(milliseconds: 160)) + .take(10) + .asBroadcastStream()); + + // listen twice on same stream + await expectLater( + stream, + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + + await expectLater(stream, emitsInOrder([emitsDone])); + }); + + test('Rx.buffer.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()) + .buffer(Stream.periodic(const Duration(milliseconds: 160))), + emitsError(isException)); + }); + + test('Rx.buffer.nullable', () { + nullableTest>( + (s) => s.buffer(Stream.empty()), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/buffer_test_test.dart b/core/reactivex/test/transformers/backpressure/buffer_test_test.dart new file mode 100644 index 00000000..0d08c4c9 --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/buffer_test_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + test('Rx.bufferTest', () async { + await expectLater( + Rx.range(1, 4).bufferTest((i) => i % 2 == 0), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.bufferTest.reusable', () async { + final transformer = BufferTestStreamTransformer((i) => i % 2 == 0); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]).transform(transformer), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]).transform(transformer), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.bufferTest.asBroadcastStream', () async { + final stream = Stream.fromIterable(const [1, 2, 3, 4]) + .asBroadcastStream() + .bufferTest((i) => i % 2 == 0); + + // listen twice on same stream + await expectLater( + stream, + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater(stream, emitsDone); + }); + + test('Rx.bufferTest.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()).bufferTest((i) => i % 2 == 0), + emitsError(isException)); + }); + + test('Rx.bufferTest.nullable', () { + nullableTest>( + (s) => s.bufferTest((i) => true), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/buffer_time_test.dart b/core/reactivex/test/transformers/backpressure/buffer_time_test.dart new file mode 100644 index 00000000..8feea7da --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/buffer_time_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +/// yield immediately, then every 100ms +Stream getStream(int n) async* { + var k = 1; + + yield 0; + + while (k < n) { + yield await Future.delayed(const Duration(milliseconds: 100)) + .then((_) => k++); + } +} + +void main() { + test('Rx.bufferTime', () async { + await expectLater( + getStream(4).bufferTime(const Duration(milliseconds: 160)), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.bufferTime.shouldClose', () async { + final controller = StreamController() + ..add(0) + ..add(1) + ..add(2) + ..add(3); + + scheduleMicrotask(controller.close); + + await expectLater( + controller.stream.bufferTime(const Duration(seconds: 3)).take(1), + emitsInOrder([ + const [0, 1, 2, 3], // done + emitsDone + ])); + }); + + test('Rx.bufferTime.reusable', () async { + final transformer = BufferStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 160))); + + await expectLater( + getStream(4).transform(transformer), + emitsInOrder([ + const [0, 1], const [2, 3], // done + emitsDone + ])); + + await expectLater( + getStream(4).transform(transformer), + emitsInOrder([ + const [0, 1], const [2, 3], // done + emitsDone + ])); + }); + + test('Rx.bufferTime.asBroadcastStream', () async { + final stream = getStream(4) + .asBroadcastStream() + .bufferTime(const Duration(milliseconds: 160)); + + // listen twice on same stream + await expectLater( + stream, + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + + await expectLater(stream, emitsInOrder([emitsDone])); + }); + + test('Rx.bufferTime.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()) + .bufferTime(const Duration(milliseconds: 160)), + emitsError(isException)); + }); + + test('Rx.bufferTime.nullable', () { + nullableTest>( + (s) => s.bufferTime(Duration.zero), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/debounce_test.dart b/core/reactivex/test/transformers/backpressure/debounce_test.dart new file mode 100644 index 00000000..e8bc0754 --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/debounce_test.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add(2)); + Timer(const Duration(milliseconds: 300), () => controller.add(3)); + Timer(const Duration(milliseconds: 400), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.debounce', () async { + await expectLater( + _getStream().debounce((_) => Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))), + emitsInOrder([4, emitsDone])); + }); + + test('Rx.debounce.dynamicWindow', () async { + // Given the input [1, 2, 3, 4] + // debounce 200ms on [1, 2, 4] + // debounce 0ms on [3] + // yields [3, 4, done] + await expectLater( + _getStream().debounce((value) => value == 3 + ? Stream.value(true) + : Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))), + emitsInOrder([3, 4, emitsDone])); + }); + + test('Rx.debounce.reusable', () async { + final transformer = DebounceStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 200))); + + await expectLater(_getStream().transform(transformer), + emitsInOrder([4, emitsDone])); + + await expectLater(_getStream().transform(transformer), + emitsInOrder([4, emitsDone])); + }); + + test('Rx.debounce.asBroadcastStream', () async { + final future = _getStream() + .asBroadcastStream() + .debounce((_) => Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))) + .drain(); + + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.debounce.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()).debounce((_) => Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))), + emitsError(isException)); + }); + + test('Rx.debounce.pause.resume', () async { + final controller = StreamController(); + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3]) + .debounce((_) => Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + subscription.pause(Future.delayed(const Duration(milliseconds: 50))); + + await expectLater(controller.stream, emitsInOrder([3, emitsDone])); + }); + + test('Rx.debounce.emits.last.item.immediately', () async { + final emissions = []; + final stopwatch = Stopwatch(); + final stream = Stream.fromIterable(const [1, 2, 3]).debounce((_) => + Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))); + late StreamSubscription subscription; + + stopwatch.start(); + + subscription = stream.listen( + expectAsync1((val) { + emissions.add(val); + }, count: 1), onDone: expectAsync0(() { + stopwatch.stop(); + + expect(emissions, const [3]); + + // We debounce for 100 seconds. To ensure we aren't waiting that long to + // emit the last item after the base stream completes, we expect the + // last value to be emitted to be much shorter than that. + expect(stopwatch.elapsedMilliseconds < 500, isTrue); + + subscription.cancel(); + })); + }, timeout: Timeout(Duration(seconds: 3))); + + test( + 'Rx.debounce.cancel.emits.nothing', + () async { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const [1, 2, 3]).doOnDone(() { + subscription.cancel(); + }).debounce((_) => Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))); + + // We expect the onData callback to be called 0 times because the + // subscription is cancelled when the base stream ends. + subscription = stream.listen(expectAsync1((_) {}, count: 0)); + }, + timeout: Timeout(Duration(seconds: 3)), + ); + + test('Rx.debounce.last.event.can.be.null', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, null]).debounce((_) => + Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)))), + emitsInOrder([null, emitsDone])); + }); + + test('Rx.debounce.nullable', () { + nullableTest( + (s) => s.debounce((_) => Stream.empty()), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/debounce_time_test.dart b/core/reactivex/test/transformers/backpressure/debounce_time_test.dart new file mode 100644 index 00000000..8501763d --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/debounce_time_test.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add(2)); + Timer(const Duration(milliseconds: 300), () => controller.add(3)); + Timer(const Duration(milliseconds: 400), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.debounceTime', () async { + await expectLater( + _getStream().debounceTime(const Duration(milliseconds: 200)), + emitsInOrder([4, emitsDone])); + }); + + test('Rx.debounceTime.reusable', () async { + final transformer = DebounceStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 200))); + + await expectLater(_getStream().transform(transformer), + emitsInOrder([4, emitsDone])); + + await expectLater(_getStream().transform(transformer), + emitsInOrder([4, emitsDone])); + }); + + test('Rx.debounceTime.asBroadcastStream', () async { + final future = _getStream() + .asBroadcastStream() + .debounceTime(const Duration(milliseconds: 200)) + .drain(); + + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.debounceTime.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()) + .debounceTime(const Duration(milliseconds: 200)), + emitsError(isException)); + }); + + test('Rx.debounceTime.pause.resume', () async { + final controller = StreamController(); + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3]) + .debounceTime(Duration(milliseconds: 100)) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + subscription.pause(Future.delayed(const Duration(milliseconds: 50))); + + await expectLater(controller.stream, emitsInOrder([3, emitsDone])); + }); + + test('Rx.debounceTime.emits.last.item.immediately', () async { + final emissions = []; + final stopwatch = Stopwatch(); + final stream = Stream.fromIterable(const [1, 2, 3]) + .debounceTime(Duration(seconds: 100)); + late StreamSubscription subscription; + + stopwatch.start(); + + subscription = stream.listen( + expectAsync1((val) { + emissions.add(val); + }, count: 1), onDone: expectAsync0(() { + stopwatch.stop(); + + expect(emissions, const [3]); + + // We debounce for 100 seconds. To ensure we aren't waiting that long to + // emit the last item after the base stream completes, we expect the + // last value to be emitted to be much shorter than that. + expect(stopwatch.elapsedMilliseconds < 500, isTrue); + + subscription.cancel(); + })); + }, timeout: Timeout(Duration(seconds: 3))); + + test( + 'Rx.debounceTime.cancel.emits.nothing', + () async { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const [1, 2, 3]).doOnDone(() { + subscription.cancel(); + }).debounceTime(Duration(seconds: 10)); + + // We expect the onData callback to be called 0 times because the + // subscription is cancelled when the base stream ends. + subscription = stream.listen(expectAsync1((_) {}, count: 0)); + }, + timeout: Timeout(Duration(seconds: 3)), + ); + + test('Rx.debounceTime.last.event.can.be.null', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, null]) + .debounceTime(const Duration(milliseconds: 200)), + emitsInOrder([null, emitsDone])); + }); + + test('Rx.debounceTime.nullable', () { + nullableTest( + (s) => s.debounceTime(Duration.zero), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/pairwise_test.dart b/core/reactivex/test/transformers/backpressure/pairwise_test.dart new file mode 100644 index 00000000..da89fa01 --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/pairwise_test.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + test('Rx.pairwise', () async { + const expectedOutput = [ + [1, 2], + [2, 3], + [3, 4] + ]; + var count = 0; + + final stream = Rx.range(1, 4).pairwise(); + + stream.listen( + expectAsync1((result) { + // test to see if the combined output matches + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length), + onError: expectAsync2((Object e, StackTrace s) {}, count: 0), + onDone: expectAsync0(() {}, count: 1), + ); + }); + + test('Rx.pairwise.empty', () { + expect(Stream.empty().pairwise(), emitsDone); + }); + + test('Rx.pairwise.single', () { + expect(Stream.value(1).pairwise(), emitsDone); + }); + + test('Rx.pairwise.compatible', () { + expect( + Stream.fromIterable([1, 2]).pairwise(), + isA>>(), + ); + + Stream> s = Stream.fromIterable([1, 2]).pairwise(); + expect( + s, + emitsInOrder([ + [1, 2], + emitsDone + ]), + ); + }); + + test('Rx.pairwise.asBroadcastStream', () async { + final stream = + Stream.fromIterable(const [1, 2, 3, 4]).asBroadcastStream().pairwise(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.pairwise.error.shouldThrow.onError', () async { + final streamWithError = Stream.error(Exception()).pairwise(); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.pairwise.nullable', () { + nullableTest>( + (s) => s.pairwise(), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/sample_test.dart b/core/reactivex/test/transformers/backpressure/sample_test.dart new file mode 100644 index 00000000..b0137d32 --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/sample_test.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _getStream() => + Stream.periodic(const Duration(milliseconds: 20), (count) => count) + .take(5); + +Stream _getSampleStream() => + Stream.periodic(const Duration(milliseconds: 35), (count) => count) + .take(10); + +void main() { + test('Rx.sample', () async { + final stream = _getStream().sample(_getSampleStream()); + + await expectLater(stream, emitsInOrder([1, 3, 4, emitsDone])); + }); + + test('Rx.sample.reusable', () async { + final transformer = SampleStreamTransformer( + (_) => _getSampleStream().asBroadcastStream()); + final streamA = _getStream().transform(transformer); + final streamB = _getStream().transform(transformer); + + await expectLater(streamA, emitsInOrder([1, 3, 4, emitsDone])); + await expectLater(streamB, emitsInOrder([1, 3, 4, emitsDone])); + }, skip: true); + + test('Rx.sample.onDone', () async { + final stream = Stream.value(1).sample(Stream.empty()); + + await expectLater(stream, emits(1)); + }); + + test('Rx.sample.shouldClose', () async { + final controller = StreamController(); + + controller.stream + .sample(Stream.empty()) // should trigger onDone + .listen(null, onDone: expectAsync0(() => expect(true, isTrue))); + + controller.add(0); + controller.add(1); + controller.add(2); + controller.add(3); + + scheduleMicrotask(controller.close); + }); + + test('Rx.sample.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .sample(_getSampleStream().asBroadcastStream()); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.sample.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).sample(_getSampleStream()); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.sample.error.shouldThrowB', () async { + final streamWithError = Stream.value(1) + .sample(Stream.error(Exception('Catch me if you can!'))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.sample.pause.resume', () async { + final controller = StreamController(); + late StreamSubscription subscription; + + subscription = _getStream() + .sample(_getSampleStream()) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + await expectLater( + controller.stream, emitsInOrder([1, 3, 4, emitsDone])); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.sample.nullable', () { + nullableTest( + (s) => s.sample(_getSampleStream()), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/sample_time_test.dart b/core/reactivex/test/transformers/backpressure/sample_time_test.dart new file mode 100644 index 00000000..f6c03860 --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/sample_time_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _getStream() => + Stream.periodic(const Duration(milliseconds: 20), (count) => count) + .take(5); + +void main() { + test('Rx.sampleTime', () async { + final stream = _getStream().sampleTime(const Duration(milliseconds: 35)); + + await expectLater(stream, emitsInOrder([1, 3, 4, emitsDone])); + }); + + test('Rx.sampleTime.reusable', () async { + final transformer = SampleStreamTransformer((_) => + TimerStream(true, const Duration(milliseconds: 35)) + .asBroadcastStream()); + + await expectLater( + _getStream().transform(transformer).drain(), + completes, + ); + await expectLater( + _getStream().transform(transformer).drain(), + completes, + ); + }); + + test('Rx.sampleTime.onDone', () async { + final stream = Stream.value(1).sampleTime(const Duration(seconds: 1)); + + await expectLater(stream, emits(1)); + }); + + test('Rx.sampleTime.shouldClose', () async { + final controller = StreamController(); + + controller.stream + .sampleTime(const Duration(seconds: 1)) // should trigger onDone + .listen(null, onDone: expectAsync0(() => expect(true, isTrue))); + + controller.add(0); + controller.add(1); + controller.add(2); + controller.add(3); + + scheduleMicrotask(controller.close); + }); + + test('Rx.sampleTime.asBroadcastStream', () async { + final stream = _getStream() + .sampleTime(const Duration(milliseconds: 35)) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.sampleTime.error.shouldThrowA', () async { + final streamWithError = Stream.error(Exception()) + .sampleTime(const Duration(milliseconds: 35)); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.sampleTime.pause.resume', () async { + final controller = StreamController(); + late StreamSubscription subscription; + + subscription = _getStream() + .sampleTime(const Duration(milliseconds: 35)) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + subscription.pause(Future.delayed(const Duration(milliseconds: 50))); + + await expectLater( + controller.stream, emitsInOrder([1, 3, 4, emitsDone])); + }); + + test('Rx.sampleTime.nullable', () { + nullableTest( + (s) => s.sampleTime(Duration.zero), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/throttle_test.dart b/core/reactivex/test/transformers/backpressure/throttle_test.dart new file mode 100644 index 00000000..3a125e8b --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/throttle_test.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _stream() => + Stream.periodic(const Duration(milliseconds: 100), (i) => i + 1).take(10); + +void main() { + test('Rx.throttle', () async { + await expectLater( + _stream() + .throttle( + (_) => Stream.periodic(const Duration(milliseconds: 250))) + .take(3), + emitsInOrder([1, 4, 7, emitsDone])); + }); + + test('Rx.throttle.trailing', () async { + await expectLater( + _stream() + .throttle( + (_) => Stream.periodic(const Duration(milliseconds: 250)), + trailing: true, + leading: false) + .take(3), + emitsInOrder([3, 6, 9, emitsDone])); + }); + + test('Rx.throttle.dynamic.window', () async { + await expectLater( + _stream() + .throttle((value) => value == 1 + ? Stream.periodic(const Duration(milliseconds: 10)) + : Stream.periodic(const Duration(milliseconds: 250))) + .take(3), + emitsInOrder([1, 2, 5, emitsDone])); + }); + + test('Rx.throttle.dynamic.window.trailing', () async { + await expectLater( + _stream() + .throttle( + (value) => value == 1 + ? Stream.periodic(const Duration(milliseconds: 10)) + : Stream.periodic(const Duration(milliseconds: 250)), + trailing: true, + leading: false) + .take(3), + emitsInOrder([1, 4, 7, emitsDone])); + }); + + test('Rx.throttle.leading.trailing.1', () async { + // --1--2--3--4--5--6--7--8--9--10--11| + // --1-----3--4-----6--7-----9--10-----11| + // --^--------^--------^---------^----- + + final values = []; + + final stream = _stream() + .concatWith([Rx.timer(11, const Duration(milliseconds: 100))]).throttle( + (v) { + values.add(v); + return Stream.periodic(const Duration(milliseconds: 250)); + }, + leading: true, + trailing: true, + ); + await expectLater( + stream, + emitsInOrder([1, 3, 4, 6, 7, 9, 10, 11, emitsDone]), + ); + expect(values, [1, 4, 7, 10]); + }); + + test('Rx.throttle.leading.trailing.2', () async { + // --1--2--3--4--5--6--7--8--9--10--11| + // --1-----3--4-----6--7-----9--10-----11| + // --^--------^--------^---------^----- + + final values = []; + + final stream = _stream().throttle( + (v) { + values.add(v); + return Stream.periodic(const Duration(milliseconds: 250)); + }, + leading: true, + trailing: true, + ); + await expectLater( + stream, + emitsInOrder([1, 3, 4, 6, 7, 9, 10, emitsDone]), + ); + expect(values, [1, 4, 7, 10]); + }); + + test('Rx.throttle.reusable', () async { + final transformer = ThrottleStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 250))); + + await expectLater(_stream().transform(transformer).take(2), + emitsInOrder([1, 4, emitsDone])); + + await expectLater(_stream().transform(transformer).take(2), + emitsInOrder([1, 4, emitsDone])); + }); + + test('Rx.throttle.asBroadcastStream', () async { + final future = _stream() + .asBroadcastStream() + .throttle( + (_) => Stream.periodic(const Duration(milliseconds: 250))) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.throttle.error.shouldThrowA', () async { + final streamWithError = Stream.error(Exception()).throttle( + (_) => Stream.periodic(const Duration(milliseconds: 250))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.throttle.pause.resume', () async { + late StreamSubscription subscription; + + final controller = StreamController(); + + subscription = _stream() + .throttle( + (_) => Stream.periodic(const Duration(milliseconds: 250))) + .take(2) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + await expectLater( + controller.stream, emitsInOrder([1, 4, emitsDone])); + + await Future.delayed(const Duration(milliseconds: 150)).whenComplete( + () => subscription + .pause(Future.delayed(const Duration(milliseconds: 150)))); + }); + + test('Rx.throttle.nullable', () { + nullableTest( + (s) => s.throttle((_) => Stream.empty()), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/throttle_time_test.dart b/core/reactivex/test/transformers/backpressure/throttle_time_test.dart new file mode 100644 index 00000000..a0d55bf5 --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/throttle_time_test.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream _stream() => + Stream.periodic(const Duration(milliseconds: 100), (i) => i + 1).take(10); + +void main() { + test('Rx.throttleTime', () async { + await expectLater( + _stream().throttleTime(const Duration(milliseconds: 250)).take(3), + emitsInOrder([1, 4, 7, emitsDone])); + }); + + test('Rx.throttleTime.trailing', () async { + await expectLater( + _stream() + .throttleTime(const Duration(milliseconds: 250), + trailing: true, leading: false) + .take(3), + emitsInOrder([3, 6, 9, emitsDone])); + }); + + test('Rx.throttleTime.reusable', () async { + final transformer = ThrottleStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 250))); + + await expectLater(_stream().transform(transformer).take(2), + emitsInOrder([1, 4, emitsDone])); + + await expectLater(_stream().transform(transformer).take(2), + emitsInOrder([1, 4, emitsDone])); + }); + + test('Rx.throttleTime.asBroadcastStream', () async { + final future = _stream() + .asBroadcastStream() + .throttleTime(const Duration(milliseconds: 250)) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.throttleTime.error.shouldThrowA', () async { + final streamWithError = Stream.error(Exception()) + .throttleTime(const Duration(milliseconds: 200)); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.throttleTime.pause.resume', () async { + late StreamSubscription subscription; + + final controller = StreamController(); + + subscription = _stream() + .throttleTime(const Duration(milliseconds: 250)) + .take(2) + .listen(controller.add, onDone: () { + controller.close(); + subscription.cancel(); + }); + + await expectLater( + controller.stream, emitsInOrder([1, 4, emitsDone])); + + await Future.delayed(const Duration(milliseconds: 150)).whenComplete( + () => subscription + .pause(Future.delayed(const Duration(milliseconds: 150)))); + }); + + test('issue/417 trailing true', () async { + await expectLater( + Stream.fromIterable([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + .interval(Duration(milliseconds: 25)) + .throttleTime(Duration(milliseconds: 50), + trailing: true, leading: false), + emitsInOrder([1, 3, 5, 7, 9, emitsDone])); + }); + + test('issue/417 trailing false', () async { + await expectLater( + Stream.fromIterable([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + .interval(Duration(milliseconds: 25)) + .throttleTime(Duration(milliseconds: 50), trailing: false), + emitsInOrder([0, 2, 4, 6, 8, emitsDone])); + }); + + test('Rx.throttleTime.nullable', () { + nullableTest( + (s) => s.throttleTime(Duration.zero), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/window_count_test.dart b/core/reactivex/test/transformers/backpressure/window_count_test.dart new file mode 100644 index 00000000..58d252f3 --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/window_count_test.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + test('Rx.windowCount.noStartBufferEvery', () async { + await expectLater( + Rx.range(1, 4).windowCount(2).asyncMap((stream) => stream.toList()), + emitsInOrder([ + [1, 2], + [3, 4], + emitsDone + ])); + }); + + test('Rx.windowCount.noStartBufferEvery.includesEventOnClose', () async { + await expectLater( + Rx.range(1, 5).windowCount(2).asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + const [5], + emitsDone + ])); + }); + + test('Rx.windowCount.startBufferEvery.count2startBufferEvery1', () async { + await expectLater( + Rx.range(1, 4).windowCount(2, 1).asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [2, 3], + const [3, 4], + const [4], + emitsDone + ])); + }); + + test('Rx.windowCount.startBufferEvery.count3startBufferEvery2', () async { + await expectLater( + Rx.range(1, 8).windowCount(3, 2).asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2, 3], + const [3, 4, 5], + const [5, 6, 7], + const [7, 8], + emitsDone + ])); + }); + + test('Rx.windowCount.startBufferEvery.count3startBufferEvery4', () async { + await expectLater( + Rx.range(1, 8).windowCount(3, 4).asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2, 3], + const [5, 6, 7], + emitsDone + ])); + }); + + test('Rx.windowCount.reusable', () async { + final transformer = WindowCountStreamTransformer(2); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.windowCount.asBroadcastStream', () async { + final future = Stream.fromIterable(const [1, 2, 3, 4]) + .asBroadcastStream() + .windowCount(2) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.windowCount.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()).windowCount(2), + emitsError(isException), + ); + }); + + test( + 'Rx.windowCount.shouldThrow.invalidCount.negative', + () { + expect(() => Stream.fromIterable(const [1, 2, 3, 4]).windowCount(-1), + throwsArgumentError); + }, + ); + + test('Rx.windowCount.startBufferEvery.shouldThrow.invalidStartBufferEvery', + () { + expect(() => Stream.fromIterable(const [1, 2, 3, 4]).windowCount(2, -1), + throwsArgumentError); + }); + + test('Rx.windowCount.nullable', () { + nullableTest>( + (s) => s.windowCount(2), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/window_test.dart b/core/reactivex/test/transformers/backpressure/window_test.dart new file mode 100644 index 00000000..33dba73c --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/window_test.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +Stream getStream(int n) async* { + var k = 0; + + while (k < n) { + await Future.delayed(const Duration(milliseconds: 100)); + + yield k++; + } +} + +void main() { + test('Rx.window', () async { + await expectLater( + getStream(4) + .window(Stream.periodic(const Duration(milliseconds: 160)) + .take(3)) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.window.sampleBeforeEvent.shouldEmit', () async { + await expectLater( + Stream.fromFuture( + Future.delayed(const Duration(milliseconds: 200)) + .then((_) => 'end')) + .startWith('start') + .window(Stream.periodic(const Duration(milliseconds: 40)) + .take(10)) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const ['start'], // after 40ms + const [], // 80ms + const [], // 120ms + const [], // 160ms + const ['end'], // done + emitsDone + ])); + }); + + test('Rx.window.shouldClose', () async { + final controller = StreamController() + ..add(0) + ..add(1) + ..add(2) + ..add(3); + + scheduleMicrotask(controller.close); + + await expectLater( + controller.stream + .window(Stream.periodic(const Duration(seconds: 3))) + .asyncMap((stream) => stream.toList()) + .take(1), + emitsInOrder([ + const [0, 1, 2, 3], // done + emitsDone + ])); + }); + + test('Rx.window.reusable', () async { + final transformer = WindowStreamTransformer((_) => + Stream.periodic(const Duration(milliseconds: 160)) + .take(3) + .asBroadcastStream()); + + await expectLater( + getStream(4) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + + await expectLater( + getStream(4) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.window.asBroadcastStream', () async { + final future = getStream(4) + .asBroadcastStream() + .window(Stream.periodic(const Duration(milliseconds: 160)) + .take(10) + .asBroadcastStream()) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.window.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()) + .window(Stream.periodic(const Duration(milliseconds: 160))), + emitsError(isException)); + }); + + test('Rx.window.nullable', () { + nullableTest>( + (s) => s.window(Stream.empty()), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/window_test_test.dart b/core/reactivex/test/transformers/backpressure/window_test_test.dart new file mode 100644 index 00000000..b59f8f7d --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/window_test_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +void main() { + test('Rx.windowTest', () async { + await expectLater( + Rx.range(1, 4) + .windowTest((i) => i % 2 == 0) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.windowTest.reusable', () async { + final transformer = WindowTestStreamTransformer((i) => i % 2 == 0); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + + await expectLater( + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [1, 2], + const [3, 4], + emitsDone + ])); + }); + + test('Rx.windowTest.asBroadcastStream', () async { + final future = Stream.fromIterable(const [1, 2, 3, 4]) + .asBroadcastStream() + .windowTest((i) => i % 2 == 0) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.windowTest.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()).windowTest((i) => i % 2 == 0), + emitsError(isException)); + }); + + test('Rx.windowTest.nullable', () { + nullableTest>( + (s) => s.windowTest((_) => true), + ); + }); +} diff --git a/core/reactivex/test/transformers/backpressure/window_time_test.dart b/core/reactivex/test/transformers/backpressure/window_time_test.dart new file mode 100644 index 00000000..8c0e1f4d --- /dev/null +++ b/core/reactivex/test/transformers/backpressure/window_time_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../../utils.dart'; + +/// yield immediately, then every 100ms +Stream getStream(int n) async* { + var k = 1; + + yield 0; + + while (k < n) { + yield await Future.delayed(const Duration(milliseconds: 100)) + .then((_) => k++); + } +} + +void main() { + test('Rx.windowTime', () async { + await expectLater( + getStream(4) + .windowTime(const Duration(milliseconds: 160)) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], + const [2, 3], + emitsDone + ])); + }); + + test('Rx.windowTime.shouldClose', () async { + final controller = StreamController() + ..add(0) + ..add(1) + ..add(2) + ..add(3); + + scheduleMicrotask(controller.close); + + await expectLater( + controller.stream + .windowTime(const Duration(seconds: 3)) + .asyncMap((stream) => stream.toList()) + .take(1), + emitsInOrder([ + const [0, 1, 2, 3], // done + emitsDone + ])); + }); + + test('Rx.windowTime.reusable', () async { + final transformer = WindowStreamTransformer( + (_) => Stream.periodic(const Duration(milliseconds: 160))); + + await expectLater( + getStream(4) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], const [2, 3], // done + emitsDone + ])); + + await expectLater( + getStream(4) + .transform(transformer) + .asyncMap((stream) => stream.toList()), + emitsInOrder([ + const [0, 1], const [2, 3], // done + emitsDone + ])); + }); + + test('Rx.windowTime.asBroadcastStream', () async { + final future = getStream(4) + .asBroadcastStream() + .windowTime(const Duration(milliseconds: 160)) + .drain(); + + // listen twice on same stream + await expectLater(future, completes); + await expectLater(future, completes); + }); + + test('Rx.windowTime.error.shouldThrowA', () async { + await expectLater( + Stream.error(Exception()) + .windowTime(const Duration(milliseconds: 160)), + emitsError(isException)); + }); + + test('Rx.windowTime.nullable', () { + nullableTest>( + (s) => s.windowTime(Duration.zero), + ); + }); +} diff --git a/core/reactivex/test/transformers/concat_with_test.dart b/core/reactivex/test/transformers/concat_with_test.dart new file mode 100644 index 00000000..6535b422 --- /dev/null +++ b/core/reactivex/test/transformers/concat_with_test.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.concatWith', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + const expected = [1, 2]; + var count = 0; + + delayedStream.concatWith([immediateStream]).listen(expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length)); + }); + test('Rx.concatWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.concatWith([Stream.empty()]); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.concatWith on broadcast stream should stay broadcast ', () async { + final delayedStream = + Rx.timer(1, Duration(milliseconds: 10)).asBroadcastStream(); + final immediateStream = Stream.value(2); + final expected = [1, 2, emitsDone]; + + final concatenatedStream = delayedStream.concatWith([immediateStream]); + + expect(concatenatedStream.isBroadcast, isTrue); + expect(concatenatedStream, emitsInOrder(expected)); + }); +} diff --git a/core/reactivex/test/transformers/default_if_empty_test.dart b/core/reactivex/test/transformers/default_if_empty_test.dart new file mode 100644 index 00000000..d6325a1b --- /dev/null +++ b/core/reactivex/test/transformers/default_if_empty_test.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.defaultIfEmpty.whenEmpty', () async { + Stream.empty() + .defaultIfEmpty(true) + .listen(expectAsync1((bool result) { + expect(result, true); + }, count: 1)); + }); + + test('Rx.defaultIfEmpty.reusable', () async { + final transformer = DefaultIfEmptyStreamTransformer(true); + + Stream.empty().transform(transformer).listen(expectAsync1((result) { + expect(result, true); + }, count: 1)); + + Stream.empty().transform(transformer).listen(expectAsync1((result) { + expect(result, true); + }, count: 1)); + }); + + test('Rx.defaultIfEmpty.whenNotEmpty', () async { + Stream.fromIterable(const [false, false, false]) + .defaultIfEmpty(true) + .listen(expectAsync1((result) { + expect(result, false); + }, count: 3)); + }); + + test('Rx.defaultIfEmpty.asBroadcastStream', () async { + final stream = Stream.fromIterable(const []) + .defaultIfEmpty(-1) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.defaultIfEmpty.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()).defaultIfEmpty(-1); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.defaultIfEmpty.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const []).defaultIfEmpty(1); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + test('Rx.defaultIfEmpty accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.defaultIfEmpty(1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.defaultIfEmpty.nullable', () { + nullableTest( + (s) => s.defaultIfEmpty(null), + ); + }); +} diff --git a/core/reactivex/test/transformers/delay_test.dart b/core/reactivex/test/transformers/delay_test.dart new file mode 100644 index 00000000..3c24d340 --- /dev/null +++ b/core/reactivex/test/transformers/delay_test.dart @@ -0,0 +1,127 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.delay', () async { + var value = 1; + _getStream() + .delay(const Duration(milliseconds: 200)) + .listen(expectAsync1((result) { + expect(result, value++); + }, count: 4)); + }); + + test('Rx.delay.zero', () { + expect( + _getStream().delay(Duration.zero), + emitsInOrder([1, 2, 3, 4]), + ); + }); + + test('Rx.delay.shouldBeDelayed', () async { + var value = 1; + _getStream() + .delay(const Duration(milliseconds: 500)) + .timeInterval() + .listen(expectAsync1((result) { + expect(result.value, value++); + + if (result.value == 1) { + expect(result.interval.inMilliseconds, + greaterThanOrEqualTo(500)); // should be delayed + } else { + expect(result.interval.inMilliseconds, + lessThanOrEqualTo(20)); // should be near instantaneous + } + }, count: 4)); + }); + + test('Rx.delay.reusable', () async { + final transformer = + DelayStreamTransformer(const Duration(milliseconds: 200)); + var valueA = 1, valueB = 1; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, valueA++); + }, count: 4)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, valueB++); + }, count: 4)); + }); + + test('Rx.delay.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .delay(const Duration(milliseconds: 200)); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.delay.error.shouldThrowA', () async { + final streamWithError = Stream.error(Exception()) + .delay(const Duration(milliseconds: 200)); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.delay.pause.resume', () async { + late StreamSubscription subscription; + final stream = + Stream.fromIterable(const [1, 2, 3]).delay(Duration(milliseconds: 1)); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test( + 'Rx.delay.cancel.emits.nothing', + () async { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const [1, 2, 3]).doOnDone(() { + subscription.cancel(); + }).delay(Duration(seconds: 10)); + + // We expect the onData callback to be called 0 times because the + // subscription is cancelled when the base stream ends. + subscription = stream.listen(expectAsync1((_) {}, count: 0)); + }, + timeout: Timeout(Duration(seconds: 3)), + ); + + test('Rx.delay accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.delay(Duration(seconds: 10)); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.delay.nullable', () { + nullableTest( + (s) => s.delay(Duration.zero), + ); + }); +} diff --git a/core/reactivex/test/transformers/delay_when_test.dart b/core/reactivex/test/transformers/delay_when_test.dart new file mode 100644 index 00000000..2e37a113 --- /dev/null +++ b/core/reactivex/test/transformers/delay_when_test.dart @@ -0,0 +1,280 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +extension on Duration { + Stream asTimerStream() => Rx.timer(null, this); +} + +void main() { + test('Rx.delayWhen', () { + expect( + _getStream().delayWhen((_) => Stream.value(null)), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + + expect( + _getStream() + .delayWhen((_) => const Duration(milliseconds: 200).asTimerStream()), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + + expect( + _getStream() + .delayWhen((i) => Duration(milliseconds: 100 * i).asTimerStream()), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + + expect( + _getStream().delayWhen( + (i) => Duration(milliseconds: 100 * i).asTimerStream(), + listenDelay: Rx.timer(null, Duration(milliseconds: 100)), + ), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + }); + + test('Rx.delayWhen.zero', () { + expect( + _getStream().delayWhen((_) => Duration.zero.asTimerStream()), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + }); + + test('Rx.delayWhen.shouldBeDelayed', () async { + { + var value = 1; + await _getStream() + .delayWhen((_) => const Duration(milliseconds: 500).asTimerStream()) + .timeInterval() + .forEach(expectAsync1((result) { + expect(result.value, value++); + + if (result.value == 1) { + expect( + result.interval.inMilliseconds, + greaterThanOrEqualTo(500), + ); // should be delayed + } else { + expect( + result.interval.inMilliseconds, + lessThanOrEqualTo(20), + ); // should be near instantaneous + } + }, count: 4)); + } + + { + var value = 1; + await _getStream() + .delayWhen((i) => Duration(milliseconds: 500 * i).asTimerStream()) + .timeInterval() + .forEach(expectAsync1((result) { + expect(result.value, value++); + + expect( + (result.interval.inMilliseconds - 500).abs(), + lessThanOrEqualTo(20), + ); // should be near instantaneous + }, count: 4)); + } + }); + + test('Rx.delayWhen.shouldBeDelayed.listenDelay', () { + var value = 1; + + void onData(TimeInterval result) { + expect(result.value, value++); + + if (result.value == 1) { + expect( + result.interval.inMilliseconds, + greaterThanOrEqualTo(500 + 300), + ); // should be delayed + } else { + expect( + (result.interval.inMilliseconds - 500).abs(), + lessThanOrEqualTo(20), + ); // should be near instantaneous + } + } + + _getStream() + .delayWhen( + (i) => Duration(milliseconds: 500 * i).asTimerStream(), + listenDelay: Rx.timer(null, const Duration(milliseconds: 300)), + ) + .timeInterval() + .listen(expectAsync1(onData, count: 4)); + }); + + test('Rx.delayWhen.reusable', () { + final transformer = DelayWhenStreamTransformer( + (_) => const Duration(milliseconds: 200).asTimerStream()); + + expect( + _getStream().transform(transformer), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + + expect( + _getStream().transform(transformer), + emitsInOrder([1, 2, 3, 4, emitsDone]), + ); + }); + + test('Rx.delayWhen.asBroadcastStream', () { + { + final stream = _getStream() + .asBroadcastStream() + .delayWhen((_) => const Duration(milliseconds: 200).asTimerStream()); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + expect(true, true); + } + + { + final stream = _getStream() + .delayWhen((_) => const Duration(milliseconds: 200).asTimerStream()) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + expect(true, true); + } + + { + final stream = _getStream() + .delayWhen( + (_) => const Duration(milliseconds: 200).asTimerStream(), + listenDelay: Stream.value(null), + ) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + expect(true, true); + } + }); + + test('Rx.delayWhen.error.shouldThrowA', () { + expect( + Stream.error(Exception()) + .delayWhen((_) => const Duration(milliseconds: 200).asTimerStream()), + emitsInOrder([ + emitsError(isA()), + emitsDone, + ]), + ); + }); + + test('Rx.delayWhen.error.shouldThrowB', () { + expect( + Stream.value(0).delayWhen( + (_) => const Duration(milliseconds: 200).asTimerStream(), + listenDelay: Stream.error(Exception('listenDelay')), + ), + emitsInOrder([ + emitsError(isA()), + emitsDone, + ]), + ); + }); + + test('Rx.delayWhen.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const [1, 2, 3]) + .delayWhen((_) => Duration(milliseconds: 1).asTimerStream()); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.delayWhen.pause.resume.listenDelay', () { + late StreamSubscription subscription; + final stream = Stream.fromIterable(const [1, 2, 3]).delayWhen( + (_) => Duration(milliseconds: 1).asTimerStream(), + listenDelay: Rx.timer(null, const Duration(milliseconds: 200)), + ); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test( + 'Rx.delayWhen.cancel.emits.nothing', + () { + late StreamSubscription subscription; + final stream = _getStream() + .doOnDone(() => subscription.cancel()) + .delayWhen((_) => Duration(seconds: 10).asTimerStream()); + + // We expect the onData callback to be called 0 times because the + // subscription is cancelled when the base stream ends. + subscription = stream.listen(expectAsync1((_) {}, count: 0)); + }, + timeout: Timeout(Duration(seconds: 3)), + ); + + test( + 'Rx.delayWhen.cancel.emits.nothing.listenDelay', + () { + late StreamSubscription subscription; + final stream = + _getStream().doOnDone(() => subscription.cancel()).delayWhen( + (_) => Duration(seconds: 10).asTimerStream(), + listenDelay: Stream.periodic(const Duration(seconds: 1)), + ); + + // We expect the onData callback to be called 0 times because the + // subscription is cancelled when the base stream ends. + subscription = stream.listen(expectAsync1((_) {}, count: 0)); + }, + timeout: Timeout(Duration(seconds: 3)), + ); + + test('Rx.delayWhen.singleSubscription', () async { + final controller = StreamController(); + + final stream = controller.stream + .delayWhen((_) => Duration(seconds: 10).asTimerStream()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.delayWhen.nullable', () { + nullableTest( + (s) => s.delayWhen((_) => Duration.zero.asTimerStream()), + ); + }); +} diff --git a/core/reactivex/test/transformers/dematerialize_test.dart b/core/reactivex/test/transformers/dematerialize_test.dart new file mode 100644 index 00000000..c4fdb57a --- /dev/null +++ b/core/reactivex/test/transformers/dematerialize_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.dematerialize.happyPath', () async { + const expectedValue = 1; + final stream = Stream.value(1).materialize(); + + stream.dematerialize().listen(expectAsync1((value) { + expect(value, expectedValue); + }), onDone: expectAsync0(() { + // Should call onDone + expect(true, isTrue); + })); + }); + + test('Rx.dematerialize.nullable.happyPath', () async { + const elements = [1, 2, null, 3, 4, null]; + final stream = Stream.fromIterable(elements).materialize(); + + expect( + stream.dematerialize(), + emitsInOrder(elements), + ); + }); + + test('Rx.dematerialize.reusable', () async { + final transformer = DematerializeStreamTransformer(); + const expectedValue = 1; + final streamA = Stream.value(1).materialize(); + final streamB = Stream.value(1).materialize(); + + streamA.transform(transformer).listen(expectAsync1((value) { + expect(value, expectedValue); + }), onDone: expectAsync0(() { + // Should call onDone + expect(true, isTrue); + })); + + streamB.transform(transformer).listen(expectAsync1((value) { + expect(value, expectedValue); + }), onDone: expectAsync0(() { + // Should call onDone + expect(true, isTrue); + })); + }); + + test('dematerializeTransformer.happyPath', () async { + const expectedValue = 1; + final stream = Stream.fromIterable([ + StreamNotification.data(expectedValue), + StreamNotification.done() + ]); + + stream.transform(DematerializeStreamTransformer()).listen( + expectAsync1((value) { + expect(value, expectedValue); + }), onDone: expectAsync0(() { + // Should call onDone + expect(true, isTrue); + })); + }); + + test('dematerializeTransformer.sadPath', () async { + final stream = Stream.fromIterable( + [StreamNotification.error(Exception(), Chain.current())]); + + stream.transform(DematerializeStreamTransformer()).listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('dematerializeTransformer.onPause.onResume', () async { + const expectedValue = 1; + final stream = Stream.fromIterable([ + StreamNotification.data(expectedValue), + StreamNotification.done() + ]); + + stream.transform(DematerializeStreamTransformer()).listen( + expectAsync1((value) { + expect(value, expectedValue); + }), onDone: expectAsync0(() { + // Should call onDone + expect(true, isTrue); + })) + ..pause() + ..resume(); + }); + + test('Rx.dematerialize accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.materialize().dematerialize(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); +} diff --git a/core/reactivex/test/transformers/distinct_test.dart b/core/reactivex/test/transformers/distinct_test.dart new file mode 100644 index 00000000..0775b7f3 --- /dev/null +++ b/core/reactivex/test/transformers/distinct_test.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +import 'package:test/test.dart'; + +void main() { + test('Rx.distinct', () async { + const expected = 1; + + final stream = Stream.fromIterable(const [expected, expected]).distinct(); + + stream.listen(expectAsync1((actual) { + expect(actual, expected); + })); + }); + test('Rx.distinct accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.distinct(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); +} diff --git a/core/reactivex/test/transformers/distinct_unique_test.dart b/core/reactivex/test/transformers/distinct_unique_test.dart new file mode 100644 index 00000000..6c4c9ddf --- /dev/null +++ b/core/reactivex/test/transformers/distinct_unique_test.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + group('DistinctUniqueStreamTransformer', () { + test('works with the equals and hascode of the class', () async { + final stream = Stream.fromIterable(const [ + _TestObject('a'), + _TestObject('a'), + _TestObject('b'), + _TestObject('a'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a') + ]).distinctUnique(); + + await expectLater( + stream, + emitsInOrder([ + const _TestObject('a'), + const _TestObject('b'), + const _TestObject('c'), + emitsDone + ])); + }); + + test('works with a provided equals and hashcode', () async { + final stream = Stream.fromIterable(const [ + _TestObject('a'), + _TestObject('a'), + _TestObject('b'), + _TestObject('a'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a') + ]).distinctUnique( + equals: (a, b) => a.key == b.key, hashCode: (o) => o.key.hashCode); + + await expectLater( + stream, + emitsInOrder([ + const _TestObject('a'), + const _TestObject('b'), + const _TestObject('c'), + emitsDone + ])); + }); + + test( + 'sends an error to the subscription if an error occurs in the equals or hashmap methods', + () async { + final stream = Stream.fromIterable( + const [_TestObject('a'), _TestObject('b'), _TestObject('c')]) + .distinctUnique( + equals: (a, b) => a.key == b.key, + hashCode: (o) => throw Exception('Catch me if you can!')); + + stream.listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + count: 3, + ), + ); + }); + + test('is reusable', () async { + const data = [ + _TestObject('a'), + _TestObject('a'), + _TestObject('b'), + _TestObject('a'), + _TestObject('a'), + _TestObject('b'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a'), + _TestObject('b'), + _TestObject('c'), + _TestObject('a') + ]; + + final distinctUniqueStreamTransformer = + DistinctUniqueStreamTransformer<_TestObject>(); + + final firstStream = + Stream.fromIterable(data).transform(distinctUniqueStreamTransformer); + + final secondStream = + Stream.fromIterable(data).transform(distinctUniqueStreamTransformer); + + await expectLater( + firstStream, + emitsInOrder([ + const _TestObject('a'), + const _TestObject('b'), + const _TestObject('c'), + emitsDone + ])); + + await expectLater( + secondStream, + emitsInOrder([ + const _TestObject('a'), + const _TestObject('b'), + const _TestObject('c'), + emitsDone + ])); + }); + + test('Rx.distinctUnique accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.distinctUnique(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + }); + + test('Rx.distinctUnique.nullable', () { + nullableTest( + (s) => s.distinctUnique(), + ); + }); +} + +class _TestObject { + final String key; + + const _TestObject(this.key); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _TestObject && + runtimeType == other.runtimeType && + key == other.key; + + @override + int get hashCode => key.hashCode; + + @override + String toString() => key; +} diff --git a/core/reactivex/test/transformers/do_test.dart b/core/reactivex/test/transformers/do_test.dart new file mode 100644 index 00000000..b99fe287 --- /dev/null +++ b/core/reactivex/test/transformers/do_test.dart @@ -0,0 +1,489 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + group('DoStreamTranformer', () { + test('calls onDone when the stream is finished', () async { + var onDoneCalled = false; + final stream = Stream.empty().doOnDone(() => onDoneCalled = true); + + await expectLater(stream, emitsDone); + await expectLater(onDoneCalled, isTrue); + }); + + test('calls onError when an error is emitted', () async { + var onErrorCalled = false; + final stream = Stream.error(Exception()) + .doOnError((e, s) => onErrorCalled = true); + + await expectLater(stream, emitsError(isException)); + await expectLater(onErrorCalled, isTrue); + }); + + test( + 'onError only called once when an error is emitted on a broadcast stream', + () async { + var count = 0; + final subject = BehaviorSubject(sync: true); + final stream = subject.stream.doOnError((e, s) => count++); + + stream.listen(null, onError: (dynamic e, dynamic s) {}); + stream.listen(null, onError: (dynamic e, dynamic s) {}); + + subject.addError(Exception()); + subject.addError(Exception()); + + await expectLater(count, 2); + await subject.close(); + }); + + test('calls onCancel when the subscription is cancelled', () async { + var onCancelCalled = false; + final stream = Stream.value(1); + + await stream + .doOnCancel(() => onCancelCalled = true) + .listen(null) + .cancel(); + + await expectLater(onCancelCalled, isTrue); + }); + + test('awaits onCancel when the subscription is cancelled', () async { + var onCancelCompleted = 10, onCancelHandled = 10, eventSequenceCount = 0; + final stream = Stream.value(1); + + await stream + .doOnCancel(() => + Future.delayed(const Duration(milliseconds: 100)) + .whenComplete(() => onCancelHandled = ++eventSequenceCount)) + .listen(null) + .cancel() + .whenComplete(() => onCancelCompleted = ++eventSequenceCount); + + await expectLater(onCancelCompleted > onCancelHandled, isTrue); + }); + + test( + 'onCancel called only once when the subscription is multiple listeners', + () async { + var count = 0; + final subject = BehaviorSubject(sync: true); + final stream = subject.doOnCancel(() => count++); + + await stream.listen(null).cancel(); + await stream.listen(null).cancel(); + + await expectLater(count, 2); + await subject.close(); + }); + + test('calls onData when the stream emits an item', () async { + var onDataCalled = false; + final stream = Stream.value(1).doOnData((_) => onDataCalled = true); + + await expectLater(stream, emits(1)); + await expectLater(onDataCalled, isTrue); + }); + + test('onData only emits once for broadcast streams with multiple listeners', + () async { + final actual = []; + final controller = StreamController.broadcast(sync: true); + final stream = + controller.stream.transform(DoStreamTransformer(onData: actual.add)); + + stream.listen(null); + stream.listen(null); + + controller.add(1); + controller.add(2); + + await expectLater(actual, const [1, 2]); + await controller.close(); + }); + + test('onData only emits once for subjects with multiple listeners', + () async { + final actual = []; + final controller = BehaviorSubject(sync: true); + final stream = + controller.stream.transform(DoStreamTransformer(onData: actual.add)); + + stream.listen(null); + stream.listen(null); + + controller.add(1); + controller.add(2); + + await expectLater(actual, const [1, 2]); + await controller.close(); + }); + + test('onData only emits correctly with ReplaySubject', () async { + final controller = ReplaySubject(sync: true) + ..add(1) + ..add(2); + final actual = []; + + await controller.close(); + + expect(await controller.stream.doOnData(actual.add).drain(actual), + const [1, 2]); + + actual.clear(); + + expect(await controller.stream.doOnData(actual.add).drain(actual), + const [1, 2]); + }); + + test('emits onEach Notifications for Data, Error, and Done', () async { + StackTrace? stacktrace; + final actual = >[]; + final exception = Exception(); + final stream = Stream.value(1) + .concatWith([Stream.error(exception)]).doOnEach((notification) { + actual.add(notification); + + if (notification.isError) { + stacktrace = notification.errorAndStackTraceOrNull?.stackTrace; + } + }); + + await expectLater(stream, + emitsInOrder([1, emitsError(isException), emitsDone])); + + await expectLater(actual, [ + StreamNotification.data(1), + StreamNotification.error(exception, stacktrace), + StreamNotification.done() + ]); + }); + + test('onEach only emits once for broadcast streams with multiple listeners', + () async { + var count = 0; + final controller = StreamController.broadcast(sync: true); + final stream = + controller.stream.transform(DoStreamTransformer(onEach: (_) { + count++; + })); + + stream.listen(null); + stream.listen(null); + + controller.add(1); + controller.add(2); + + await expectLater(count, 2); + await controller.close(); + }); + + test('calls onListen when a consumer listens', () async { + var onListenCalled = false; + final stream = Stream.empty().doOnListen(() { + onListenCalled = true; + }); + + await expectLater(stream, emitsDone); + await expectLater(onListenCalled, isTrue); + }); + + test( + 'calls onListen once when multiple subscribers open, without cancelling', + () async { + var onListenCallCount = 0; + final sc = StreamController.broadcast() + ..add(1) + ..add(2) + ..add(3); + + final stream = sc.stream.doOnListen(() => onListenCallCount++); + + stream.listen(null); + stream.listen(null); + + await expectLater(onListenCallCount, 1); + await sc.close(); + }); + + test( + 'calls onListen every time after all previous subscribers have cancelled', + () async { + var onListenCallCount = 0; + final sc = StreamController.broadcast() + ..add(1) + ..add(2) + ..add(3); + + final stream = sc.stream.doOnListen(() => onListenCallCount++); + + await stream.listen(null).cancel(); + await stream.listen(null).cancel(); + + await expectLater(onListenCallCount, 2); + await sc.close(); + }); + + test('calls onPause and onResume when the subscription is', () async { + var onPauseCalled = false, onResumeCalled = false; + final stream = Stream.value(1).doOnPause(() { + onPauseCalled = true; + }).doOnResume(() { + onResumeCalled = true; + }); + + stream.listen(null, onDone: expectAsync0(() { + expect(onPauseCalled, isTrue); + expect(onResumeCalled, isTrue); + })) + ..pause() + ..resume(); + }); + + test('should be reusable', () async { + var callCount = 0; + final transformer = DoStreamTransformer(onData: (_) { + callCount++; + }); + + final streamA = Stream.value(1).transform(transformer), + streamB = Stream.value(1).transform(transformer); + + await expectLater(streamA, emitsInOrder([1, emitsDone])); + await expectLater(streamB, emitsInOrder([1, emitsDone])); + + expect(callCount, 2); + }); + + test('throws an error when no arguments are provided', () { + expect(() => DoStreamTransformer(), throwsArgumentError); + }); + + test('should propagate errors', () { + Stream.value(1) + .doOnListen(() => throw Exception('catch me if you can! doOnListen')) + .listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + ), + ); + + Stream.value(1) + .doOnData((_) => throw Exception('catch me if you can! doOnData')) + .listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + ), + ); + + Stream.error(Exception('oh noes!')) + .doOnError( + (_, __) => throw Exception('catch me if you can! doOnError')) + .listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + count: 2, + ), + ); + + // a cancel() call may occur after the controller is already closed + // in that case, the error is forwarded to the current [Zone] + runZonedGuarded( + () { + Stream.value(1) + .doOnCancel(() => + throw Exception('catch me if you can! doOnCancel-zoned')) + .listen(null); + + Stream.value(1) + .doOnCancel( + () => throw Exception('catch me if you can! doOnCancel')) + .listen(null) + .cancel(); + }, + expectAsync2( + (Object e, StackTrace s) => expect(e, isException), + count: 2, + ), + ); + + Stream.value(1) + .doOnDone(() => throw Exception('catch me if you can! doOnDone')) + .listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + ), + ); + + Stream.value(1) + .doOnEach((_) => throw Exception('catch me if you can! doOnEach')) + .listen( + null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + count: 2, + ), + ); + + Stream.value(1) + .doOnPause(() => throw Exception('catch me if you can! doOnPause')) + .listen(null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException), + )) + ..pause() + ..resume(); + + Stream.value(1) + .doOnResume(() => throw Exception('catch me if you can! doOnResume')) + .listen(null, + onError: expectAsync2( + (Exception e, StackTrace s) => expect(e, isException))) + ..pause() + ..resume(); + }); + + test( + 'doOnListen correctly allows subscribing multiple times on a broadcast stream', + () { + final controller = StreamController.broadcast(); + final stream = controller.stream.doOnListen(() { + // do nothing + }); + + controller.close(); + + expectLater(stream, emitsDone); + expectLater(stream, emitsDone); + }); + + test('issue/389/1', () { + final controller = StreamController.broadcast(); + final stream = controller.stream.doOnListen(() { + // do nothing + }); + + expectLater(stream, emitsDone); + expectLater(stream, emitsDone); // #issue/389 : is being ignored/hangs up + + controller.close(); + }); + + test('issue/389/2', () { + final controller = StreamController(); + var isListening = false; + + final stream = controller.stream.doOnListen(() { + isListening = true; + }); + + controller.close(); + + // should be done + expectLater(stream, emitsDone); + // should have called onX + expect(isListening, true); + // should not be converted to a broadcast Stream + expect(() => stream.listen(null), throwsStateError); + }); + + test('Rx.do accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.doOnEach((_) {}); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('nested doOnX', () async { + final completer = Completer(); + final stream = + Rx.range(0, 30).interval(const Duration(milliseconds: 100)); + final result = []; + const expectedOutput = [ + 'A: 0', + 'B: 0', + 'pause', + 'A: 1', + 'B: 1', + 'A: 2', + 'B: 2', + 'A: 3', + 'B: 3', + 'A: 4', + 'B: 4', + 'A: 5', + 'B: 5', + 'pause', + 'A: 6', + 'B: 6', + 'A: 7', + 'B: 7', + 'A: 8', + 'B: 8', + 'A: 9', + 'B: 9', + 'A: 10', + 'B: 10', + 'pause', + 'A: 11', + 'B: 11', + 'A: 12', + 'B: 12', + 'A: 13', + 'B: 13', + 'A: 14', + 'B: 14', + 'A: 15', + 'B: 15', + 'pause', + 'A: 16', + 'B: 16', + 'A: 17', + ]; + late StreamSubscription subscription; + + void addToResult(String value) { + result.add(value); + + if (result.length == expectedOutput.length) { + subscription.cancel(); + completer.complete(); + } + } + + subscription = Stream.value(1) + .exhaustMap((_) => stream.doOnData((data) => addToResult('A: $data'))) + .doOnPause(() => addToResult('pause')) + .doOnData((data) => addToResult('B: $data')) + .take(expectedOutput.length) + .listen((value) { + if (value % 5 == 0) { + subscription.pause(Future.delayed(const Duration(seconds: 2))); + } + }); + + await completer.future; + + expect(result, expectedOutput); + }); + + test('doOnData nullable', () { + nullableTest( + (s) => s.doOnData((d) {}), + ); + }); + }); +} diff --git a/core/reactivex/test/transformers/end_with_many_test.dart b/core/reactivex/test/transformers/end_with_many_test.dart new file mode 100644 index 00000000..ab284372 --- /dev/null +++ b/core/reactivex/test/transformers/end_with_many_test.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.endWithMany', () async { + const expectedOutput = [1, 2, 3, 4, 5, 6]; + + await expectLater( + _getStream().endWithMany(const [5, 6]), emitsInOrder(expectedOutput)); + }); + + test('Rx.endWithMany.reusable', () async { + final transformer = EndWithManyStreamTransformer(const [5, 6]); + const expectedOutput = [1, 2, 3, 4, 5, 6]; + + await expectLater( + _getStream().transform(transformer), emitsInOrder(expectedOutput)); + await expectLater( + _getStream().transform(transformer), emitsInOrder(expectedOutput)); + }); + + test('Rx.endWithMany.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().endWithMany(const [5, 6]); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.endWithMany.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).endWithMany(const [5, 6]); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('Rx.endWithMany.pause.resume', () async { + const expectedOutput = [1, 2, 3, 4, 5, 6]; + var count = 0; + + late StreamSubscription subscription; + subscription = + _getStream().endWithMany(const [5, 6]).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + test('Rx.endWithMany accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.endWithMany(const [1, 2, 3]); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.endWithMany.nullable', () { + nullableTest( + (s) => s.endWithMany(['String']), + ); + }); +} diff --git a/core/reactivex/test/transformers/end_with_test.dart b/core/reactivex/test/transformers/end_with_test.dart new file mode 100644 index 00000000..d54562bb --- /dev/null +++ b/core/reactivex/test/transformers/end_with_test.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.endWith', () async { + const expectedOutput = [1, 2, 3, 4, 5]; + + await expectLater(_getStream().endWith(5), emitsInOrder(expectedOutput)); + }); + + test('Rx.endWith.reusable', () async { + final transformer = EndWithStreamTransformer(5); + const expectedOutput = [1, 2, 3, 4, 5]; + + await expectLater( + _getStream().transform(transformer), emitsInOrder(expectedOutput)); + await expectLater( + _getStream().transform(transformer), emitsInOrder(expectedOutput)); + }); + + test('Rx.endWith.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().endWith(5); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.endWith.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()).endWith(5); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('Rx.endWith.pause.resume', () async { + const expectedOutput = [1, 2, 3, 4, 5]; + var count = 0; + + late StreamSubscription subscription; + subscription = _getStream().endWith(5).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.endWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.endWith(1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.endWith.nullable', () { + nullableTest( + (s) => s.endWith('String'), + ); + }); +} diff --git a/core/reactivex/test/transformers/exhaust_map_test.dart b/core/reactivex/test/transformers/exhaust_map_test.dart new file mode 100644 index 00000000..0f9137fc --- /dev/null +++ b/core/reactivex/test/transformers/exhaust_map_test.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + group('ExhaustMap', () { + test('does not create a new Stream while emitting', () async { + var calls = 0; + final stream = Rx.range(0, 9).exhaustMap((i) { + calls++; + return Rx.timer(i, Duration(milliseconds: 100)); + }); + + await expectLater(stream, emitsInOrder([0, emitsDone])); + await expectLater(calls, 1); + }); + + test('starts emitting again after previous Stream is complete', () async { + final stream = Stream.fromIterable(const [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + .interval(Duration(milliseconds: 30)) + .exhaustMap((i) async* { + yield await Future.delayed(Duration(milliseconds: 70), () => i); + }); + + await expectLater(stream, emitsInOrder([0, 3, 6, 9, emitsDone])); + }); + + test('is reusable', () async { + final transformer = ExhaustMapStreamTransformer( + (int i) => Rx.timer(i, Duration(milliseconds: 100))); + + await expectLater( + Rx.range(0, 9).transform(transformer), + emitsInOrder([0, emitsDone]), + ); + + await expectLater( + Rx.range(0, 9).transform(transformer), + emitsInOrder([0, emitsDone]), + ); + }); + + test('works as a broadcast stream', () async { + final stream = Rx.range(0, 9) + .asBroadcastStream() + .exhaustMap((i) => Rx.timer(i, Duration(milliseconds: 100))); + + await expectLater(() { + stream.listen(null); + stream.listen(null); + }, returnsNormally); + }); + + test('should emit errors from source', () async { + final streamWithError = Stream.error(Exception()) + .exhaustMap((i) => Rx.timer(i, Duration(milliseconds: 100))); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('should emit errors from mapped stream', () async { + final streamWithError = Stream.value(1).exhaustMap( + (_) => Stream.error(Exception('Catch me if you can!'))); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('should emit errors thrown in the mapper', () async { + final streamWithError = Stream.value(1).exhaustMap((_) { + throw Exception('oh noes!'); + }); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('can be paused and resumed', () async { + late StreamSubscription subscription; + final stream = Rx.range(0, 9) + .exhaustMap((i) => Rx.timer(i, Duration(milliseconds: 20))); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 0); + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.exhaustMap accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.exhaustMap((_) => Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.exhaustMap.nullable', () { + nullableTest( + (s) => s.exhaustMap((v) => Stream.value(v)), + ); + }); + }); +} diff --git a/core/reactivex/test/transformers/flat_map_iterable_test.dart b/core/reactivex/test/transformers/flat_map_iterable_test.dart new file mode 100644 index 00000000..e1cb944c --- /dev/null +++ b/core/reactivex/test/transformers/flat_map_iterable_test.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + group('Rx.flatMapIterable', () { + test('transforms a Stream> into individual items', () { + expect( + Rx.range(1, 4) + .flatMapIterable((int i) => Stream>.value([i])), + emitsInOrder([1, 2, 3, 4, emitsDone])); + }); + + test('accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream + .flatMapIterable((int i) => Stream>.value([i])); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('nullable', () { + nullableTest( + (s) => s.flatMapIterable((v) => Stream.value([v])), + ); + }); + }); +} diff --git a/core/reactivex/test/transformers/flat_map_test.dart b/core/reactivex/test/transformers/flat_map_test.dart new file mode 100644 index 00000000..db994f0f --- /dev/null +++ b/core/reactivex/test/transformers/flat_map_test.dart @@ -0,0 +1,267 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.flatMap', () async { + const expectedOutput = [3, 2, 1]; + var count = 0; + + _getStream().flatMap(_getOtherStream).listen(expectAsync1((result) { + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.flatMap.reusable', () async { + final transformer = FlatMapStreamTransformer(_getOtherStream); + const expectedOutput = [3, 2, 1]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, expectedOutput[countA++]); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, expectedOutput[countB++]); + }, count: expectedOutput.length)); + }); + + test('Rx.flatMap.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().flatMap(_getOtherStream); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.flatMap.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).flatMap(_getOtherStream); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.flatMap.error.shouldThrowB', () async { + final streamWithError = Stream.value(1) + .flatMap((_) => Stream.error(Exception('Catch me if you can!'))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.flatMap.error.shouldThrowC', () async { + final streamWithError = + Stream.value(1).flatMap((_) => throw Exception('oh noes!')); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.flatMap.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.value(0).flatMap((_) => Stream.value(1)); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.flatMap.chains', () { + expect( + Stream.value(1) + .flatMap((_) => Stream.value(2)) + .flatMap((_) => Stream.value(3)), + emitsInOrder([3, emitsDone]), + ); + }); + + test('Rx.flatMap accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.flatMap((_) => Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.flatMap(maxConcurrent: 1)', () { + { + // asyncExpand / concatMap + final stream = Stream.fromIterable([1, 2, 3, 4]).flatMap( + (value) => Rx.timer( + value, + Duration(milliseconds: (5 - value) * 100), + ), + maxConcurrent: 1, + ); + expect(stream, emitsInOrder([1, 2, 3, 4, emitsDone])); + } + + { + // emits error + final stream = Stream.fromIterable([1, 2, 3, 4]).flatMap( + (value) => value == 1 + ? throw Exception() + : Rx.timer( + value, + Duration(milliseconds: (5 - value) * 100), + ), + maxConcurrent: 1, + ); + expect(stream, + emitsInOrder([emitsError(isException), 2, 3, 4, emitsDone])); + } + + { + // emits error + final stream = Stream.fromIterable([1, 2, 3, 4]).flatMap( + (value) => value == 1 + ? Stream.error(Exception()) + : Rx.timer( + value, + Duration(milliseconds: (5 - value) * 100), + ), + maxConcurrent: 1, + ); + expect(stream, + emitsInOrder([emitsError(isException), 2, 3, 4, emitsDone])); + } + }); + + test('Rx.flatMap(maxConcurrent: 2)', () async { + const maxConcurrent = 2; + var activeCount = 0; + + // 1 -> 500 + // 2 -> 400 + // 3 -> 500 + // 4 -> 200 + // -----1--4 + // ----2-----3 + // ----21--4-3 + final stream = Stream.fromIterable([1, 2, 3, 4]).flatMap( + (value) { + return Rx.defer(() { + expect(++activeCount, lessThanOrEqualTo(maxConcurrent)); + + final ms = (value.isOdd ? 5 : 6 - value) * 100; + return Rx.timer(value, Duration(milliseconds: ms)); + }).doOnDone(() => --activeCount); + }, + maxConcurrent: maxConcurrent, + ); + + await expectLater(stream, emitsInOrder([2, 1, 4, 3, emitsDone])); + }); + + test('Rx.flatMap(maxConcurrent: 3)', () async { + const maxConcurrent = 3; + var activeCount = 0; + + // 1 -> 400 + // 2 -> 300 + // 3 -> 200 + // 4 -> 200 + // 5 -> 300 + // 6 -> 400 + // ----1----6 + // ---2---5 + // --3--4 + // --3214-5-6 + final stream = Stream.fromIterable([1, 2, 3, 4, 5, 6]).flatMap( + (value) { + return Rx.defer(() { + expect(++activeCount, lessThanOrEqualTo(maxConcurrent)); + + final ms = (value <= 3 ? 5 - value : value - 2) * 100; + return Rx.timer(value, Duration(milliseconds: ms)); + }).doOnDone(() => --activeCount); + }, + maxConcurrent: maxConcurrent, + ); + + await expectLater( + stream, emitsInOrder([3, 2, 1, 4, 5, 6, emitsDone])); + }); + + test('Rx.flatMap.cancel', () { + _getStream() + .flatMap(_getOtherStream) + .listen(expectAsync1((data) {}, count: 0)) + .cancel(); + }, timeout: const Timeout(Duration(milliseconds: 200))); + + test('Rx.flatMap(maxConcurrent: 1).cancel', () { + _getStream() + .flatMap(_getOtherStream, maxConcurrent: 1) + .listen(expectAsync1((data) {}, count: 0)) + .cancel(); + }, timeout: const Timeout(Duration(milliseconds: 200))); + + test('Rx.flatMap.take.cancel', () { + _getStream() + .flatMap(_getOtherStream) + .take(1) + .listen(expectAsync1((data) => expect(data, 3), count: 1)); + }, timeout: const Timeout(Duration(milliseconds: 200))); + + test('Rx.flatMap(maxConcurrent: 1).take.cancel', () { + _getStream() + .flatMap(_getOtherStream, maxConcurrent: 1) + .take(1) + .listen(expectAsync1((data) => expect(data, 1), count: 1)); + }, timeout: const Timeout(Duration(milliseconds: 200))); + + test('Rx.flatMap(maxConcurrent: 2).take.cancel', () { + _getStream() + .flatMap(_getOtherStream, maxConcurrent: 2) + .take(1) + .listen(expectAsync1((data) => expect(data, 2), count: 1)); + }, timeout: const Timeout(Duration(milliseconds: 200))); + + test('Rx.flatMap.nullable', () { + nullableTest( + (s) => s.flatMap((v) => Stream.value(v)), + ); + }); +} + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3]); + +Stream _getOtherStream(int value) { + final controller = StreamController(); + + Timer( + // Reverses the order of 1, 2, 3 to 3, 2, 1 by delaying 1, and 2 longer + // than they delay 3 + Duration( + milliseconds: value == 1 + ? 15 + : value == 2 + ? 10 + : 5), () { + controller.add(value); + controller.close(); + }); + + return controller.stream; +} diff --git a/core/reactivex/test/transformers/group_by_test.dart b/core/reactivex/test/transformers/group_by_test.dart new file mode 100644 index 00000000..9b896ea1 --- /dev/null +++ b/core/reactivex/test/transformers/group_by_test.dart @@ -0,0 +1,312 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +String _toEventOdd(int value) => value == 0 ? 'even' : 'odd'; + +void main() { + test('Rx.groupBy', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, 4]).groupBy((value) => value), + emitsInOrder([ + TypeMatcher>() + .having((stream) => stream.key, 'key', 1), + TypeMatcher>() + .having((stream) => stream.key, 'key', 2), + TypeMatcher>() + .having((stream) => stream.key, 'key', 3), + TypeMatcher>() + .having((stream) => stream.key, 'key', 4), + emitsDone + ])); + + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => value, durationSelector: (_) => Rx.never()), + emitsInOrder([ + TypeMatcher>() + .having((stream) => stream.key, 'key', 1), + TypeMatcher>() + .having((stream) => stream.key, 'key', 2), + TypeMatcher>() + .having((stream) => stream.key, 'key', 3), + TypeMatcher>() + .having((stream) => stream.key, 'key', 4), + emitsDone + ])); + }); + + test('Rx.groupBy.correctlyEmitsGroupEvents', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => _toEventOdd(value % 2)) + .flatMap((stream) => stream.map((event) => {stream.key: event})), + emitsInOrder([ + {'odd': 1}, + {'even': 2}, + {'odd': 3}, + {'even': 4}, + emitsDone + ])); + + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy( + (value) => _toEventOdd(value % 2), + durationSelector: (_) => + Stream.periodic(const Duration(seconds: 1)), + ) + .flatMap((stream) => stream.map((event) => {stream.key: event})), + emitsInOrder([ + {'odd': 1}, + {'even': 2}, + {'odd': 3}, + {'even': 4}, + emitsDone + ])); + }); + + test('Rx.groupBy.correctlyEmitsGroupEvents.alternate', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => _toEventOdd(value % 2)) + // fold is called when onDone triggers on the Stream + .map((stream) async => await stream.fold( + {stream.key: []}, + (Map> previous, element) => + previous..[stream.key]?.add(element))), + emitsInOrder([ + { + 'odd': [1, 3] + }, + { + 'even': [2, 4] + }, + emitsDone + ])); + + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy( + (value) => _toEventOdd(value % 2), + durationSelector: (_) => + Stream.periodic(const Duration(seconds: 1)), + ) + // fold is called when onDone triggers on the Stream + .map((stream) async => await stream.fold( + {stream.key: []}, + (Map> previous, element) => + previous..[stream.key]?.add(element))), + emitsInOrder([ + { + 'odd': [1, 3] + }, + { + 'even': [2, 4] + }, + emitsDone + ])); + }); + + test('Rx.groupBy.emittedStreamCallOnDone', () async { + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => value) + // drain will emit 'done' onDone + .map((stream) async => await stream.drain('done')), + emitsInOrder(['done', 'done', 'done', 'done', emitsDone])); + + await expectLater( + Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => value, durationSelector: (_) => Rx.never()) + // drain will emit 'done' onDone + .map((stream) async => await stream.drain('done')), + emitsInOrder(['done', 'done', 'done', 'done', emitsDone])); + }); + + test('Rx.groupBy.asBroadcastStream', () async { + { + final stream = Stream.fromIterable([1, 2, 3, 4]) + .asBroadcastStream() + .groupBy((value) => value); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + } + + { + final stream = + Stream.fromIterable([1, 2, 3, 4]).asBroadcastStream().groupBy( + (value) => value, + durationSelector: (_) => + Stream.periodic(const Duration(seconds: 2)), + ); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + } + }); + + test('Rx.groupBy.pause.resume', () async { + { + var count = 0; + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3, 4]) + .groupBy((value) => value) + .listen(expectAsync1((result) { + count++; + + if (count == 4) { + subscription.cancel(); + } + }, count: 4)); + + subscription + .pause(Future.delayed(const Duration(milliseconds: 100))); + } + + { + var count = 0; + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3, 4]) + .groupBy( + (value) => value, + durationSelector: (_) => Rx.timer(null, const Duration(seconds: 1)), + ) + .listen(expectAsync1((result) { + count++; + + if (count == 4) { + subscription.cancel(); + } + }, count: 4)); + + subscription + .pause(Future.delayed(const Duration(milliseconds: 100))); + } + }); + + test('Rx.groupBy.error.shouldThrow.onError', () async { + { + final streamWithError = + Stream.error(Exception()).groupBy((value) => value); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + } + + { + final streamWithError = Stream.error(Exception()).groupBy( + (value) => value, + durationSelector: (_) => Rx.timer(null, const Duration(seconds: 1)), + ); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + } + }); + + test('Rx.groupBy.error.shouldThrow.onGrouper', () async { + { + final streamWithError = + Stream.fromIterable([1, 2, 3, 4]).groupBy((value) { + throw Exception(); + }); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + }, count: 4)); + } + + { + final streamWithError = Stream.fromIterable([1, 2, 3, 4]).groupBy( + (value) => throw Exception(), + durationSelector: (_) => Rx.timer(null, const Duration(seconds: 1)), + ); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + }, count: 4)); + } + }); + test('Rx.groupBy accidental broadcast', () async { + { + final controller = StreamController(); + + final stream = controller.stream.groupBy((_) => _); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + } + + { + final controller = StreamController(); + + final stream = controller.stream.groupBy( + (_) => _, + durationSelector: (_) => Rx.timer(null, const Duration(seconds: 1)), + ); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + } + }); + + test('Rx.groupBy.durationSelector', () { + final g = [ + '0 -> 1', + '1 -> 1', + '2 -> 1', + '0 -> 2', + '1 -> 2', + '2 -> 2', + ]; + final take = 30; + + final stream = Stream.periodic(const Duration(milliseconds: 100), (i) => i) + .groupBy( + (i) => i % 3, + durationSelector: (i) => + Rx.timer(null, const Duration(milliseconds: 400)), + ) + .flatMap((g) => g + .scan((acc, value, index) => acc + 1, 0) + .map((event) => '${g.key} -> $event')) + .take(take); + + expect( + stream, + emitsInOrder([ + ...List.filled(take ~/ g.length, g).expand((e) => e), + emitsDone, + ]), + ); + }); + + test('Rx.groupBy.nullable', () { + nullableTest>( + (s) => s.groupBy((v) => v), + ); + }); +} diff --git a/core/reactivex/test/transformers/ignore_elements_test.dart b/core/reactivex/test/transformers/ignore_elements_test.dart new file mode 100644 index 00000000..9673be5f --- /dev/null +++ b/core/reactivex/test/transformers/ignore_elements_test.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add(2)); + Timer(const Duration(milliseconds: 300), () => controller.add(3)); + Timer(const Duration(milliseconds: 400), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.ignoreElements', () async { + var hasReceivedEvent = false; + + _getStream().ignoreElements().listen((_) { + hasReceivedEvent = true; + }, + onDone: expectAsync0(() { + expect(hasReceivedEvent, isFalse); + }, count: 1)); + + expect( + _getStream().ignoreElements(), + emitsInOrder([emitsDone]), + ); + }); + + test('Rx.ignoreElements.cast', () { + final ignored = _getStream().ignoreElements(); + + expect(ignored, isA>()); + expect(ignored, isA>()); // ignore: prefer_void_to_null + expect(ignored, isA>()); + expect(ignored, isA>()); + expect(ignored, isA>()); + expect(ignored, isA>()); + + ignored as Stream; // ignore: unnecessary_cast + ignored as Stream; // ignore: unnecessary_cast, prefer_void_to_null + ignored as Stream; // ignore: unnecessary_cast + ignored as Stream; // ignore: unnecessary_cast + ignored as Stream; // ignore: unnecessary_cast + ignored as Stream; // ignore: unnecessary_cast + + expect(true, true); + }); + + test('Rx.ignoreElements.reusable', () async { + final transformer = IgnoreElementsStreamTransformer(); + var hasReceivedEvent = false; + + _getStream().transform(transformer).listen((_) { + hasReceivedEvent = true; + }, + onDone: expectAsync0(() { + expect(hasReceivedEvent, isFalse); + }, count: 1)); + + _getStream().transform(transformer).listen((_) { + hasReceivedEvent = true; + }, + onDone: expectAsync0(() { + expect(hasReceivedEvent, isFalse); + }, count: 1)); + }); + + test('Rx.ignoreElements.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().ignoreElements(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.ignoreElements.pause.resume', () async { + var hasReceivedEvent = false; + + _getStream().ignoreElements().listen((_) { + hasReceivedEvent = true; + }, + onDone: expectAsync0(() { + expect(hasReceivedEvent, isFalse); + }, count: 1)) + ..pause() + ..resume(); + }); + + test('Rx.ignoreElements.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()).ignoreElements(); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + }, count: 1)); + }); + + test('Rx.ignoreElements accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.ignoreElements(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.ignoreElements.nullable', () { + nullableTest( + (s) => s.ignoreElements(), + ); + }); +} diff --git a/core/reactivex/test/transformers/interval_test.dart b/core/reactivex/test/transformers/interval_test.dart new file mode 100644 index 00000000..0fa9315b --- /dev/null +++ b/core/reactivex/test/transformers/interval_test.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [0, 1, 2, 3, 4]); + +void main() { + test('Rx.interval', () async { + const expectedOutput = [0, 1, 2, 3, 4]; + var count = 0, lastInterval = -1; + final stopwatch = Stopwatch()..start(); + + _getStream().interval(const Duration(milliseconds: 1)).listen( + expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (lastInterval != -1) { + expect(stopwatch.elapsedMilliseconds - lastInterval >= 1, true); + } + + lastInterval = stopwatch.elapsedMilliseconds; + }, count: expectedOutput.length), + onDone: stopwatch.stop); + }); + + test('Rx.interval.reusable', () async { + final transformer = + IntervalStreamTransformer(const Duration(milliseconds: 1)); + const expectedOutput = [0, 1, 2, 3, 4]; + var countA = 0, countB = 0; + final stopwatch = Stopwatch()..start(); + + _getStream().transform(transformer).listen( + expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length), + onDone: stopwatch.stop); + + _getStream().transform(transformer).listen( + expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length), + onDone: stopwatch.stop); + }); + + test('Rx.interval.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .interval(const Duration(milliseconds: 20)); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.interval.error.shouldThrowA', () async { + final streamWithError = Stream.error(Exception()) + .interval(const Duration(milliseconds: 20)); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.interval accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.interval(const Duration(milliseconds: 10)); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.interval.nullable', () { + nullableTest( + (s) => s.interval(Duration.zero), + ); + }); +} diff --git a/core/reactivex/test/transformers/join_test.dart b/core/reactivex/test/transformers/join_test.dart new file mode 100644 index 00000000..008d2b52 --- /dev/null +++ b/core/reactivex/test/transformers/join_test.dart @@ -0,0 +1,11 @@ +import 'dart:async'; + +import 'package:test/test.dart'; + +void main() { + test('Rx.join', () async { + final joined = await Stream.fromIterable(const ['h', 'i']).join('+'); + + await expectLater(joined, 'h+i'); + }); +} diff --git a/core/reactivex/test/transformers/map_not_null_test.dart b/core/reactivex/test/transformers/map_not_null_test.dart new file mode 100644 index 00000000..7f900beb --- /dev/null +++ b/core/reactivex/test/transformers/map_not_null_test.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.mapNotNull', () { + expect( + Stream.fromIterable(['1', '2', 'invalid_num', '3', 'invalid_num', '4']) + .mapNotNull(int.tryParse), + emitsInOrder([1, 2, 3, 4])); + + // 0-----1-----2-----3-----...-----8-----9-----| + // 1-----null--3-----null--...-----9-----null--| + // 1--3--5--7--9--| + final stream = Stream.periodic(const Duration(milliseconds: 10), (i) => i) + .take(10) + .transform(MapNotNullStreamTransformer((i) => i.isOdd ? null : i + 1)); + expect(stream, emitsInOrder([1, 3, 5, 7, 9, emitsDone])); + }); + + test('Rx.mapNotNull.shouldThrowA', () { + expect( + Stream.error(Exception()).mapNotNull((_) => true), + emitsError(isA()), + ); + + expect( + Rx.concat([ + Stream.fromIterable([1, 2]), + Stream.error(Exception()), + Stream.value(3), + ]).mapNotNull((i) => i.isEven ? i + 1 : null), + emitsInOrder([ + 3, + emitsError(isException), + emitsDone, + ]), + ); + }); + + test('Rx.mapNotNull.shouldThrowB', () { + expect( + Stream.fromIterable([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]).mapNotNull((i) { + if (i == 4) throw Exception(); + return i.isEven ? i + 1 : null; + }), + emitsInOrder([ + 3, + emitsError(isException), + 7, + 9, + 11, + emitsDone, + ]), + ); + }); + + test('Rx.mapNotNull.asBroadcastStream', () { + final stream = Stream.fromIterable([2, 3, 4, 5, 6]) + .mapNotNull((i) => null) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + expect(true, true); + }); + + test('Rx.mapNotNull.singleSubscription', () { + final stream = StreamController().stream.mapNotNull((i) => i); + + expect(stream.isBroadcast, isFalse); + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + }); + + test('Rx.mapNotNull.pause.resume', () async { + final subscription = + Stream.fromIterable([2, 3, 4, 5, 6]).mapNotNull((i) => i).listen(null); + + subscription + ..pause() + ..onData(expectAsync1((data) { + expect(data, 2); + subscription.cancel(); + })) + ..resume(); + }); + + test('Rx.mapNotNull.nullable', () { + nullableTest( + (s) => s.mapNotNull((i) => i), + ); + }); +} diff --git a/core/reactivex/test/transformers/map_to_test.dart b/core/reactivex/test/transformers/map_to_test.dart new file mode 100644 index 00000000..6e7febfa --- /dev/null +++ b/core/reactivex/test/transformers/map_to_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.mapTo', () async { + await expectLater(Rx.range(1, 4).mapTo(true), + emitsInOrder([true, true, true, true, emitsDone])); + }); + + test('Rx.mapTo.shouldThrow', () async { + await expectLater( + Rx.range(1, 4).concatWith([Stream.error(Error())]).mapTo(true), + emitsInOrder([ + true, + true, + true, + true, + emitsError(TypeMatcher()), + emitsDone + ])); + }); + + test('Rx.mapTo.reusable', () async { + final transformer = MapToStreamTransformer(true); + final stream = Rx.range(1, 4).asBroadcastStream(); + + stream.transform(transformer).listen(null); + stream.transform(transformer).listen(null); + + await expectLater(true, true); + }); + + test('Rx.mapTo.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.value(1).mapTo(true); + + subscription = stream.listen(expectAsync1((value) { + expect(value, isTrue); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.mapTo accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.mapTo(1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.mapTo.nullable', () { + nullableTest( + (s) => s.mapTo('String'), + ); + }); +} diff --git a/core/reactivex/test/transformers/materialize_test.dart b/core/reactivex/test/transformers/materialize_test.dart new file mode 100644 index 00000000..bcb81c4f --- /dev/null +++ b/core/reactivex/test/transformers/materialize_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.materialize.happyPath', () async { + final stream = Stream.value(1); + final notifications = >[]; + + stream.materialize().listen(notifications.add, onDone: expectAsync0(() { + expect(notifications, + [StreamNotification.data(1), StreamNotification.done()]); + })); + }); + + test('Rx.materialize.reusable', () async { + final transformer = MaterializeStreamTransformer(); + final stream = Stream.value(1).asBroadcastStream(); + final notificationsA = >[], + notificationsB = >[]; + + stream.transform(transformer).listen(notificationsA.add, + onDone: expectAsync0(() { + expect(notificationsA, + [StreamNotification.data(1), StreamNotification.done()]); + })); + + stream.transform(transformer).listen(notificationsB.add, + onDone: expectAsync0(() { + expect(notificationsB, + [StreamNotification.data(1), StreamNotification.done()]); + })); + }); + + test('materializeTransformer.happyPath', () async { + final stream = Stream.fromIterable(const [1]); + final notifications = >[]; + + stream + .transform(MaterializeStreamTransformer()) + .listen(notifications.add, onDone: expectAsync0(() { + expect(notifications, + [StreamNotification.data(1), StreamNotification.done()]); + })); + }); + + test('materializeTransformer.sadPath', () async { + final stream = Stream.error(Exception()); + final notifications = >[]; + + stream + .transform(MaterializeStreamTransformer()) + .listen(notifications.add, + onError: expectAsync2((Exception e, StackTrace s) { + // Check to ensure the stream does not come to this point + expect(true, isFalse); + }, count: 0), onDone: expectAsync0(() { + expect(notifications.length, 2); + expect(notifications[0].isError, isTrue); + expect(notifications[1].isDone, isTrue); + })); + }); + + test('materializeTransformer.onPause.onResume', () async { + final stream = Stream.fromIterable(const [1]); + final notifications = >[]; + + stream + .transform(MaterializeStreamTransformer()) + .listen(notifications.add, onDone: expectAsync0(() { + expect(notifications, >[ + StreamNotification.data(1), + StreamNotification.done() + ]); + })) + ..pause() + ..resume(); + }); + + test('Rx.materialize accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.materialize(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.materialize.nullable', () { + nullableTest>( + (s) => s.materialize(), + ); + }); +} diff --git a/core/reactivex/test/transformers/max_test.dart b/core/reactivex/test/transformers/max_test.dart new file mode 100644 index 00000000..cacd3950 --- /dev/null +++ b/core/reactivex/test/transformers/max_test.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.max', () async { + await expectLater(_getStream().max(), completion(9)); + + expect( + await Stream.fromIterable([1, 2, 3, 3.5]).max(), + 3.5, + ); + }); + + test('Rx.max.empty.shouldThrow', () { + expect( + () => Stream.empty().max(), + throwsStateError, + ); + }); + + test('Rx.max.error.shouldThrow', () { + expect( + () => Stream.value(1).concatWith( + [Stream.error(Exception('This is exception'))], + ).max(), + throwsException, + ); + }); + + test('Rx.max.with.comparator', () async { + await expectLater( + Stream.fromIterable(['one', 'two', 'three']) + .max((a, b) => a.length - b.length), + completion('three'), + ); + }); + + test('Rx.max.errorComparator.shouldThrow', () { + expect( + () => _getStream().max((a, b) => throw Exception()), + throwsException, + ); + }); + + test('Rx.max.without.comparator.Comparable', () async { + const expected = _Class2(3); + expect( + await Stream.fromIterable(const [ + _Class2(0), + expected, + _Class2(2), + _Class2(-1), + _Class2(2), + ]).max(), + expected, + ); + }); + + test('Rx.max.without.comparator.not.Comparable', () async { + expect( + () => Stream.fromIterable(const [ + _Class1(0), + _Class1(3), + _Class1(2), + _Class1(3), + _Class1(2), + ]).max(), + throwsStateError, + ); + }); +} + +class ErrorComparator implements Comparable { + @override + int compareTo(ErrorComparator other) { + throw Exception(); + } +} + +Stream _getStream() => + Stream.fromIterable(const [2, 3, 3, 5, 2, 9, 1, 2, 0]); + +class _Class1 { + final int value; + + const _Class1(this.value); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _Class1 && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + String toString() => '_Class{value: $value}'; +} + +class _Class2 implements Comparable<_Class2> { + final int value; + + const _Class2(this.value); + + @override + String toString() => '_Class2{value: $value}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _Class2 && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + int compareTo(_Class2 other) => value.compareTo(other.value); +} diff --git a/core/reactivex/test/transformers/merge_with_test.dart b/core/reactivex/test/transformers/merge_with_test.dart new file mode 100644 index 00000000..ed6efdb7 --- /dev/null +++ b/core/reactivex/test/transformers/merge_with_test.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.mergeWith', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + const expected = [2, 1]; + var count = 0; + + delayedStream.mergeWith([immediateStream]).listen(expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.mergeWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.mergeWith([Stream.empty()]); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.mergeWith on single stream should stay single ', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + final expected = [2, 1, emitsDone]; + + final concatenatedStream = delayedStream.mergeWith([immediateStream]); + + expect(concatenatedStream.isBroadcast, isFalse); + expect(concatenatedStream, emitsInOrder(expected)); + }); + + test('Rx.mergeWith on broadcast stream should stay broadcast ', () async { + final delayedStream = + Rx.timer(1, Duration(milliseconds: 10)).asBroadcastStream(); + final immediateStream = Stream.value(2); + final expected = [2, 1, emitsDone]; + + final concatenatedStream = delayedStream.mergeWith([immediateStream]); + + expect(concatenatedStream.isBroadcast, isTrue); + expect(concatenatedStream, emitsInOrder(expected)); + }); + + test('Rx.mergeWith multiple subscriptions on single ', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + + final concatenatedStream = delayedStream.mergeWith([immediateStream]); + + expect(() => concatenatedStream.listen(null), returnsNormally); + expect(() => concatenatedStream.listen(null), + throwsA(TypeMatcher())); + }); +} diff --git a/core/reactivex/test/transformers/min_test.dart b/core/reactivex/test/transformers/min_test.dart new file mode 100644 index 00000000..6c2c7728 --- /dev/null +++ b/core/reactivex/test/transformers/min_test.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.min', () async { + await expectLater(_getStream().min(), completion(0)); + + expect( + await Stream.fromIterable([1, 2, 3, 3.5]).min(), + 1, + ); + }); + + test('Rx.min.empty.shouldThrow', () { + expect( + () => Stream.empty().min(), + throwsStateError, + ); + }); + + test('Rx.min.error.shouldThrow', () { + expect( + () => Stream.value(1).concatWith( + [Stream.error(Exception('This is exception'))], + ).min(), + throwsException, + ); + }); + + test('Rx.min.errorComparator.shouldThrow', () { + expect( + () => _getStream().min((a, b) => throw Exception()), + throwsException, + ); + }); + + test('Rx.min.with.comparator', () async { + await expectLater( + Stream.fromIterable(['one', 'two', 'three']) + .min((a, b) => a.length - b.length), + completion('one'), + ); + }); + + test('Rx.min.without.comparator.Comparable', () async { + const expected = _Class2(-1); + expect( + await Stream.fromIterable(const [ + _Class2(0), + _Class2(3), + _Class2(2), + expected, + _Class2(2), + ]).min(), + expected, + ); + }); + + test('Rx.min.without.comparator.not.Comparable', () async { + expect( + () => Stream.fromIterable(const [ + _Class1(0), + _Class1(3), + _Class1(2), + _Class1(3), + _Class1(2), + ]).min(), + throwsStateError, + ); + }); +} + +Stream _getStream() => + Stream.fromIterable(const [2, 3, 3, 5, 2, 9, 1, 2, 0]); + +class _Class1 { + final int value; + + const _Class1(this.value); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _Class1 && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + String toString() => '_Class{value: $value}'; +} + +class _Class2 implements Comparable<_Class2> { + final int value; + + const _Class2(this.value); + + @override + String toString() => '_Class2{value: $value}'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _Class2 && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + int compareTo(_Class2 other) => value.compareTo(other.value); +} diff --git a/core/reactivex/test/transformers/on_error_resume_test.dart b/core/reactivex/test/transformers/on_error_resume_test.dart new file mode 100644 index 00000000..ce9253c7 --- /dev/null +++ b/core/reactivex/test/transformers/on_error_resume_test.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [0, 1, 2, 3]); + +const List expected = [0, 1, 2, 3]; + +void main() { + test('Rx.onErrorResumeNext', () async { + var count = 0; + + Stream.error(Exception()) + .onErrorResumeNext(_getStream()) + .listen(expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.onErrorResume', () async { + var count = 0; + + Stream.error(Exception()) + .onErrorResume((e, st) => _getStream()) + .listen(expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.onErrorResume.correctError', () async { + final exception = Exception(); + + expect( + Stream.error(exception).onErrorResume((e, st) => Stream.value(e)), + emits(exception), + ); + }); + + test('Rx.onErrorResumeNext.asBroadcastStream', () async { + final stream = Stream.error(Exception()) + .onErrorResumeNext(_getStream()) + .asBroadcastStream(); + var countA = 0, countB = 0; + + await expectLater(stream.isBroadcast, isTrue); + + stream.listen(expectAsync1((result) { + expect(result, expected[countA++]); + }, count: expected.length)); + stream.listen(expectAsync1((result) { + expect(result, expected[countB++]); + }, count: expected.length)); + }); + + test('Rx.onErrorResumeNext.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()) + .onErrorResumeNext(Stream.error(Exception())); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.onErrorResumeNext.pause.resume', () async { + final transformer = + OnErrorResumeStreamTransformer((_, __) => _getStream()); + final exp = const [50] + expected; + late StreamSubscription subscription; + var count = 0; + + subscription = Rx.merge([ + Stream.value(50), + Stream.error(Exception()), + ]).transform(transformer).listen(expectAsync1((result) { + expect(result, exp[count++]); + + if (count == exp.length) { + subscription.cancel(); + } + }, count: exp.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.onErrorResumeNext.close', () async { + var count = 0; + + Stream.error(Exception()).onErrorResumeNext(_getStream()).listen( + expectAsync1((result) { + expect(result, expected[count++]); + }, count: expected.length), + onDone: expectAsync0(() { + // The code should reach this point + expect(true, true); + }, count: 1)); + }); + + test('Rx.onErrorResumeNext.noErrors.close', () async { + expect( + Stream.empty().onErrorResumeNext(_getStream()), + emitsDone, + ); + }); + + test('OnErrorResumeStreamTransformer.reusable', () async { + final transformer = OnErrorResumeStreamTransformer( + (_, __) => _getStream().asBroadcastStream()); + var countA = 0, countB = 0; + + Stream.error(Exception()) + .transform(transformer) + .listen(expectAsync1((result) { + expect(result, expected[countA++]); + }, count: expected.length)); + + Stream.error(Exception()) + .transform(transformer) + .listen(expectAsync1((result) { + expect(result, expected[countB++]); + }, count: expected.length)); + }); + + test('Rx.onErrorResume accidental broadcast', () async { + final controller = StreamController(); + + final stream = + controller.stream.onErrorResume((_, __) => Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.onErrorResumeNext accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.onErrorResumeNext(Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.onErrorResume still adds data when Stream emits an error: issue/616', + () { + { + final stream = Rx.concat([ + Stream.value(1), + Stream.error(Exception()), + Stream.fromIterable([2, 3]), + Stream.error(Exception()), + Stream.value(4), + ]).onErrorResume((e, s) => Stream.value(-1)); + expect( + stream, + emitsInOrder([1, -1, 2, 3, -1, 4, emitsDone]), + ); + } + + { + final stream = Rx.concat([ + Stream.value(1), + Stream.error(Exception()), + Stream.fromIterable([2, 3]), + Stream.error(Exception()), + Stream.value(4), + ]).onErrorResumeNext(Stream.value(-1)); + expect( + stream, + emitsInOrder([1, -1, 2, 3, -1, 4, emitsDone]), + ); + } + }); + + test('Rx.onErrorResumeNext with many errors', () { + final stream = Rx.concat([ + Stream.value(1), + Stream.error(Exception()), + Stream.value(2), + Stream.error(StateError('')), + Stream.value(3), + ]).onErrorResume((e, s) { + if (e is Exception) { + return Rx.timer(-1, const Duration(milliseconds: 100)); + } + if (e is StateError) { + return Rx.timer(-2, const Duration(milliseconds: 200)); + } + throw e; + }); + expect( + stream, + emitsInOrder([1, 2, 3, -1, -2, emitsDone]), + ); + }); + + test('Rx.onErrorResumeNext.nullable', () { + nullableTest( + (s) => s.onErrorResumeNext(Stream.empty()), + ); + }); +} diff --git a/core/reactivex/test/transformers/on_error_return_test.dart b/core/reactivex/test/transformers/on_error_return_test.dart new file mode 100644 index 00000000..d4d06449 --- /dev/null +++ b/core/reactivex/test/transformers/on_error_return_test.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + const num expected = 0; + + test('Rx.onErrorReturn', () async { + Stream.error(Exception()) + .onErrorReturn(0) + .listen(expectAsync1((num result) { + expect(result, expected); + })); + }); + + test('Rx.onErrorReturn.asBroadcastStream', () async { + final stream = + Stream.error(Exception()).onErrorReturn(0).asBroadcastStream(); + + await expectLater(stream.isBroadcast, isTrue); + + stream.listen(expectAsync1((num result) { + expect(result, expected); + })); + + stream.listen(expectAsync1((num result) { + expect(result, expected); + })); + }); + + test('Rx.onErrorReturn.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Stream.error(Exception()) + .onErrorReturn(0) + .listen(expectAsync1((num result) { + expect(result, expected); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.onErrorReturn accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.onErrorReturn(1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.onErrorReturn still adds data when Stream emits an error: issue/616', + () { + final stream = Rx.concat([ + Stream.value(1), + Stream.error(Exception()), + Stream.fromIterable([2, 3]), + Stream.error(Exception()), + Stream.value(4), + ]).onErrorReturn(-1); + expect( + stream, + emitsInOrder([1, -1, 2, 3, -1, 4, emitsDone]), + ); + }); + + test('Rx.onErrorReturn.nullable', () { + nullableTest( + (s) => s.onErrorReturn('String'), + ); + }); +} diff --git a/core/reactivex/test/transformers/on_error_return_with_test.dart b/core/reactivex/test/transformers/on_error_return_with_test.dart new file mode 100644 index 00000000..7ffc7260 --- /dev/null +++ b/core/reactivex/test/transformers/on_error_return_with_test.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + const num expected = 0; + + test('Rx.onErrorReturnWith', () async { + Stream.error(Exception()) + .onErrorReturnWith((e, _) => e is StateError ? 1 : 0) + .listen(expectAsync1((num result) { + expect(result, expected); + })); + }); + + test('Rx.onErrorReturnWith.asBroadcastStream', () async { + final stream = Stream.error(Exception()) + .onErrorReturnWith((_, __) => 0) + .asBroadcastStream(); + + await expectLater(stream.isBroadcast, isTrue); + + stream.listen(expectAsync1((num result) { + expect(result, expected); + })); + + stream.listen(expectAsync1((num result) { + expect(result, expected); + })); + }); + + test('Rx.onErrorReturnWith.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Stream.error(Exception()) + .onErrorReturnWith((_, __) => 0) + .listen(expectAsync1((num result) { + expect(result, expected); + + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.onErrorReturnWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.onErrorReturnWith((_, __) => 1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test( + 'Rx.onErrorReturnWith still adds data when Stream emits an error: issue/616', + () { + final stream = Rx.concat([ + Stream.value(1), + Stream.error(Exception()), + Stream.fromIterable([2, 3]), + Stream.error(Exception()), + Stream.value(4), + ]).onErrorReturnWith((e, s) => -1); + expect( + stream, + emitsInOrder([1, -1, 2, 3, -1, 4, emitsDone]), + ); + }); + + test('Rx.onErrorReturnWith.nullable', () { + nullableTest( + (s) => s.onErrorReturnWith((e, s) => 'String'), + ); + }); +} diff --git a/core/reactivex/test/transformers/scan_test.dart b/core/reactivex/test/transformers/scan_test.dart new file mode 100644 index 00000000..3913017b --- /dev/null +++ b/core/reactivex/test/transformers/scan_test.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.scan', () async { + const expectedOutput = [1, 3, 6, 10]; + var count = 0; + + Stream.fromIterable(const [1, 2, 3, 4]) + .scan((acc, value, index) => acc + value, 0) + .listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.scan.nullable', () { + nullableTest( + (s) => s.scan((acc, value, index) => acc, null), + ); + + expect( + Stream.fromIterable(const [1, 2, 3, 4]) + .scan((acc, value, index) => (acc ?? 0) + value, null) + .cast(), + emitsInOrder([1, 3, 6, 10]), + ); + }); + + test('Rx.scan.reusable', () async { + final transformer = + ScanStreamTransformer((acc, value, index) => acc + value, 0); + const expectedOutput = [1, 3, 6, 10]; + var countA = 0, countB = 0; + + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .listen(expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length)); + + Stream.fromIterable(const [1, 2, 3, 4]) + .transform(transformer) + .listen(expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.scan.asBroadcastStream', () async { + final stream = Stream.fromIterable(const [1, 2, 3, 4]) + .asBroadcastStream() + .scan((acc, value, index) => acc + value, 0); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.scan.error.shouldThrow', () async { + final streamWithError = Stream.fromIterable(const [1, 2, 3, 4]) + .scan((acc, value, index) => throw StateError('oh noes!'), 0); + + streamWithError.listen(null, + onError: expectAsync2((StateError e, StackTrace s) { + expect(e, isStateError); + }, count: 4)); + }); + + test('Rx.scan accidental broadcast', () async { + final controller = StreamController(); + + final stream = + controller.stream.scan((acc, value, index) => acc + value, 0); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); +} diff --git a/core/reactivex/test/transformers/skip_last_test.dart b/core/reactivex/test/transformers/skip_last_test.dart new file mode 100644 index 00000000..6c5349c3 --- /dev/null +++ b/core/reactivex/test/transformers/skip_last_test.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.skipLast', () async { + final stream = Stream.fromIterable([1, 2, 3, 4, 5]).skipLast(3); + await expectLater( + stream, + emitsInOrder([1, 2, emitsDone]), + ); + }); + + test('Rx.skipLast.zero', () async { + var count = 0; + final values = [1, 2, 3, 4, 5]; + final stream = + Stream.fromIterable(values).doOnData((_) => count++).skipLast(0); + await expectLater( + stream, + emitsInOrder([1, 2, 3, 4, 5, emitsDone]), + ); + expect(count, equals(values.length)); + }); + + test('Rx.skipLast.skipMoreThanLength', () async { + final stream = Stream.fromIterable([1, 2, 3, 4, 5]).skipLast(100); + + await expectLater( + stream, + emits(emitsDone), + ); + }); + + test('Rx.skipLast.emitsError', () async { + final stream = Stream.error(Exception()).skipLast(3); + await expectLater(stream, emitsError(isException)); + }); + + test('Rx.skipLast.countCantBeNegative', () async { + Stream stream() => Stream.fromIterable([1, 2, 3, 4, 5]).skipLast(-1); + expect(stream, throwsA(isArgumentError)); + }); + + test('Rx.skipLast.reusable', () async { + final transformer = SkipLastStreamTransformer(1); + Stream stream() => Stream.fromIterable([1, 2, 3, 4, 5]).skipLast(2); + var valueA = 1, valueB = 1; + + stream().transform(transformer).listen(expectAsync1( + (result) { + expect(result, valueA++); + }, + count: 2, + )); + + stream().transform(transformer).listen(expectAsync1( + (result) { + expect(result, valueB++); + }, + count: 2, + )); + }); + + test('Rx.skipLast.asBroadcastStream', () async { + final stream = + Stream.fromIterable([1, 2, 3, 4, 5]).skipLast(3).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.skipLast.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3, 4, 5]) + .skipLast(3) + .listen(expectAsync1((data) { + expect(data, 1); + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.skipLast.singleSubscription', () async { + final controller = StreamController(); + + final stream = controller.stream.skipLast(3); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.skipLast.nullable', () { + nullableTest( + (s) => s.skipLast(1), + ); + }); +} diff --git a/core/reactivex/test/transformers/skip_until_test.dart b/core/reactivex/test/transformers/skip_until_test.dart new file mode 100644 index 00000000..fa3a97cc --- /dev/null +++ b/core/reactivex/test/transformers/skip_until_test.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add(2)); + Timer(const Duration(milliseconds: 300), () => controller.add(3)); + Timer(const Duration(milliseconds: 400), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +Stream _getOtherStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 250), () { + controller.add(1); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.skipUntil', () async { + const expectedOutput = [3, 4]; + var count = 0; + + _getStream().skipUntil(_getOtherStream()).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.skipUntil.shouldClose', () async { + _getStream() + .skipUntil(Stream.empty()) + .listen(null, onDone: expectAsync0(() => expect(true, isTrue))); + }); + + test('Rx.skipUntil.reusable', () async { + final transformer = SkipUntilStreamTransformer( + _getOtherStream().asBroadcastStream()); + const expectedOutput = [3, 4]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.skipUntil.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .skipUntil(_getOtherStream().asBroadcastStream()); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.skipUntil.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).skipUntil(_getOtherStream()); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.skipUntil.error.shouldThrowB', () async { + final streamWithError = + Stream.value(1).skipUntil(Stream.error(Exception('Oh noes!'))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.skipUntil.pause.resume', () async { + late StreamSubscription subscription; + const expectedOutput = [3, 4]; + var count = 0; + + subscription = + _getStream().skipUntil(_getOtherStream()).listen(expectAsync1((result) { + expect(result, expectedOutput[count++]); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.skipUntil accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.skipUntil(Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.skipUntil.nullable', () { + nullableTest( + (s) => s.skipUntil(Stream.empty()), + ); + }); +} diff --git a/core/reactivex/test/transformers/start_with_error_test.dart b/core/reactivex/test/transformers/start_with_error_test.dart new file mode 100644 index 00000000..7a61c097 --- /dev/null +++ b/core/reactivex/test/transformers/start_with_error_test.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/src/transformers/start_with_error.dart'; +import 'package:test/test.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.startWithError', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + const expectedOutput = [1, 2, 3, 4]; + + await expectLater(_getStream().transform(transformer), + emitsInOrder([emitsError(isException), ...expectedOutput])); + }); + + test('Rx.startWithError.reusable', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + const expectedOutput = [1, 2, 3, 4]; + + await expectLater(_getStream().transform(transformer), + emitsInOrder([emitsError(isException), ...expectedOutput])); + await expectLater(_getStream().transform(transformer), + emitsInOrder([emitsError(isException), ...expectedOutput])); + }); + + test('Rx.startWithError.asBroadcastStream', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + final stream = _getStream().asBroadcastStream().transform(transformer); + const expectedOutput = [1, 2, 3, 4]; + + // listen twice on same stream + await expectLater( + stream, + emitsInOrder( + [emitsError(isException), ...expectedOutput, emitsDone])); + await expectLater(stream, emitsDone); + }); + + test('Rx.startWithError.error.shouldThrow', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + final streamWithError = + Stream.error(Exception()).transform(transformer); + + await expectLater(streamWithError, emitsError(isException)); + }); + + test('Rx.startWithError.pause.resume', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + const expectedOutput = [1, 2, 3, 4]; + var count = 0; + + late StreamSubscription subscription; + subscription = _getStream().transform(transformer).listen( + expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length), + onError: (Object e, StackTrace s) => expect(e, isException)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.startWithError accidental broadcast', () async { + final transformer = StartWithErrorStreamTransformer( + Exception(), StackTrace.fromString('oh noes!')); + final controller = StreamController(); + + final stream = controller.stream.transform(transformer); + + stream.listen(null, onError: (Object e, StackTrace s) {}); + expect(() => stream.listen(null, onError: (Object e, StackTrace s) {}), + throwsStateError); + + controller.add(1); + }); +} diff --git a/core/reactivex/test/transformers/start_with_many_test.dart b/core/reactivex/test/transformers/start_with_many_test.dart new file mode 100644 index 00000000..7159e3bb --- /dev/null +++ b/core/reactivex/test/transformers/start_with_many_test.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.startWithMany', () async { + const expectedOutput = [5, 6, 1, 2, 3, 4]; + var count = 0; + + _getStream().startWithMany(const [5, 6]).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.startWithMany.reusable', () async { + final transformer = StartWithManyStreamTransformer(const [5, 6]); + const expectedOutput = [5, 6, 1, 2, 3, 4]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.startWithMany.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().startWithMany(const [5, 6]); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.startWithMany.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).startWithMany(const [5, 6]); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.startWithMany.pause.resume', () async { + const expectedOutput = [5, 6, 1, 2, 3, 4]; + var count = 0; + + late StreamSubscription subscription; + subscription = + _getStream().startWithMany(const [5, 6]).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + test('Rx.startWithMany accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.startWithMany(const [1, 2, 3]); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.startWithMany.nullable', () { + nullableTest( + (s) => s.startWithMany([]), + ); + }); +} diff --git a/core/reactivex/test/transformers/start_with_test.dart b/core/reactivex/test/transformers/start_with_test.dart new file mode 100644 index 00000000..235b6ff9 --- /dev/null +++ b/core/reactivex/test/transformers/start_with_test.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable(const [1, 2, 3, 4]); + +void main() { + test('Rx.startWith', () async { + const expectedOutput = [5, 1, 2, 3, 4]; + var count = 0; + + _getStream().startWith(5).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.startWith.reusable', () async { + final transformer = StartWithStreamTransformer(5); + const expectedOutput = [5, 1, 2, 3, 4]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.startWith.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().startWith(5); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.startWith.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()).startWith(5); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.startWith.pause.resume', () async { + const expectedOutput = [5, 1, 2, 3, 4]; + var count = 0; + + late StreamSubscription subscription; + subscription = _getStream().startWith(5).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.startWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.startWith(1); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test( + 'Rx.startWith broadcast stream should not startWith on multiple subscribers', + () async { + final controller = StreamController.broadcast(); + + final stream = controller.stream.startWith(1); + + await controller.close(); + + stream.listen(null); + + await Future.delayed(const Duration(milliseconds: 10)); + + await expectLater(stream, emits(emitsDone)); + }, skip: true); + + test('Rx.startWith.nullable', () { + nullableTest( + (s) => s.startWith('String'), + ); + }); +} diff --git a/core/reactivex/test/transformers/switch_if_empty_test.dart b/core/reactivex/test/transformers/switch_if_empty_test.dart new file mode 100644 index 00000000..c1f15beb --- /dev/null +++ b/core/reactivex/test/transformers/switch_if_empty_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.switchIfEmpty.whenEmpty', () async { + expect( + Stream.empty().switchIfEmpty(Stream.value(1)), + emitsInOrder([1, emitsDone]), + ); + }); + + test('Rx.initial.completes', () async { + expect( + Stream.value(99).switchIfEmpty(Stream.value(1)), + emitsInOrder([99, emitsDone]), + ); + }); + + test('Rx.switchIfEmpty.reusable', () async { + final transformer = SwitchIfEmptyStreamTransformer( + Stream.value(true).asBroadcastStream()); + + Stream.empty().transform(transformer).listen(expectAsync1((result) { + expect(result, true); + }, count: 1)); + + Stream.empty().transform(transformer).listen(expectAsync1((result) { + expect(result, true); + }, count: 1)); + }); + + test('Rx.switchIfEmpty.whenNotEmpty', () async { + Stream.value(false) + .switchIfEmpty(Stream.value(true)) + .listen(expectAsync1((result) { + expect(result, false); + }, count: 1)); + }); + + test('Rx.switchIfEmpty.asBroadcastStream', () async { + final stream = + Stream.empty().switchIfEmpty(Stream.value(1)).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.switchIfEmpty.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).switchIfEmpty(Stream.value(1)); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.switchIfEmpty.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.empty().switchIfEmpty(Stream.value(1)); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.switchIfEmpty accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.switchIfEmpty(Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.switchIfEmpty.nullable', () { + nullableTest( + (s) => s.switchIfEmpty(Stream.value('String')), + ); + }); +} diff --git a/core/reactivex/test/transformers/switch_map_test.dart b/core/reactivex/test/transformers/switch_map_test.dart new file mode 100644 index 00000000..cb863967 --- /dev/null +++ b/core/reactivex/test/transformers/switch_map_test.dart @@ -0,0 +1,359 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 10), () => controller.add(1)); + Timer(const Duration(milliseconds: 20), () => controller.add(2)); + Timer(const Duration(milliseconds: 30), () => controller.add(3)); + Timer(const Duration(milliseconds: 40), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +Stream _getOtherStream(int value) { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 15), () => controller.add(value + 1)); + Timer(const Duration(milliseconds: 25), () => controller.add(value + 2)); + Timer(const Duration(milliseconds: 35), () => controller.add(value + 3)); + Timer(const Duration(milliseconds: 45), () { + controller.add(value + 4); + controller.close(); + }); + + return controller.stream; +} + +Stream range() => + Stream.fromIterable(const [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + +void main() { + test('Rx.switchMap', () async { + const expectedOutput = [5, 6, 7, 8]; + var count = 0; + + _getStream().switchMap(_getOtherStream).listen(expectAsync1((result) { + expect(result, expectedOutput[count++]); + }, count: expectedOutput.length)); + }); + + test('Rx.switchMap.reusable', () async { + final transformer = SwitchMapStreamTransformer(_getOtherStream); + const expectedOutput = [5, 6, 7, 8]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, expectedOutput[countA++]); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(result, expectedOutput[countB++]); + }, count: expectedOutput.length)); + }); + + test('Rx.switchMap.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().switchMap(_getOtherStream); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.switchMap.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).switchMap(_getOtherStream); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.switchMap.error.shouldThrowB', () async { + final streamWithError = Stream.value(1).switchMap( + (_) => Stream.error(Exception('Catch me if you can!'))); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.switchMap.error.shouldThrowC', () async { + final streamWithError = Stream.value(1).switchMap((_) { + throw Exception('oh noes!'); + }); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.switchMap.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.value(0).switchMap((_) => Stream.value(1)); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.switchMap stream close after switch', () async { + final controller = StreamController(); + final list = controller.stream + .switchMap((it) => Stream.fromIterable([it, it])) + .toList(); + + controller.add(1); + await Future.delayed(Duration(microseconds: 1)); + controller.add(2); + + await controller.close(); + expect(await list, [1, 1, 2, 2]); + }); + + test('Rx.switchMap accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.switchMap((_) => Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.switchMap closes after the last inner Stream closed - issue/511', + () async { + final outer = StreamController(); + final inner = BehaviorSubject.seeded(false); + final stream = outer.stream.switchMap((_) => inner.stream); + + expect(stream, emitsThrough(emitsDone)); + + outer.add(true); + await Future.delayed(Duration.zero); + await inner.close(); + await outer.close(); + }); + + test('Rx.switchMap every subscription triggers a listen on the root Stream', + () async { + var count = 0; + final controller = StreamController.broadcast(); + final root = + OnSubscriptionTriggerableStream(controller.stream, () => count++); + final stream = root.switchMap((event) => Stream.value(event)); + + stream.listen((event) {}); + stream.listen((event) {}); + + expect(count, 2); + + await controller.close(); + }); + + test('Rx.switchMap.nullable', () { + nullableTest( + (s) => s.switchMap((v) => Stream.value(v)), + ); + }); + + test( + 'Rx.switchMap pauses subscription when cancelling inner subscription, then resume', + () async { + var isController1Cancelled = false; + final cancelCompleter1 = Completer.sync(); + final controller1 = StreamController() + ..add(0) + ..add(1) + ..onCancel = () async { + await Future.delayed(const Duration(milliseconds: 10)); + await cancelCompleter1.future; + isController1Cancelled = true; + }; + + final controller2 = StreamController() + ..add(2) + ..add(3) + ..onListen = () { + expect( + isController1Cancelled, + true, + reason: + 'controller1 should be cancelled before controller2 is listened to', + ); + }; + + final controller = StreamController>() + ..add(controller1); + final stream = controller.stream.switchMap((c) => c.stream); + + var expected = 0; + stream.listen( + expectAsync1( + (v) async { + expect(v, expected++); + + if (v == 1) { + // switch to controller2.stream + controller.add(controller2); + + await Future.delayed(const Duration(milliseconds: 10)); + cancelCompleter1.complete(null); + } + }, + count: 4, + ), + ); + }, + ); + + test('Rx.switchMap forwards errors from the cancel()', () { + var isController1Cancelled = false; + + final controller1 = StreamController() + ..add(0) + ..add(1) + ..onCancel = () async { + await Future.delayed(const Duration(milliseconds: 10)); + isController1Cancelled = true; + throw Exception('cancel error'); + }; + + final controller2 = StreamController() + ..add(2) + ..add(3) + ..onListen = () { + expect( + isController1Cancelled, + true, + reason: + 'controller1 should be cancelled before controller2 is listened to', + ); + }; + + final controller = StreamController>() + ..add(controller1); + final stream = controller.stream.switchMap((c) => c.stream); + + var expected = 0; + stream.listen( + expectAsync1( + (v) async { + expect(v, expected++); + + if (v == 1) { + // switch to controller2.stream + controller.add(controller2); + } + }, + count: 4, + ), + onError: expectAsync1( + (Object error) => expect(error, isException), + count: 1, + ), + ); + }); + + test( + 'Rx.switchMap pauses the next inner StreamSubscription when pausing while cancelling the previous inner Stream', + () { + var isController1Cancelled = false; + final cancelCompleter1 = Completer.sync(); + final controller1 = StreamController() + ..add(0) + ..add(1) + ..onCancel = () async { + await Future.delayed(const Duration(milliseconds: 10)); + await cancelCompleter1.future; + isController1Cancelled = true; + }; + + final controller2 = StreamController() + ..add(2) + ..add(3) + ..onListen = () { + expect( + isController1Cancelled, + true, + reason: + 'controller1 should be cancelled before controller2 is listened to', + ); + }; + + final controller = StreamController>() + ..add(controller1); + final stream = controller.stream.switchMap((c) => c.stream); + + var expected = 0; + late StreamSubscription subscription; + subscription = stream.listen( + expectAsync1( + (v) async { + expect(v, expected++); + + if (v == 1) { + // switch to controller2.stream + controller.add(controller2); + + await Future.delayed(const Duration(milliseconds: 10)); + + // pauses the subscription while cancelling the controller1 + subscription.pause(); + + // let the cancellation of controller1 complete + cancelCompleter1.complete(null); + + // make sure the controller2.stream is added to the controller + await pumpEventQueue(); + + // controller2.stream should be paused + expect(controller2.isPaused, true); + + // resume the subscription to continue the rest of the stream + subscription.resume(); + } + }, + count: 4, + ), + ); + }, + ); +} + +class OnSubscriptionTriggerableStream extends Stream { + final Stream inner; + final void Function() onSubscribe; + + OnSubscriptionTriggerableStream(this.inner, this.onSubscribe); + + @override + bool get isBroadcast => inner.isBroadcast; + + @override + StreamSubscription listen(void Function(T event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + onSubscribe(); + return inner.listen(onData, + onError: onError, onDone: onDone, cancelOnError: cancelOnError); + } +} diff --git a/core/reactivex/test/transformers/take_last_test.dart b/core/reactivex/test/transformers/take_last_test.dart new file mode 100644 index 00000000..64474c6c --- /dev/null +++ b/core/reactivex/test/transformers/take_last_test.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.takeLast', () async { + final stream = Stream.fromIterable([1, 2, 3, 4, 5]).takeLast(3); + await expectLater( + stream, + emitsInOrder([3, 4, 5, emitsDone]), + ); + }); + + test('Rx.takeLast.zero', () async { + var count = 0; + final values = [1, 2, 3, 4, 5]; + final stream = + Stream.fromIterable(values).doOnData((_) => count++).takeLast(0); + await expectLater( + stream, + emitsInOrder([emitsDone]), + ); + expect(count, equals(values.length)); + }); + + test('Rx.takeLast.emitsError', () async { + final stream = Stream.error(Exception()).takeLast(3); + await expectLater(stream, emitsError(isException)); + }); + + test('Rx.takeLast.countCantBeNegative', () async { + Stream stream() => Stream.fromIterable([1, 2, 3, 4, 5]).takeLast(-1); + expect(stream, throwsA(isArgumentError)); + }); + + test('Rx.takeLast.reusable', () async { + final transformer = TakeLastStreamTransformer(3); + Stream stream() => Stream.fromIterable([1, 2, 3, 4, 5]).takeLast(3); + var valueA = 3, valueB = 3; + + stream().transform(transformer).listen(expectAsync1((result) { + expect(result, valueA++); + }, count: 3)); + + stream().transform(transformer).listen(expectAsync1((result) { + expect(result, valueB++); + }, count: 3)); + }); + + test('Rx.takeLast.asBroadcastStream', () async { + final stream = + Stream.fromIterable([1, 2, 3, 4, 5]).takeLast(3).asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('Rx.takeLast.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Stream.fromIterable([1, 2, 3, 4, 5]) + .takeLast(3) + .listen(expectAsync1((data) { + expect(data, 3); + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.takeLast.singleSubscription', () async { + final controller = StreamController(); + + final stream = controller.stream.takeLast(3); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.takeLast.cancel', () { + final subscription = + Stream.fromIterable([1, 2, 3, 4, 5]).takeLast(3).listen(null); + subscription.onData( + expectAsync1( + (event) { + subscription.cancel(); + expect(event, 3); + }, + count: 1, + ), + ); + }, timeout: const Timeout(Duration(seconds: 1))); + + test('Rx.takeLast.nullable', () { + nullableTest( + (s) => s.takeLast(1), + ); + }); +} diff --git a/core/reactivex/test/transformers/take_until_test.dart b/core/reactivex/test/transformers/take_until_test.dart new file mode 100644 index 00000000..23efaf28 --- /dev/null +++ b/core/reactivex/test/transformers/take_until_test.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add(2)); + Timer(const Duration(milliseconds: 300), () => controller.add(3)); + Timer(const Duration(milliseconds: 400), () { + controller.add(4); + controller.close(); + }); + + return controller.stream; +} + +Stream _getOtherStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 250), () { + controller.add(1); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.takeUntil', () async { + const expectedOutput = [1, 2]; + var count = 0; + + _getStream().takeUntil(_getOtherStream()).listen(expectAsync1((result) { + expect(expectedOutput[count++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.takeUntil.shouldClose', () async { + _getStream() + .takeUntil(Stream.empty()) + .listen(null, onDone: expectAsync0(() => expect(true, isTrue))); + }); + + test('Rx.takeUntil.reusable', () async { + final transformer = TakeUntilStreamTransformer( + _getOtherStream().asBroadcastStream()); + const expectedOutput = [1, 2]; + var countA = 0, countB = 0; + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countA++], result); + }, count: expectedOutput.length)); + + _getStream().transform(transformer).listen(expectAsync1((result) { + expect(expectedOutput[countB++], result); + }, count: expectedOutput.length)); + }); + + test('Rx.takeUntil.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .takeUntil(_getOtherStream().asBroadcastStream()); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.takeUntil.error.shouldThrowA', () async { + final streamWithError = + Stream.error(Exception()).takeUntil(_getOtherStream()); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.takeUntil.pause.resume', () async { + late StreamSubscription subscription; + const expectedOutput = [1, 2]; + var count = 0; + + subscription = + _getStream().takeUntil(_getOtherStream()).listen(expectAsync1((result) { + expect(result, expectedOutput[count++]); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.takeUntil accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.takeUntil(Stream.empty()); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.takeUntil.nullable', () { + nullableTest( + (s) => s.takeUntil(Stream.empty()), + ); + }); +} diff --git a/core/reactivex/test/transformers/take_while_inclusive_test.dart b/core/reactivex/test/transformers/take_while_inclusive_test.dart new file mode 100644 index 00000000..7b2f774e --- /dev/null +++ b/core/reactivex/test/transformers/take_while_inclusive_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.takeWhileInclusive', () async { + final stream = Stream.fromIterable([2, 3, 4, 5, 6, 1, 2, 3]) + .takeWhileInclusive((i) => i < 4); + await expectLater( + stream, + emitsInOrder([2, 3, 4, emitsDone]), + ); + }); + + test('Rx.takeWhileInclusive.shouldClose', () async { + final stream = + Stream.fromIterable([2, 3, 4, 5, 6, 1, 2, 3]).takeWhileInclusive((i) { + if (i == 4) { + throw Exception(); + } else { + return true; + } + }); + await expectLater( + stream, + emitsInOrder( + [ + 2, + 3, + emitsError(isA()), + emitsDone, + ], + ), + ); + }); + + test('Rx.takeWhileInclusive.asBroadcastStream', () async { + final stream = Stream.fromIterable([2, 3, 4, 5, 6]) + .takeWhileInclusive((i) => i < 4) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + await expectLater(true, true); + }); + + test('Rx.takeWhileInclusive.shouldThrowB', () async { + final stream = + Stream.error(Exception()).takeWhileInclusive((_) => true); + await expectLater( + stream, + emitsError(isA()), + ); + }); + + test('Rx.takeWhileInclusive.pause.resume', () async { + late StreamSubscription subscription; + + subscription = Stream.fromIterable([2, 3, 4, 5, 6]) + .takeWhileInclusive((i) => i < 4) + .listen(expectAsync1((data) { + expect(data, 2); + subscription.cancel(); + })); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.takeWhileInclusive accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.takeWhileInclusive((_) => true); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.takeWhileInclusive.nullable', () { + nullableTest( + (s) => s.takeWhileInclusive((_) => true), + ); + }); +} diff --git a/core/reactivex/test/transformers/time_interval_test.dart b/core/reactivex/test/transformers/time_interval_test.dart new file mode 100644 index 00000000..9c1d1f47 --- /dev/null +++ b/core/reactivex/test/transformers/time_interval_test.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() => Stream.fromIterable([0, 1, 2]); + +void main() { + test('Rx.timeInterval', () async { + const expectedOutput = [0, 1, 2]; + var count = 0; + + _getStream() + .interval(const Duration(milliseconds: 1)) + .timeInterval() + .listen(expectAsync1((result) { + expect(expectedOutput[count++], result.value); + + expect( + result.interval.inMicroseconds >= 1000 /* microseconds! */, true); + }, count: expectedOutput.length)); + }); + + test('Rx.timeInterval.reusable', () async { + final transformer = TimeIntervalStreamTransformer(); + const expectedOutput = [0, 1, 2]; + var countA = 0, countB = 0; + + _getStream() + .interval(const Duration(milliseconds: 1)) + .transform(transformer) + .listen(expectAsync1((result) { + expect(expectedOutput[countA++], result.value); + + expect( + result.interval.inMicroseconds >= 1000 /* microseconds! */, true); + }, count: expectedOutput.length)); + + _getStream() + .interval(const Duration(milliseconds: 1)) + .transform(transformer) + .listen(expectAsync1((result) { + expect(expectedOutput[countB++], result.value); + + expect( + result.interval.inMicroseconds >= 1000 /* microseconds! */, true); + }, count: expectedOutput.length)); + }); + + test('Rx.timeInterval.asBroadcastStream', () async { + final stream = _getStream() + .asBroadcastStream() + .interval(const Duration(milliseconds: 1)) + .timeInterval(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.timeInterval.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()) + .interval(const Duration(milliseconds: 1)) + .timeInterval(); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.timeInterval.pause.resume', () async { + late StreamSubscription> subscription; + const expectedOutput = [0, 1, 2]; + var count = 0; + + subscription = _getStream() + .interval(const Duration(milliseconds: 1)) + .timeInterval() + .listen(expectAsync1((result) { + expect(result.value, expectedOutput[count++]); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.timeInterval accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.timeInterval(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.timeInterval.nullable', () { + nullableTest>( + (s) => s.timeInterval(), + ); + }); +} diff --git a/core/reactivex/test/transformers/timeout_test.dart b/core/reactivex/test/transformers/timeout_test.dart new file mode 100644 index 00000000..5460b1df --- /dev/null +++ b/core/reactivex/test/transformers/timeout_test.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +import 'package:test/test.dart'; + +void main() { + test('Rx.timeout', () async { + late StreamSubscription subscription; + + final stream = Stream.fromFuture( + Future.delayed(Duration(milliseconds: 30), () => 1)) + .timeout(Duration(milliseconds: 1)); + + subscription = stream.listen((_) {}, + onError: expectAsync2((Object e, StackTrace s) { + expect(e is TimeoutException, isTrue); + subscription.cancel(); + }, count: 1)); + }); +} diff --git a/core/reactivex/test/transformers/timestamp_test.dart b/core/reactivex/test/transformers/timestamp_test.dart new file mode 100644 index 00000000..0a4cccf0 --- /dev/null +++ b/core/reactivex/test/transformers/timestamp_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.Rx.timestamp', () async { + const expected = [1, 2, 3]; + var count = 0; + + Stream.fromIterable(const [1, 2, 3]) + .timestamp() + .listen(expectAsync1((result) { + expect(result.value, expected[count++]); + }, count: expected.length)); + }); + + test('Rx.Rx.timestamp.reusable', () async { + final transformer = TimestampStreamTransformer(); + const expected = [1, 2, 3]; + var countA = 0, countB = 0; + + Stream.fromIterable(const [1, 2, 3]) + .transform(transformer) + .listen(expectAsync1((result) { + expect(result.value, expected[countA++]); + }, count: expected.length)); + + Stream.fromIterable(const [1, 2, 3]) + .transform(transformer) + .listen(expectAsync1((result) { + expect(result.value, expected[countB++]); + }, count: expected.length)); + }); + + test('timestampTransformer', () async { + const expected = [1, 2, 3]; + var count = 0; + + Stream.fromIterable(const [1, 2, 3]) + .transform(TimestampStreamTransformer()) + .listen(expectAsync1((result) { + expect(result.value, expected[count++]); + }, count: expected.length)); + }); + + test('timestampTransformer.asBroadcastStream', () async { + final stream = Stream.fromIterable(const [1, 2, 3]) + .transform(TimestampStreamTransformer()) + .asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(stream.isBroadcast, isTrue); + }); + + test('timestampTransformer.error.shouldThrow', () async { + final streamWithError = + Stream.error(Exception()).transform(TimestampStreamTransformer()); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('timestampTransformer.pause.resume', () async { + final stream = Stream.fromIterable(const [1, 2, 3]) + .transform(TimestampStreamTransformer()); + const expected = [1, 2, 3]; + late StreamSubscription> subscription; + var count = 0; + + subscription = stream.listen(expectAsync1((result) { + expect(result.value, expected[count++]); + + if (count == expected.length) { + subscription.cancel(); + } + }, count: expected.length)); + + subscription.pause(); + subscription.resume(); + }); + test('Rx.timestamp accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.timestamp(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.timestamp.nullable', () { + nullableTest>( + (s) => s.timestamp(), + ); + }); +} diff --git a/core/reactivex/test/transformers/where_not_null_test.dart b/core/reactivex/test/transformers/where_not_null_test.dart new file mode 100644 index 00000000..fd9c77e4 --- /dev/null +++ b/core/reactivex/test/transformers/where_not_null_test.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +void main() { + test('Rx.whereNotNull', () { + { + final notNull = Stream.fromIterable([1, 2, 3, 4]).whereNotNull(); + + expect(notNull, isA>()); + expect(notNull, emitsInOrder([1, 2, 3, 4])); + } + + { + final notNull = Stream.fromIterable([1, 2, null, 3, 4, null]) + .transform(WhereNotNullStreamTransformer()); + + expect(notNull, isA>()); + expect(notNull, emitsInOrder([1, 2, 3, 4])); + } + }); + + test('Rx.whereNotNull.shouldThrow', () { + expect( + Stream.error(Exception()).whereNotNull(), + emitsError(isA()), + ); + + expect( + Rx.concat([ + Stream.fromIterable([1, 2, null]), + Stream.error(Exception()), + Stream.value(3), + ]).whereNotNull(), + emitsInOrder([ + 1, + 2, + emitsError(isException), + 3, + emitsDone, + ]), + ); + }); + + test('Rx.whereNotNull.asBroadcastStream', () { + final stream = + Stream.fromIterable([1, 2, null]).whereNotNull().asBroadcastStream(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + // code should reach here + expect(true, true); + }); + + test('Rx.whereNotNull.singleSubscription', () { + final stream = StreamController().stream.whereNotNull(); + + expect(stream.isBroadcast, isFalse); + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + }); + + test('Rx.whereNotNull.pause.resume', () async { + final subscription = Stream.fromIterable([null, 2, 3, null, 4, 5, 6]) + .whereNotNull() + .listen(null); + + subscription + ..pause() + ..onData(expectAsync1((data) { + expect(data, 2); + subscription.cancel(); + })) + ..resume(); + }); + + test('Rx.whereNotNull.nullable', () { + nullableTest( + (s) => s.whereNotNull(), + ); + }); +} diff --git a/core/reactivex/test/transformers/where_type_test.dart b/core/reactivex/test/transformers/where_type_test.dart new file mode 100644 index 00000000..2eb0158b --- /dev/null +++ b/core/reactivex/test/transformers/where_type_test.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +Stream _getStream() { + final controller = StreamController(); + + Timer(const Duration(milliseconds: 100), () => controller.add(1)); + Timer(const Duration(milliseconds: 200), () => controller.add('2')); + Timer( + const Duration(milliseconds: 300), () => controller.add(const {'3': 3})); + Timer(const Duration(milliseconds: 400), () { + controller.add(const {'4': '4'}); + }); + Timer(const Duration(milliseconds: 500), () { + controller.add(5.0); + controller.close(); + }); + + return controller.stream; +} + +void main() { + test('Rx.whereType', () async { + _getStream().whereType>().listen(expectAsync1((result) { + expect(result, isMap); + }, count: 1)); + }); + + test('Rx.whereType.polymorphism', () async { + _getStream().whereType().listen(expectAsync1((Object result) { + expect(result is num, true); + }, count: 2)); + }); + + test('Rx.whereType.null.values', () async { + await expectLater( + Stream.fromIterable([null, 1, null, 'two', 3]).whereType(), + emitsInOrder(const ['two'])); + }); + + test('Rx.whereType.asBroadcastStream', () async { + final stream = _getStream().asBroadcastStream().whereType(); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + // code should reach here + await expectLater(true, true); + }); + + test('Rx.whereType.error.shouldThrow', () async { + final streamWithError = Stream.error(Exception()).whereType(); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.whereType.pause.resume', () async { + late StreamSubscription subscription; + final stream = Stream.value(1).whereType(); + + subscription = stream.listen(expectAsync1((value) { + expect(value, 1); + + subscription.cancel(); + }, count: 1)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.whereType accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream.whereType(); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.whereType.nullable', () { + nullableTest( + (s) => s.whereType(), + ); + }); +} diff --git a/core/reactivex/test/transformers/with_latest_from_test.dart b/core/reactivex/test/transformers/with_latest_from_test.dart new file mode 100644 index 00000000..51914403 --- /dev/null +++ b/core/reactivex/test/transformers/with_latest_from_test.dart @@ -0,0 +1,541 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +/// creates 5 Streams, deferred from a source Stream, so that they all emit +/// under the same Timer interval. +/// before, tests could fail, since we created 5 separate Streams with each +/// using their own Timer. +List> _createTestStreams() { + /// creates streams that emit after a certain amount of milliseconds, + /// the List of intervals (in ms) + const intervals = [22, 50, 30, 40, 60]; + final ticker = + Stream.periodic(const Duration(milliseconds: 1), (index) => index) + .skip(1) + .take(300) + .asBroadcastStream(); + + return [ + ticker + .where((index) => index % intervals[0] == 0) + .map((index) => index ~/ intervals[0] - 1), + ticker + .where((index) => index % intervals[1] == 0) + .map((index) => index ~/ intervals[1] - 1), + ticker + .where((index) => index % intervals[2] == 0) + .map((index) => index ~/ intervals[2] - 1), + ticker + .where((index) => index % intervals[3] == 0) + .map((index) => index ~/ intervals[3] - 1), + ticker + .where((index) => index % intervals[4] == 0) + .map((index) => index ~/ intervals[4] - 1) + ]; +} + +void main() { + test('Rx.withLatestFrom', () async { + const expectedOutput = [ + Pair(2, 0), + Pair(3, 0), + Pair(4, 1), + Pair(5, 1), + Pair(6, 2) + ]; + final streams = _createTestStreams(); + + await expectLater( + streams.first + .withLatestFrom( + streams[1], (first, int second) => Pair(first, second)) + .take(5), + emitsInOrder(expectedOutput)); + }); + + test('Rx.withLatestFrom.iterate.once', () async { + var iterationCount = 0; + + final combined = Stream.value(1).withLatestFromList(() sync* { + ++iterationCount; + yield Stream.value(2); + yield Stream.value(3); + }()); + + await expectLater( + combined, + emitsInOrder([ + [1, 2, 3], + emitsDone, + ]), + ); + expect(iterationCount, 1); + }); + + test('Rx.withLatestFrom.reusable', () async { + final streams = _createTestStreams(); + final transformer = WithLatestFromStreamTransformer.with1( + streams[1], (first, second) => Pair(first, second)); + const expectedOutput = [ + Pair(2, 0), + Pair(3, 0), + Pair(4, 1), + Pair(5, 1), + Pair(6, 2) + ]; + var countA = 0, countB = 0; + + streams.first.transform(transformer).take(5).listen(expectAsync1((result) { + expect(result, expectedOutput[countA++]); + }, count: expectedOutput.length)); + + streams.first.transform(transformer).take(5).listen(expectAsync1((result) { + expect(result, expectedOutput[countB++]); + }, count: expectedOutput.length)); + }); + + test('Rx.withLatestFrom.asBroadcastStream', () async { + final streams = _createTestStreams(); + final stream = + streams.first.withLatestFrom(streams[1], (first, int second) => 0); + + // listen twice on same stream + stream.listen(null); + stream.listen(null); + + await expectLater(true, true); + }); + + test('Rx.withLatestFrom.error.shouldThrowA', () async { + final streams = _createTestStreams(); + final streamWithError = Stream.error(Exception()) + .withLatestFrom(streams[1], (first, int second) => 'Hello'); + + streamWithError.listen(null, + onError: expectAsync2((Exception e, StackTrace s) { + expect(e, isException); + })); + }); + + test('Rx.withLatestFrom.error.shouldThrowB', () async { + final streams = _createTestStreams(); + final stream = streams[1].take(1).withLatestFrom( + Stream.value(0), (first, int second) => throw Exception()); + + expect( + stream, + emitsInOrder([ + emitsError(isException), + emitsDone, + ])); + }); + + test('Rx.withLatestFrom.pause.resume', () async { + late StreamSubscription subscription; + const expectedOutput = [Pair(2, 0)]; + final streams = _createTestStreams(); + var count = 0; + + subscription = streams.first + .withLatestFrom(streams[1], (first, int second) => Pair(first, second)) + .take(1) + .listen(expectAsync1((result) { + expect(result, expectedOutput[count++]); + + if (count == expectedOutput.length) { + subscription.cancel(); + } + }, count: expectedOutput.length)); + + subscription.pause(); + subscription.resume(); + }); + + test('Rx.withLatestFrom.otherEmitsNull', () async { + const expected = Pair(1, null); + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom( + Stream.value(null), + (a, int? b) => Pair(a, b), + ); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFrom.otherNotEmit', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom( + Stream.empty(), + (a, int b) => Pair(a, b), + ); + + await expectLater( + stream, + emitsDone, + ); + }); + + test('Rx.withLatestFrom2', () async { + const expectedOutput = [ + _Tuple(2, 0, 1), + _Tuple(3, 0, 1), + _Tuple(4, 1, 2), + _Tuple(5, 1, 3), + _Tuple(6, 2, 4), + ]; + final streams = _createTestStreams(); + var count = 0; + + streams.first + .withLatestFrom2( + streams[1], + streams[2], + (item1, int item2, int item3) => _Tuple(item1, item2, item3), + ) + .take(5) + .listen( + expectAsync1( + (result) => expect(result, expectedOutput[count++]), + count: expectedOutput.length, + ), + ); + }); + + test('Rx.withLatestFrom3', () async { + const expectedOutput = [ + _Tuple(2, 0, 1, 0), + _Tuple(3, 0, 1, 1), + _Tuple(4, 1, 2, 1), + _Tuple(5, 1, 3, 2), + _Tuple(6, 2, 4, 2), + ]; + final streams = _createTestStreams(); + var count = 0; + + streams.first + .withLatestFrom3( + streams[1], + streams[2], + streams[3], + (item1, int item2, int item3, int item4) => + _Tuple(item1, item2, item3, item4), + ) + .take(5) + .listen( + expectAsync1( + (result) => expect(result, expectedOutput[count++]), + count: expectedOutput.length, + ), + ); + }); + + test('Rx.withLatestFrom4', () async { + const expectedOutput = [ + _Tuple(2, 0, 1, 0, 0), + _Tuple(3, 0, 1, 1, 0), + _Tuple(4, 1, 2, 1, 0), + _Tuple(5, 1, 3, 2, 1), + _Tuple(6, 2, 4, 2, 1), + ]; + final streams = _createTestStreams(); + var count = 0; + + streams.first + .withLatestFrom4( + streams[1], + streams[2], + streams[3], + streams[4], + (item1, int item2, int item3, int item4, int item5) => + _Tuple(item1, item2, item3, item4, item5), + ) + .take(5) + .listen( + expectAsync1( + (result) => expect(result, expectedOutput[count++]), + count: expectedOutput.length, + ), + ); + }); + + test('Rx.withLatestFrom5', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom5( + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + (a, int b, int c, int d, int e, int f) => _Tuple(a, b, c, d, e, f), + ); + const expected = _Tuple(1, 2, 3, 4, 5, 6); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFrom6', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom6( + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + Stream.value(7), + (a, int b, int c, int d, int e, int f, int g) => + _Tuple(a, b, c, d, e, f, g), + ); + const expected = _Tuple(1, 2, 3, 4, 5, 6, 7); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFrom7', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom7( + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + Stream.value(7), + Stream.value(8), + (a, int b, int c, int d, int e, int f, int g, int h) => + _Tuple(a, b, c, d, e, f, g, h), + ); + const expected = _Tuple(1, 2, 3, 4, 5, 6, 7, 8); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFrom8', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom8( + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + Stream.value(7), + Stream.value(8), + Stream.value(9), + (a, int b, int c, int d, int e, int f, int g, int h, int i) => + _Tuple(a, b, c, d, e, f, g, h, i), + ); + const expected = _Tuple(1, 2, 3, 4, 5, 6, 7, 8, 9); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFrom9', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFrom9( + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + Stream.value(7), + Stream.value(8), + Stream.value(9), + Stream.value(10), + (a, int b, int c, int d, int e, int f, int g, int h, int i, int j) => + _Tuple(a, b, c, d, e, f, g, h, i, j), + ); + const expected = _Tuple(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFromList', () async { + final stream = Rx.timer( + 1, + const Duration(microseconds: 100), + ).withLatestFromList( + [ + Stream.value(2), + Stream.value(3), + Stream.value(4), + Stream.value(5), + Stream.value(6), + Stream.value(7), + Stream.value(8), + Stream.value(9), + Stream.value(10), + ], + ); + const expected = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + await expectLater( + stream, + emits(expected), + ); + }); + + test('Rx.withLatestFromList.emptyList', () async { + final stream = Stream.fromIterable([1, 2, 3]).withLatestFromList([]); + + await expectLater( + stream, + emitsInOrder( + >[ + [1], + [2], + [3], + ], + ), + ); + }); + test('Rx.withLatestFrom accidental broadcast', () async { + final controller = StreamController(); + + final stream = controller.stream + .withLatestFrom(Stream.empty(), (_, dynamic __) => true); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.withLatestFrom.nullable', () { + nullableTest>( + (s) => s.withLatestFromList([Stream.value('String')]), + ); + }); +} + +class Pair { + final int? first; + final int? second; + + const Pair(this.first, this.second); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is Pair && first == other.first && second == other.second; + } + + @override + int get hashCode { + return first.hashCode ^ second.hashCode; + } + + @override + String toString() { + return 'Pair{first: $first, second: $second}'; + } +} + +class _Tuple { + final int? item1; + final int? item2; + final int? item3; + final int? item4; + final int? item5; + final int? item6; + final int? item7; + final int? item8; + final int? item9; + final int? item10; + + const _Tuple([ + this.item1, + this.item2, + this.item3, + this.item4, + this.item5, + this.item6, + this.item7, + this.item8, + this.item9, + this.item10, + ]); + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is _Tuple && + item1 == other.item1 && + item2 == other.item2 && + item3 == other.item3 && + item4 == other.item4 && + item5 == other.item5 && + item6 == other.item6 && + item7 == other.item7 && + item8 == other.item8 && + item9 == other.item9 && + item10 == other.item10; + } + + @override + int get hashCode { + return item1.hashCode ^ + item2.hashCode ^ + item3.hashCode ^ + item4.hashCode ^ + item5.hashCode ^ + item6.hashCode ^ + item7.hashCode ^ + item8.hashCode ^ + item9.hashCode ^ + item10.hashCode; + } + + @override + String toString() { + final values = [ + item1, + item2, + item3, + item4, + item5, + item6, + item7, + item8, + item9, + item10, + ]; + final s = values.join(', '); + return 'Tuple { $s }'; + } +} diff --git a/core/reactivex/test/transformers/zip_with_test.dart b/core/reactivex/test/transformers/zip_with_test.dart new file mode 100644 index 00000000..36839c96 --- /dev/null +++ b/core/reactivex/test/transformers/zip_with_test.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + test('Rx.zipWith', () async { + Stream.value(1) + .zipWith(Stream.value(2), (int one, int two) => one + two) + .listen(expectAsync1((int result) { + expect(result, 3); + }, count: 1)); + }); + + test('Rx.zipWith accidental broadcast', () async { + final controller = StreamController(); + + final stream = + controller.stream.zipWith(Stream.empty(), (_, dynamic __) => true); + + stream.listen(null); + expect(() => stream.listen(null), throwsStateError); + + controller.add(1); + }); + + test('Rx.zipWith on single stream should stay single ', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + final expected = [3, emitsDone]; + + final concatenatedStream = + delayedStream.zipWith(immediateStream, (a, int b) => a + b); + + expect(concatenatedStream.isBroadcast, isFalse); + expect(concatenatedStream, emitsInOrder(expected)); + }); + + test('Rx.zipWith on broadcast stream should stay broadcast ', () async { + final delayedStream = + Rx.timer(1, Duration(milliseconds: 10)).asBroadcastStream(); + final immediateStream = Stream.value(2); + final expected = [3, emitsDone]; + + final concatenatedStream = + delayedStream.zipWith(immediateStream, (a, int b) => a + b); + + expect(concatenatedStream.isBroadcast, isTrue); + expect(concatenatedStream, emitsInOrder(expected)); + }); + + test('Rx.zipWith multiple subscriptions on single ', () async { + final delayedStream = Rx.timer(1, Duration(milliseconds: 10)); + final immediateStream = Stream.value(2); + + final concatenatedStream = + delayedStream.zipWith(immediateStream, (a, int b) => a + b); + + expect(() => concatenatedStream.listen(null), returnsNormally); + expect(() => concatenatedStream.listen(null), + throwsA(TypeMatcher())); + }); +} diff --git a/core/reactivex/test/utils.dart b/core/reactivex/test/utils.dart new file mode 100644 index 00000000..2a8f4fda --- /dev/null +++ b/core/reactivex/test/utils.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +/// Explicitly ignores a future. +/// +/// Not all futures need to be awaited. +/// The Dart linter has an optional ["unawaited futures" lint](https://dart-lang.github.io/linter/lints/unawaited_futures.html) +/// which enforces that futures (expressions with a static type of [Future]) +/// in asynchronous functions are handled *somehow*. +/// If a particular future value doesn't need to be awaited, +/// you can call `unawaited(...)` with it, which will avoid the lint, +/// simply because the expression no longer has type [Future]. +/// Using `unawaited` has no other effect. +/// You should use `unawaited` to convey the *intention* of +/// deliberately not waiting for the future. +/// +/// If the future completes with an error, +/// it was likely a mistake to not await it. +/// That error will still occur and will be considered unhandled +/// unless the same future is awaited (or otherwise handled) elsewhere too. +/// Because of that, `unawaited` should only be used for futures that +/// are *expected* to complete with a value. +void unawaited(Future future) {} + +void nullableTest(Stream Function(Stream s) transform) => + transform(Stream.fromIterable(['1', '2', '3'])); diff --git a/core/reactivex/test/utils/composite_subscription_test.dart b/core/reactivex/test/utils/composite_subscription_test.dart new file mode 100644 index 00000000..66a01e3c --- /dev/null +++ b/core/reactivex/test/utils/composite_subscription_test.dart @@ -0,0 +1,316 @@ +import 'dart:async'; + +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + group('CompositeSubscription', () { + test('cast to StreamSubscription of any type', () { + final cs = CompositeSubscription(); + + expect(cs, isA>()); + // ignore: prefer_void_to_null + expect(cs, isA>()); + expect(cs, isA>()); + expect(cs, isA>()); + expect(cs, isA>()); + expect(cs, isA>()); + + cs as StreamSubscription; // ignore: unnecessary_cast + // ignore: unnecessary_cast, prefer_void_to_null + cs as StreamSubscription; + cs as StreamSubscription; // ignore: unnecessary_cast + cs as StreamSubscription; // ignore: unnecessary_cast + cs as StreamSubscription; // ignore: unnecessary_cast + cs as StreamSubscription; // ignore: unnecessary_cast + + expect(true, true); + }); + + group('throws UnsupportedError', () { + test('when calling asFuture()', () { + expect( + () => CompositeSubscription().asFuture(0), throwsUnsupportedError); + }); + + test('when calling onData()', () { + expect(() => CompositeSubscription().onData((_) {}), + throwsUnsupportedError); + }); + + test('when calling onError()', () { + expect(() => CompositeSubscription().onError((Object _) {}), + throwsUnsupportedError); + }); + + test('when calling onDone()', () { + expect(() => CompositeSubscription().onDone(() {}), + throwsUnsupportedError); + }); + }); + + group('Rx.compositeSubscription.clear', () { + test('should cancel all subscriptions', () { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + final composite = CompositeSubscription(); + + composite + ..add(stream.listen(null)) + ..add(stream.listen(null)) + ..add(stream.listen(null)); + + final done = composite.clear(); + + expect(stream, neverEmits(anything)); + expect(done, isA>()); + }); + + test( + 'should return null since no subscription has been canceled clear()', + () { + final composite = CompositeSubscription(); + final done = composite.clear(); + expect(done, null); + }, + ); + }); + + group('Rx.compositeSubscription.onDispose', () { + test('should cancel all subscriptions when calling dispose()', () { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + final composite = CompositeSubscription(); + + composite + ..add(stream.listen(null)) + ..add(stream.listen(null)) + ..add(stream.listen(null)); + + final done = composite.dispose(); + + expect(stream, neverEmits(anything)); + expect(done, isA>()); + }); + + test('should cancel all subscriptions when calling cancel()', () { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + final composite = CompositeSubscription(); + + composite + ..add(stream.listen(null)) + ..add(stream.listen(null)) + ..add(stream.listen(null)); + + final done = composite.cancel(); + + expect(stream, neverEmits(anything)); + expect(done, isA>()); + }); + + test( + 'should return null since no subscription has been canceled on dispose()', + () { + final composite = CompositeSubscription(); + final done = composite.dispose(); + expect(done, null); + }, + ); + + test( + 'should return Future completed with null since no subscription has been canceled on cancel()', + () { + final composite = CompositeSubscription(); + final done = composite.cancel(); + expect(done, completion(null)); + }, + ); + + test( + 'should throw exception if trying to add subscription to disposed composite, after calling dispose()', + () { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + final composite = CompositeSubscription(); + + composite.dispose(); + + expect(() => composite.add(stream.listen(null)), throwsA(anything)); + }, + ); + + test( + 'should throw exception if trying to add subscription to disposed composite, after calling cancel()', + () { + final stream = Stream.fromIterable(const [1, 2, 3]).shareValue(); + final composite = CompositeSubscription(); + + composite.cancel(); + + expect(() => composite.add(stream.listen(null)), throwsA(anything)); + }, + ); + }); + + group('Rx.compositeSubscription.remove', () { + test('should cancel subscription on if it is removed from composite', () { + const value = 1; + final stream = Stream.fromIterable([value]).shareValue(); + final composite = CompositeSubscription(); + final subscription = stream.listen(null); + + composite.add(subscription); + final done = composite.remove(subscription); + + expect(stream, neverEmits(anything)); + expect(done, isA>()); + }); + + test( + 'should not cancel the subscription since it is not present in the composite', + () { + const value = 1; + final stream = Stream.fromIterable([value]).shareValue(); + final composite = CompositeSubscription(); + final subscription = stream.listen(null); + + final done = composite.remove(subscription); + + expect(stream, emits(anything)); + expect(done, null); + }, + ); + }); + + test('Rx.compositeSubscription.pauseAndResume()', () { + final composite = CompositeSubscription(); + final s1 = Stream.fromIterable(const [1, 2, 3]).listen(null), + s2 = Stream.fromIterable(const [4, 5, 6]).listen(null); + + composite.add(s1); + composite.add(s2); + + void expectPaused() { + expect(composite.allPaused, isTrue); + expect(composite.isPaused, isTrue); + + expect(s1.isPaused, isTrue); + expect(s2.isPaused, isTrue); + } + + void expectResumed() { + expect(composite.allPaused, isFalse); + expect(composite.isPaused, isFalse); + + expect(s1.isPaused, isFalse); + expect(s2.isPaused, isFalse); + } + + composite.pauseAll(); + + expectPaused(); + + composite.resumeAll(); + + expectResumed(); + + composite.pause(); + + expectPaused(); + + composite.resume(); + + expectResumed(); + }); + + test('Rx.compositeSubscription.resumeWithFuture', () async { + final composite = CompositeSubscription(); + final s1 = Stream.fromIterable(const [1, 2, 3]).listen(null), + s2 = Stream.fromIterable(const [4, 5, 6]).listen(null); + final completer = Completer(); + + composite.add(s1); + composite.add(s2); + composite.pauseAll(completer.future); + + expect(composite.allPaused, isTrue); + expect(composite.isPaused, isTrue); + + completer.complete(); + + await expectLater(completer.future.then((_) => composite.allPaused), + completion(isFalse)); + await expectLater(completer.future.then((_) => composite.isPaused), + completion(isFalse)); + }); + + test('Rx.compositeSubscription.allPaused', () { + final composite = CompositeSubscription(); + final s1 = Stream.fromIterable(const [1, 2, 3]).listen(null), + s2 = Stream.fromIterable(const [4, 5, 6]).listen(null); + + expect(composite.allPaused, isFalse); + expect(composite.isPaused, isFalse); + + composite.add(s1); + composite.add(s2); + + expect(composite.allPaused, isFalse); + expect(composite.isPaused, isFalse); + + composite.pauseAll(); + + expect(composite.allPaused, isTrue); + expect(composite.isPaused, isTrue); + + composite.remove(s1); + composite.remove(s2); + + /// all subscriptions are removed, allPaused should yield false + expect(composite.allPaused, isFalse); + expect(composite.isPaused, isFalse); + }); + + test('Rx.compositeSubscription.allPaused.indirectly', () { + final composite = CompositeSubscription(); + final s1 = Stream.fromIterable(const [1, 2, 3]).listen(null), + s2 = Stream.fromIterable(const [4, 5, 6]).listen(null); + + s1.pause(); + s2.pause(); + + composite.add(s1); + composite.add(s2); + + expect(composite.allPaused, isTrue); + expect(composite.isPaused, isTrue); + + s1.resume(); + s2.resume(); + + expect(composite.allPaused, isFalse); + expect(composite.isPaused, isFalse); + }); + + test('Rx.compositeSubscription.size', () { + final composite = CompositeSubscription(); + final s1 = Stream.fromIterable(const [1, 2, 3]).listen(null), + s2 = Stream.fromIterable(const [4, 5, 6]).listen(null); + + expect(composite.isEmpty, isTrue); + expect(composite.isNotEmpty, isFalse); + expect(composite.length, 0); + + composite.add(s1); + composite.add(s2); + + expect(composite.isEmpty, isFalse); + expect(composite.isNotEmpty, isTrue); + expect(composite.length, 2); + + composite.remove(s1); + composite.remove(s2); + + expect(composite.isEmpty, isTrue); + expect(composite.isNotEmpty, isFalse); + expect(composite.length, 0); + }); + }); +} diff --git a/core/reactivex/test/utils/notification_test.dart b/core/reactivex/test/utils/notification_test.dart new file mode 100644 index 00000000..45191f94 --- /dev/null +++ b/core/reactivex/test/utils/notification_test.dart @@ -0,0 +1,234 @@ +import 'package:angel3_reactivex/angel3_reactivex.dart'; +import 'package:test/test.dart'; + +void main() { + group('StreamNotification', () { + test('hashCode', () { + final value1 = 1; + final value2 = 2; + + final st1 = StackTrace.current; + final st2 = StackTrace.current; + + expect( + StreamNotification.data(value1).hashCode, + StreamNotification.data(value1).hashCode, + ); + expect( + StreamNotification.data(value1).hashCode, + StreamNotification.data(value1).hashCode, + ); + expect( + StreamNotification.data(value1).hashCode, + isNot(StreamNotification.data(value2).hashCode), + ); + + expect( + StreamNotification.done().hashCode, + StreamNotification.done().hashCode, + ); + expect( + StreamNotification.done().hashCode, + StreamNotification.done().hashCode, + ); + + expect( + StreamNotification.error(value1, st1).hashCode, + StreamNotification.error(value1, st1).hashCode, + ); + expect( + StreamNotification.error(value1, st1).hashCode, + isNot(StreamNotification.error(value2, st1).hashCode), + ); + expect( + StreamNotification.error(value1, st1).hashCode, + isNot(StreamNotification.error(value1, st2).hashCode), + ); + expect( + StreamNotification.error(value1, st1).hashCode, + isNot(StreamNotification.error(value2, st2).hashCode), + ); + + expect( + StreamNotification.data(value1).hashCode, + isNot(StreamNotification.done().hashCode), + ); + expect( + StreamNotification.data(value1).hashCode, + isNot(StreamNotification.error(value1, st1).hashCode), + ); + expect( + StreamNotification.done().hashCode, + isNot(StreamNotification.error(value1, st1).hashCode), + ); + }); + + test('==', () { + final value1 = 1; + final value2 = 2; + + final st1 = StackTrace.current; + final st2 = StackTrace.current; + + expect( + StreamNotification.data(value1), + StreamNotification.data(value1), + ); + expect( + StreamNotification.data(value1), + isNot(StreamNotification.data(value1)), + ); + expect( + StreamNotification.data(value1), + isNot(StreamNotification.data(value2)), + ); + + expect( + StreamNotification.done(), + StreamNotification.done(), + ); + expect( + const StreamNotification.done(), + StreamNotification.done(), + ); + expect( + StreamNotification.done(), + StreamNotification.done(), + ); + + expect( + StreamNotification.error(value1, st1), + StreamNotification.error(value1, st1), + ); + expect( + StreamNotification.error(value1, st1), + isNot(StreamNotification.error(value2, st1)), + ); + expect( + StreamNotification.error(value1, st1), + isNot(StreamNotification.error(value1, st2)), + ); + expect( + StreamNotification.error(value1, st1), + isNot(StreamNotification.error(value2, st2)), + ); + + expect( + StreamNotification.data(value1), + isNot(StreamNotification.done()), + ); + expect( + StreamNotification.data(value1), + isNot(StreamNotification.error(value1, st1)), + ); + expect( + StreamNotification.done(), + isNot(StreamNotification.error(value1, st1)), + ); + }); + + test('toString', () { + expect( + StreamNotification.data(1).toString(), + 'DataNotification{value: 1}', + ); + + expect( + StreamNotification.done().toString(), + 'DoneNotification{}', + ); + + expect( + StreamNotification.error(2, StackTrace.empty).toString(), + 'ErrorNotification{error: 2, stackTrace: }', + ); + }); + + test('requireData', () { + expect( + StreamNotification.data(1).requireDataValue, + 1, + ); + + expect( + () => StreamNotification.done().requireDataValue, + throwsA(isA()), + ); + + expect( + () => + StreamNotification.error(2, StackTrace.empty).requireDataValue, + throwsA(isA()), + ); + }); + + test('errorAndStackTraceOrNull', () { + expect( + StreamNotification.data(1).errorAndStackTraceOrNull, + isNull, + ); + + expect( + StreamNotification.done().errorAndStackTraceOrNull, + isNull, + ); + + expect( + StreamNotification.error(2, StackTrace.empty) + .errorAndStackTraceOrNull, + ErrorAndStackTrace(2, StackTrace.empty), + ); + }); + + test('isOnData', () { + expect( + StreamNotification.data(1).isData, + isTrue, + ); + + expect( + StreamNotification.done().isData, + isFalse, + ); + + expect( + StreamNotification.error(2, StackTrace.empty).isData, + isFalse, + ); + }); + + test('isOnDone', () { + expect( + StreamNotification.data(1).isDone, + isFalse, + ); + + expect( + StreamNotification.done().isDone, + isTrue, + ); + + expect( + StreamNotification.error(2, StackTrace.empty).isDone, + isFalse, + ); + }); + + test('isOnError', () { + expect( + StreamNotification.data(1).isError, + isFalse, + ); + + expect( + StreamNotification.done().isError, + isFalse, + ); + + expect( + StreamNotification.error(2, StackTrace.empty).isError, + isTrue, + ); + }); + }); +}