From a9172c2d3accc055b3b4001e083b6946ee813767 Mon Sep 17 00:00:00 2001 From: Patrick Stewart Date: Wed, 2 Oct 2024 18:47:02 -0700 Subject: [PATCH] update: updating structure adding forked rxdart, event_bus_plus, dart_mq --- .../{angel_container => container}/.gitignore | 0 .../{angel_container => container}/AUTHORS.md | 0 .../CHANGELOG.md | 0 .../{angel_container => container}/LICENSE | 0 .../{angel_container => container}/README.md | 0 .../analysis_options.yaml | 0 .../example/main.dart | 0 .../example/throwing.dart | 0 .../lib/angel3_container.dart | 0 .../lib/mirrors.dart | 0 .../lib/src/container.dart | 0 .../lib/src/container_const.dart | 0 .../lib/src/empty/empty.dart | 0 .../lib/src/exception.dart | 0 .../lib/src/mirrors/mirrors.dart | 0 .../lib/src/mirrors/reflector.dart | 0 .../lib/src/reflectable/reflectable.dart | 0 .../lib/src/reflector.dart | 0 .../lib/src/static/static.dart | 0 .../lib/src/throwing.dart | 0 .../pubspec.yaml | 0 .../test/common.dart | 0 .../test/empty_reflector_test.dart | 0 .../test/has_test.dart | 0 .../test/lazy_test.dart | 0 .../test/mirrors_test.dart | 0 .../test/named_test.dart | 0 .../test/throwing_reflector_test.dart | 0 .../.gitignore | 0 .../CHANGELOG.md | 0 .../LICENSE | 0 .../README.md | 0 .../analysis_options.yaml | 0 .../example/main.dart | 0 .../example/main.reflectable.dart | 0 .../lib/angel3_container_generator.dart | 0 .../pubspec.yaml | 0 .../test/reflector_test.dart | 0 .../test/reflector_test.reflectable.dart | 0 core/eventbus | 1 + .../{http_exception => exceptions}/.gitignore | 0 .../{http_exception => exceptions}/AUTHORS.md | 0 .../CHANGELOG.md | 0 core/{http_exception => exceptions}/LICENSE | 0 core/{http_exception => exceptions}/README.md | 0 .../analysis_options.yaml | 0 .../example/main.dart | 0 .../lib/angel3_http_exception.dart | 0 .../pubspec.yaml | 0 core/{mock_request => mocking}/.gitignore | 0 core/{mock_request => mocking}/AUTHORS.md | 0 core/{mock_request => mocking}/CHANGELOG.md | 0 core/{mock_request => mocking}/LICENSE | 0 core/{mock_request => mocking}/README.md | 0 .../analysis_options.yaml | 0 .../example/main.dart | 0 .../lib/angel3_mock_request.dart | 0 .../lib/src/connection_info.dart | 0 .../lib/src/headers.dart | 0 .../lib/src/lockable_headers.dart | 0 .../lib/src/request.dart | 0 .../lib/src/response.dart | 0 .../lib/src/session.dart | 0 core/{mock_request => mocking}/pubspec.yaml | 0 .../test/all_test.dart | 0 core/mqueue/.github/workflows/action.yaml | 43 + core/mqueue/.gitignore | 7 + core/mqueue/CHANGELOG.md | 19 + core/mqueue/LICENSE | 21 + core/mqueue/README.md | 165 ++ core/mqueue/analysis_options.yaml | 211 +++ core/mqueue/assets/components-mq.png | Bin 0 -> 94259 bytes core/mqueue/assets/components.png | Bin 0 -> 30338 bytes core/mqueue/assets/default-exchange.png | Bin 0 -> 24526 bytes core/mqueue/assets/detailed-view.png | Bin 0 -> 138254 bytes core/mqueue/assets/direct-exchange.png | Bin 0 -> 42545 bytes core/mqueue/assets/fanout-exchange.png | Bin 0 -> 26191 bytes core/mqueue/assets/simple-view.png | Bin 0 -> 36356 bytes core/mqueue/example/main.dart | 16 + .../example/message_filtering/main.dart | 27 + .../message_filtering/task_manager.dart | 12 + .../example/message_filtering/worker_one.dart | 22 + .../example/message_filtering/worker_two.dart | 24 + core/mqueue/example/receiver.dart | 18 + core/mqueue/example/routing/debug_logger.dart | 39 + core/mqueue/example/routing/logger.dart | 21 + core/mqueue/example/routing/main.dart | 30 + .../example/routing/production_logger.dart | 29 + core/mqueue/example/rpc/main.dart | 19 + core/mqueue/example/rpc/service_one.dart | 19 + core/mqueue/example/rpc/service_two.dart | 46 + core/mqueue/example/sender.dart | 12 + core/mqueue/lib/mq.dart | 11 + core/mqueue/lib/src/binding/binding.dart | 75 + .../lib/src/binding/binding.interface.dart | 44 + core/mqueue/lib/src/consumer/consumer.dart | 95 ++ .../lib/src/consumer/consumer.interface.dart | 74 + .../lib/src/consumer/consumer.mixin.dart | 92 + core/mqueue/lib/src/core/constants/enums.dart | 22 + .../lib/src/core/constants/error_strings.dart | 99 ++ .../core/exceptions/binding_exceptions.dart | 42 + .../core/exceptions/consumer_exceptions.dart | 73 + .../lib/src/core/exceptions/exceptions.dart | 7 + .../core/exceptions/exchange_exceptions.dart | 44 + .../core/exceptions/mq_client_exceptions.dart | 31 + .../src/core/exceptions/queue_exceptions.dart | 54 + .../core/exceptions/registrar_exceptions.dart | 43 + .../exceptions/routing_key_exceptions.dart | 30 + .../src/core/registrar/simple_registrar.dart | 100 ++ .../lib/src/exchange/default_exchange.dart | 86 + .../lib/src/exchange/direct_exchange.dart | 89 + .../lib/src/exchange/exchange.base.dart | 27 + .../lib/src/exchange/exchange_interface.dart | 51 + .../lib/src/exchange/fanout_exchange.dart | 70 + core/mqueue/lib/src/message/message.base.dart | 43 + core/mqueue/lib/src/message/message.dart | 76 + core/mqueue/lib/src/mq/mq.base.dart | 14 + core/mqueue/lib/src/mq/mq.dart | 246 +++ core/mqueue/lib/src/mq/mq.interface.dart | 115 ++ core/mqueue/lib/src/producer/producer.dart | 91 + .../lib/src/producer/producer.interface.dart | 56 + .../lib/src/producer/producer.mixin.dart | 90 + .../lib/src/queue/data_stream.base.dart | 54 + core/mqueue/lib/src/queue/queue.dart | 36 + core/mqueue/pubspec.yaml | 17 + core/mqueue/test/binding/binding_test.dart | 97 ++ core/mqueue/test/consumer/consumer_test.dart | 333 ++++ .../exceptions/binding_exceptions_test.dart | 24 + .../exceptions/consumer_exceptions_test.dart | 60 + .../exceptions/exchange_exceptions_test.dart | 21 + .../exceptions/mq_client_exceptions_test.dart | 17 + .../exceptions/queue_exceptions_test.dart | 30 + .../exceptions/registrar_exceptions_test.dart | 25 + .../routing_key_exceptionss_test.dart | 12 + .../core/registrar/simple_registrar_test.dart | 105 ++ .../test/exchange/default_exchange_test.dart | 79 + .../test/exchange/direct_exchange_test.dart | 88 + .../test/exchange/fanout_exchange_test.dart | 69 + .../test/message/message.base_test.dart | 59 + core/mqueue/test/mq/mq_test.dart | 342 ++++ core/mqueue/test/producer/producer_test.dart | 111 ++ core/mqueue/test/queue/queue_test.dart | 98 ++ core/reactivex/.gitignore | 30 + core/reactivex/CHANGELOG.md | 775 +++++++++ core/reactivex/LICENSE | 201 +++ core/reactivex/README.md | 277 ++++ core/reactivex/analysis_options.yaml | 15 + core/reactivex/lib/angel3_reactivex.dart | 7 + core/reactivex/lib/src/rx.dart | 1357 +++++++++++++++ .../lib/src/streams/combine_latest.dart | 352 ++++ core/reactivex/lib/src/streams/concat.dart | 75 + .../lib/src/streams/concat_eager.dart | 88 + .../lib/src/streams/connectable_stream.dart | 516 ++++++ core/reactivex/lib/src/streams/defer.dart | 57 + core/reactivex/lib/src/streams/fork_join.dart | 366 ++++ .../lib/src/streams/from_callable.dart | 67 + core/reactivex/lib/src/streams/merge.dart | 74 + core/reactivex/lib/src/streams/never.dart | 31 + core/reactivex/lib/src/streams/race.dart | 70 + core/reactivex/lib/src/streams/range.dart | 41 + core/reactivex/lib/src/streams/repeat.dart | 78 + .../lib/src/streams/replay_stream.dart | 26 + core/reactivex/lib/src/streams/retry.dart | 89 + .../reactivex/lib/src/streams/retry_when.dart | 142 ++ .../lib/src/streams/sequence_equal.dart | 95 ++ .../lib/src/streams/switch_latest.dart | 99 ++ core/reactivex/lib/src/streams/timer.dart | 69 + core/reactivex/lib/src/streams/using.dart | 107 ++ .../lib/src/streams/value_stream.dart | 97 ++ core/reactivex/lib/src/streams/zip.dart | 388 +++++ .../lib/src/subjects/behavior_subject.dart | 275 +++ .../lib/src/subjects/publish_subject.dart | 51 + .../lib/src/subjects/replay_subject.dart | 204 +++ core/reactivex/lib/src/subjects/subject.dart | 231 +++ .../backpressure/backpressure.dart | 357 ++++ .../src/transformers/backpressure/buffer.dart | 149 ++ .../transformers/backpressure/debounce.dart | 85 + .../transformers/backpressure/pairwise.dart | 35 + .../src/transformers/backpressure/sample.dart | 50 + .../transformers/backpressure/throttle.dart | 79 + .../src/transformers/backpressure/window.dart | 158 ++ .../src/transformers/default_if_empty.dart | 60 + .../reactivex/lib/src/transformers/delay.dart | 98 ++ .../lib/src/transformers/delay_when.dart | 171 ++ .../lib/src/transformers/dematerialize.dart | 81 + .../lib/src/transformers/distinct_unique.dart | 92 + core/reactivex/lib/src/transformers/do.dart | 305 ++++ .../lib/src/transformers/end_with.dart | 52 + .../lib/src/transformers/end_with_many.dart | 52 + .../lib/src/transformers/exhaust_map.dart | 113 ++ .../lib/src/transformers/flat_map.dart | 148 ++ .../lib/src/transformers/group_by.dart | 157 ++ .../lib/src/transformers/ignore_elements.dart | 61 + .../lib/src/transformers/interval.dart | 91 + .../lib/src/transformers/map_not_null.dart | 75 + .../lib/src/transformers/map_to.dart | 52 + .../lib/src/transformers/materialize.dart | 68 + core/reactivex/lib/src/transformers/max.dart | 25 + core/reactivex/lib/src/transformers/min.dart | 27 + .../lib/src/transformers/on_error_resume.dart | 181 ++ core/reactivex/lib/src/transformers/scan.dart | 62 + .../lib/src/transformers/skip_last.dart | 79 + .../lib/src/transformers/skip_until.dart | 82 + .../lib/src/transformers/start_with.dart | 65 + .../src/transformers/start_with_error.dart | 57 + .../lib/src/transformers/start_with_many.dart | 66 + .../lib/src/transformers/switch_if_empty.dart | 119 ++ .../lib/src/transformers/switch_map.dart | 155 ++ .../lib/src/transformers/take_last.dart | 83 + .../lib/src/transformers/take_until.dart | 80 + .../transformers/take_while_inclusive.dart | 74 + .../lib/src/transformers/time_interval.dart | 111 ++ .../lib/src/transformers/timestamp.dart | 87 + .../lib/src/transformers/where_not_null.dart | 65 + .../lib/src/transformers/where_type.dart | 71 + .../src/transformers/with_latest_from.dart | 738 +++++++++ .../lib/src/utils/collection_extensions.dart | 65 + .../lib/src/utils/composite_subscription.dart | 121 ++ core/reactivex/lib/src/utils/empty.dart | 18 + .../lib/src/utils/error_and_stacktrace.dart | 28 + .../lib/src/utils/forwarding_sink.dart | 82 + .../lib/src/utils/forwarding_stream.dart | 165 ++ core/reactivex/lib/src/utils/future.dart | 25 + core/reactivex/lib/src/utils/min_max.dart | 76 + .../reactivex/lib/src/utils/notification.dart | 169 ++ .../reactivex/lib/src/utils/subscription.dart | 34 + core/reactivex/lib/streams.dart | 23 + core/reactivex/lib/subjects.dart | 6 + core/reactivex/lib/transformers.dart | 42 + core/reactivex/lib/utils.dart | 5 + core/reactivex/pubspec.yaml | 25 + core/reactivex/screenshots/logo.png | Bin 0 -> 54748 bytes core/reactivex/test/rxdart_test.dart | 187 +++ .../test/streams/combine_latest_test.dart | 394 +++++ .../test/streams/concat_eager_test.dart | 185 +++ core/reactivex/test/streams/concat_test.dart | 136 ++ core/reactivex/test/streams/defer_test.dart | 128 ++ .../test/streams/fork_join_test.dart | 452 +++++ .../test/streams/from_callable_test.dart | 130 ++ core/reactivex/test/streams/merge_test.dart | 92 + core/reactivex/test/streams/never_test.dart | 67 + .../publish_connectable_stream_test.dart | 164 ++ core/reactivex/test/streams/race_test.dart | 136 ++ core/reactivex/test/streams/range_test.dart | 52 + core/reactivex/test/streams/repeat_test.dart | 99 ++ .../replay_connectable_stream_test.dart | 229 +++ core/reactivex/test/streams/retry_test.dart | 142 ++ .../test/streams/retry_when_test.dart | 224 +++ .../test/streams/sequence_equals_test.dart | 112 ++ .../test/streams/switch_latest_test.dart | 88 + core/reactivex/test/streams/timer_test.dart | 140 ++ core/reactivex/test/streams/using_test.dart | 378 +++++ .../value_connectable_stream_test.dart | 295 ++++ core/reactivex/test/streams/zip_test.dart | 395 +++++ .../test/subject/behavior_subject_test.dart | 1475 +++++++++++++++++ .../test/subject/publish_subject_test.dart | 323 ++++ .../test/subject/replay_subject_test.dart | 478 ++++++ .../backpressure/buffer_count_test.dart | 125 ++ .../backpressure/buffer_test.dart | 118 ++ .../backpressure/buffer_test_test.dart | 67 + .../backpressure/buffer_time_test.dart | 96 ++ .../backpressure/debounce_test.dart | 145 ++ .../backpressure/debounce_time_test.dart | 126 ++ .../backpressure/pairwise_test.dart | 78 + .../backpressure/sample_test.dart | 109 ++ .../backpressure/sample_time_test.dart | 99 ++ .../backpressure/throttle_test.dart | 160 ++ .../backpressure/throttle_time_test.dart | 102 ++ .../backpressure/window_count_test.dart | 125 ++ .../backpressure/window_test.dart | 123 ++ .../backpressure/window_test_test.dart | 67 + .../backpressure/window_time_test.dart | 99 ++ .../test/transformers/concat_with_test.dart | 39 + .../transformers/default_if_empty_test.dart | 88 + .../test/transformers/delay_test.dart | 127 ++ .../test/transformers/delay_when_test.dart | 280 ++++ .../test/transformers/dematerialize_test.dart | 105 ++ .../test/transformers/distinct_test.dart | 25 + .../transformers/distinct_unique_test.dart | 157 ++ core/reactivex/test/transformers/do_test.dart | 489 ++++++ .../test/transformers/end_with_many_test.dart | 78 + .../test/transformers/end_with_test.dart | 76 + .../test/transformers/exhaust_map_test.dart | 110 ++ .../transformers/flat_map_iterable_test.dart | 35 + .../test/transformers/flat_map_test.dart | 267 +++ .../test/transformers/group_by_test.dart | 312 ++++ .../transformers/ignore_elements_test.dart | 126 ++ .../test/transformers/interval_test.dart | 87 + .../test/transformers/join_test.dart | 11 + .../test/transformers/map_not_null_test.dart | 100 ++ .../test/transformers/map_to_test.dart | 67 + .../test/transformers/materialize_test.dart | 99 ++ .../reactivex/test/transformers/max_test.dart | 124 ++ .../test/transformers/merge_with_test.dart | 62 + .../reactivex/test/transformers/min_test.dart | 117 ++ .../transformers/on_error_resume_test.dart | 209 +++ .../transformers/on_error_return_test.dart | 80 + .../on_error_return_with_test.dart | 82 + .../test/transformers/scan_test.dart | 85 + .../test/transformers/skip_last_test.dart | 110 ++ .../test/transformers/skip_until_test.dart | 130 ++ .../transformers/start_with_error_test.dart | 86 + .../transformers/start_with_many_test.dart | 87 + .../test/transformers/start_with_test.dart | 102 ++ .../transformers/switch_if_empty_test.dart | 96 ++ .../test/transformers/switch_map_test.dart | 359 ++++ .../test/transformers/take_last_test.dart | 109 ++ .../test/transformers/take_until_test.dart | 120 ++ .../take_while_inclusive_test.dart | 92 + .../test/transformers/time_interval_test.dart | 112 ++ .../test/transformers/timeout_test.dart | 19 + .../test/transformers/timestamp_test.dart | 105 ++ .../transformers/where_not_null_test.dart | 87 + .../test/transformers/where_type_test.dart | 94 ++ .../transformers/with_latest_from_test.dart | 541 ++++++ .../test/transformers/zip_with_test.dart | 63 + core/reactivex/test/utils.dart | 25 + .../utils/composite_subscription_test.dart | 316 ++++ .../test/utils/notification_test.dart | 234 +++ 319 files changed, 30982 insertions(+) rename core/container/{angel_container => container}/.gitignore (100%) rename core/container/{angel_container => container}/AUTHORS.md (100%) rename core/container/{angel_container => container}/CHANGELOG.md (100%) rename core/container/{angel_container => container}/LICENSE (100%) rename core/container/{angel_container => container}/README.md (100%) rename core/container/{angel_container => container}/analysis_options.yaml (100%) rename core/container/{angel_container => container}/example/main.dart (100%) rename core/container/{angel_container => container}/example/throwing.dart (100%) rename core/container/{angel_container => container}/lib/angel3_container.dart (100%) rename core/container/{angel_container => container}/lib/mirrors.dart (100%) rename core/container/{angel_container => container}/lib/src/container.dart (100%) rename core/container/{angel_container => container}/lib/src/container_const.dart (100%) rename core/container/{angel_container => container}/lib/src/empty/empty.dart (100%) rename core/container/{angel_container => container}/lib/src/exception.dart (100%) rename core/container/{angel_container => container}/lib/src/mirrors/mirrors.dart (100%) rename core/container/{angel_container => container}/lib/src/mirrors/reflector.dart (100%) rename core/container/{angel_container => container}/lib/src/reflectable/reflectable.dart (100%) rename core/container/{angel_container => container}/lib/src/reflector.dart (100%) rename core/container/{angel_container => container}/lib/src/static/static.dart (100%) rename core/container/{angel_container => container}/lib/src/throwing.dart (100%) rename core/container/{angel_container => container}/pubspec.yaml (100%) rename core/container/{angel_container => container}/test/common.dart (100%) rename core/container/{angel_container => container}/test/empty_reflector_test.dart (100%) rename core/container/{angel_container => container}/test/has_test.dart (100%) rename core/container/{angel_container => container}/test/lazy_test.dart (100%) rename core/container/{angel_container => container}/test/mirrors_test.dart (100%) rename core/container/{angel_container => container}/test/named_test.dart (100%) rename core/container/{angel_container => container}/test/throwing_reflector_test.dart (100%) rename core/container/{angel_container_generator => container_generator}/.gitignore (100%) rename core/container/{angel_container_generator => container_generator}/CHANGELOG.md (100%) rename core/container/{angel_container_generator => container_generator}/LICENSE (100%) rename core/container/{angel_container_generator => container_generator}/README.md (100%) rename core/container/{angel_container_generator => container_generator}/analysis_options.yaml (100%) rename core/container/{angel_container_generator => container_generator}/example/main.dart (100%) rename core/container/{angel_container_generator => container_generator}/example/main.reflectable.dart (100%) rename core/container/{angel_container_generator => container_generator}/lib/angel3_container_generator.dart (100%) rename core/container/{angel_container_generator => container_generator}/pubspec.yaml (100%) rename core/container/{angel_container_generator => container_generator}/test/reflector_test.dart (100%) rename core/container/{angel_container_generator => container_generator}/test/reflector_test.reflectable.dart (100%) create mode 160000 core/eventbus rename core/{http_exception => exceptions}/.gitignore (100%) rename core/{http_exception => exceptions}/AUTHORS.md (100%) rename core/{http_exception => exceptions}/CHANGELOG.md (100%) rename core/{http_exception => exceptions}/LICENSE (100%) rename core/{http_exception => exceptions}/README.md (100%) rename core/{http_exception => exceptions}/analysis_options.yaml (100%) rename core/{http_exception => exceptions}/example/main.dart (100%) rename core/{http_exception => exceptions}/lib/angel3_http_exception.dart (100%) rename core/{http_exception => exceptions}/pubspec.yaml (100%) rename core/{mock_request => mocking}/.gitignore (100%) rename core/{mock_request => mocking}/AUTHORS.md (100%) rename core/{mock_request => mocking}/CHANGELOG.md (100%) rename core/{mock_request => mocking}/LICENSE (100%) rename core/{mock_request => mocking}/README.md (100%) rename core/{mock_request => mocking}/analysis_options.yaml (100%) rename core/{mock_request => mocking}/example/main.dart (100%) rename core/{mock_request => mocking}/lib/angel3_mock_request.dart (100%) rename core/{mock_request => mocking}/lib/src/connection_info.dart (100%) rename core/{mock_request => mocking}/lib/src/headers.dart (100%) rename core/{mock_request => mocking}/lib/src/lockable_headers.dart (100%) rename core/{mock_request => mocking}/lib/src/request.dart (100%) rename core/{mock_request => mocking}/lib/src/response.dart (100%) rename core/{mock_request => mocking}/lib/src/session.dart (100%) rename core/{mock_request => mocking}/pubspec.yaml (100%) rename core/{mock_request => mocking}/test/all_test.dart (100%) create mode 100644 core/mqueue/.github/workflows/action.yaml create mode 100644 core/mqueue/.gitignore create mode 100644 core/mqueue/CHANGELOG.md create mode 100644 core/mqueue/LICENSE create mode 100644 core/mqueue/README.md create mode 100644 core/mqueue/analysis_options.yaml create mode 100644 core/mqueue/assets/components-mq.png create mode 100644 core/mqueue/assets/components.png create mode 100644 core/mqueue/assets/default-exchange.png create mode 100644 core/mqueue/assets/detailed-view.png create mode 100644 core/mqueue/assets/direct-exchange.png create mode 100644 core/mqueue/assets/fanout-exchange.png create mode 100644 core/mqueue/assets/simple-view.png create mode 100644 core/mqueue/example/main.dart create mode 100644 core/mqueue/example/message_filtering/main.dart create mode 100644 core/mqueue/example/message_filtering/task_manager.dart create mode 100644 core/mqueue/example/message_filtering/worker_one.dart create mode 100644 core/mqueue/example/message_filtering/worker_two.dart create mode 100644 core/mqueue/example/receiver.dart create mode 100644 core/mqueue/example/routing/debug_logger.dart create mode 100644 core/mqueue/example/routing/logger.dart create mode 100644 core/mqueue/example/routing/main.dart create mode 100644 core/mqueue/example/routing/production_logger.dart create mode 100644 core/mqueue/example/rpc/main.dart create mode 100644 core/mqueue/example/rpc/service_one.dart create mode 100644 core/mqueue/example/rpc/service_two.dart create mode 100644 core/mqueue/example/sender.dart create mode 100644 core/mqueue/lib/mq.dart create mode 100644 core/mqueue/lib/src/binding/binding.dart create mode 100644 core/mqueue/lib/src/binding/binding.interface.dart create mode 100644 core/mqueue/lib/src/consumer/consumer.dart create mode 100644 core/mqueue/lib/src/consumer/consumer.interface.dart create mode 100644 core/mqueue/lib/src/consumer/consumer.mixin.dart create mode 100644 core/mqueue/lib/src/core/constants/enums.dart create mode 100644 core/mqueue/lib/src/core/constants/error_strings.dart create mode 100644 core/mqueue/lib/src/core/exceptions/binding_exceptions.dart create mode 100644 core/mqueue/lib/src/core/exceptions/consumer_exceptions.dart create mode 100644 core/mqueue/lib/src/core/exceptions/exceptions.dart create mode 100644 core/mqueue/lib/src/core/exceptions/exchange_exceptions.dart create mode 100644 core/mqueue/lib/src/core/exceptions/mq_client_exceptions.dart create mode 100644 core/mqueue/lib/src/core/exceptions/queue_exceptions.dart create mode 100644 core/mqueue/lib/src/core/exceptions/registrar_exceptions.dart create mode 100644 core/mqueue/lib/src/core/exceptions/routing_key_exceptions.dart create mode 100644 core/mqueue/lib/src/core/registrar/simple_registrar.dart create mode 100644 core/mqueue/lib/src/exchange/default_exchange.dart create mode 100644 core/mqueue/lib/src/exchange/direct_exchange.dart create mode 100644 core/mqueue/lib/src/exchange/exchange.base.dart create mode 100644 core/mqueue/lib/src/exchange/exchange_interface.dart create mode 100644 core/mqueue/lib/src/exchange/fanout_exchange.dart create mode 100644 core/mqueue/lib/src/message/message.base.dart create mode 100644 core/mqueue/lib/src/message/message.dart create mode 100644 core/mqueue/lib/src/mq/mq.base.dart create mode 100644 core/mqueue/lib/src/mq/mq.dart create mode 100644 core/mqueue/lib/src/mq/mq.interface.dart create mode 100644 core/mqueue/lib/src/producer/producer.dart create mode 100644 core/mqueue/lib/src/producer/producer.interface.dart create mode 100644 core/mqueue/lib/src/producer/producer.mixin.dart create mode 100644 core/mqueue/lib/src/queue/data_stream.base.dart create mode 100644 core/mqueue/lib/src/queue/queue.dart create mode 100644 core/mqueue/pubspec.yaml create mode 100644 core/mqueue/test/binding/binding_test.dart create mode 100644 core/mqueue/test/consumer/consumer_test.dart create mode 100644 core/mqueue/test/core/exceptions/binding_exceptions_test.dart create mode 100644 core/mqueue/test/core/exceptions/consumer_exceptions_test.dart create mode 100644 core/mqueue/test/core/exceptions/exchange_exceptions_test.dart create mode 100644 core/mqueue/test/core/exceptions/mq_client_exceptions_test.dart create mode 100644 core/mqueue/test/core/exceptions/queue_exceptions_test.dart create mode 100644 core/mqueue/test/core/exceptions/registrar_exceptions_test.dart create mode 100644 core/mqueue/test/core/exceptions/routing_key_exceptionss_test.dart create mode 100644 core/mqueue/test/core/registrar/simple_registrar_test.dart create mode 100644 core/mqueue/test/exchange/default_exchange_test.dart create mode 100644 core/mqueue/test/exchange/direct_exchange_test.dart create mode 100644 core/mqueue/test/exchange/fanout_exchange_test.dart create mode 100644 core/mqueue/test/message/message.base_test.dart create mode 100644 core/mqueue/test/mq/mq_test.dart create mode 100644 core/mqueue/test/producer/producer_test.dart create mode 100644 core/mqueue/test/queue/queue_test.dart create mode 100644 core/reactivex/.gitignore create mode 100644 core/reactivex/CHANGELOG.md create mode 100644 core/reactivex/LICENSE create mode 100644 core/reactivex/README.md create mode 100644 core/reactivex/analysis_options.yaml create mode 100644 core/reactivex/lib/angel3_reactivex.dart create mode 100644 core/reactivex/lib/src/rx.dart create mode 100644 core/reactivex/lib/src/streams/combine_latest.dart create mode 100644 core/reactivex/lib/src/streams/concat.dart create mode 100644 core/reactivex/lib/src/streams/concat_eager.dart create mode 100644 core/reactivex/lib/src/streams/connectable_stream.dart create mode 100644 core/reactivex/lib/src/streams/defer.dart create mode 100644 core/reactivex/lib/src/streams/fork_join.dart create mode 100644 core/reactivex/lib/src/streams/from_callable.dart create mode 100644 core/reactivex/lib/src/streams/merge.dart create mode 100644 core/reactivex/lib/src/streams/never.dart create mode 100644 core/reactivex/lib/src/streams/race.dart create mode 100644 core/reactivex/lib/src/streams/range.dart create mode 100644 core/reactivex/lib/src/streams/repeat.dart create mode 100644 core/reactivex/lib/src/streams/replay_stream.dart create mode 100644 core/reactivex/lib/src/streams/retry.dart create mode 100644 core/reactivex/lib/src/streams/retry_when.dart create mode 100644 core/reactivex/lib/src/streams/sequence_equal.dart create mode 100644 core/reactivex/lib/src/streams/switch_latest.dart create mode 100644 core/reactivex/lib/src/streams/timer.dart create mode 100644 core/reactivex/lib/src/streams/using.dart create mode 100644 core/reactivex/lib/src/streams/value_stream.dart create mode 100644 core/reactivex/lib/src/streams/zip.dart create mode 100644 core/reactivex/lib/src/subjects/behavior_subject.dart create mode 100644 core/reactivex/lib/src/subjects/publish_subject.dart create mode 100644 core/reactivex/lib/src/subjects/replay_subject.dart create mode 100644 core/reactivex/lib/src/subjects/subject.dart create mode 100644 core/reactivex/lib/src/transformers/backpressure/backpressure.dart create mode 100644 core/reactivex/lib/src/transformers/backpressure/buffer.dart create mode 100644 core/reactivex/lib/src/transformers/backpressure/debounce.dart create mode 100644 core/reactivex/lib/src/transformers/backpressure/pairwise.dart create mode 100644 core/reactivex/lib/src/transformers/backpressure/sample.dart create mode 100644 core/reactivex/lib/src/transformers/backpressure/throttle.dart create mode 100644 core/reactivex/lib/src/transformers/backpressure/window.dart create mode 100644 core/reactivex/lib/src/transformers/default_if_empty.dart create mode 100644 core/reactivex/lib/src/transformers/delay.dart create mode 100644 core/reactivex/lib/src/transformers/delay_when.dart create mode 100644 core/reactivex/lib/src/transformers/dematerialize.dart create mode 100644 core/reactivex/lib/src/transformers/distinct_unique.dart create mode 100644 core/reactivex/lib/src/transformers/do.dart create mode 100644 core/reactivex/lib/src/transformers/end_with.dart create mode 100644 core/reactivex/lib/src/transformers/end_with_many.dart create mode 100644 core/reactivex/lib/src/transformers/exhaust_map.dart create mode 100644 core/reactivex/lib/src/transformers/flat_map.dart create mode 100644 core/reactivex/lib/src/transformers/group_by.dart create mode 100644 core/reactivex/lib/src/transformers/ignore_elements.dart create mode 100644 core/reactivex/lib/src/transformers/interval.dart create mode 100644 core/reactivex/lib/src/transformers/map_not_null.dart create mode 100644 core/reactivex/lib/src/transformers/map_to.dart create mode 100644 core/reactivex/lib/src/transformers/materialize.dart create mode 100644 core/reactivex/lib/src/transformers/max.dart create mode 100644 core/reactivex/lib/src/transformers/min.dart create mode 100644 core/reactivex/lib/src/transformers/on_error_resume.dart create mode 100644 core/reactivex/lib/src/transformers/scan.dart create mode 100644 core/reactivex/lib/src/transformers/skip_last.dart create mode 100644 core/reactivex/lib/src/transformers/skip_until.dart create mode 100644 core/reactivex/lib/src/transformers/start_with.dart create mode 100644 core/reactivex/lib/src/transformers/start_with_error.dart create mode 100644 core/reactivex/lib/src/transformers/start_with_many.dart create mode 100644 core/reactivex/lib/src/transformers/switch_if_empty.dart create mode 100644 core/reactivex/lib/src/transformers/switch_map.dart create mode 100644 core/reactivex/lib/src/transformers/take_last.dart create mode 100644 core/reactivex/lib/src/transformers/take_until.dart create mode 100644 core/reactivex/lib/src/transformers/take_while_inclusive.dart create mode 100644 core/reactivex/lib/src/transformers/time_interval.dart create mode 100644 core/reactivex/lib/src/transformers/timestamp.dart create mode 100644 core/reactivex/lib/src/transformers/where_not_null.dart create mode 100644 core/reactivex/lib/src/transformers/where_type.dart create mode 100644 core/reactivex/lib/src/transformers/with_latest_from.dart create mode 100644 core/reactivex/lib/src/utils/collection_extensions.dart create mode 100644 core/reactivex/lib/src/utils/composite_subscription.dart create mode 100644 core/reactivex/lib/src/utils/empty.dart create mode 100644 core/reactivex/lib/src/utils/error_and_stacktrace.dart create mode 100644 core/reactivex/lib/src/utils/forwarding_sink.dart create mode 100644 core/reactivex/lib/src/utils/forwarding_stream.dart create mode 100644 core/reactivex/lib/src/utils/future.dart create mode 100644 core/reactivex/lib/src/utils/min_max.dart create mode 100644 core/reactivex/lib/src/utils/notification.dart create mode 100644 core/reactivex/lib/src/utils/subscription.dart create mode 100644 core/reactivex/lib/streams.dart create mode 100644 core/reactivex/lib/subjects.dart create mode 100644 core/reactivex/lib/transformers.dart create mode 100644 core/reactivex/lib/utils.dart create mode 100644 core/reactivex/pubspec.yaml create mode 100644 core/reactivex/screenshots/logo.png create mode 100644 core/reactivex/test/rxdart_test.dart create mode 100644 core/reactivex/test/streams/combine_latest_test.dart create mode 100644 core/reactivex/test/streams/concat_eager_test.dart create mode 100644 core/reactivex/test/streams/concat_test.dart create mode 100644 core/reactivex/test/streams/defer_test.dart create mode 100644 core/reactivex/test/streams/fork_join_test.dart create mode 100644 core/reactivex/test/streams/from_callable_test.dart create mode 100644 core/reactivex/test/streams/merge_test.dart create mode 100644 core/reactivex/test/streams/never_test.dart create mode 100644 core/reactivex/test/streams/publish_connectable_stream_test.dart create mode 100644 core/reactivex/test/streams/race_test.dart create mode 100644 core/reactivex/test/streams/range_test.dart create mode 100644 core/reactivex/test/streams/repeat_test.dart create mode 100644 core/reactivex/test/streams/replay_connectable_stream_test.dart create mode 100644 core/reactivex/test/streams/retry_test.dart create mode 100644 core/reactivex/test/streams/retry_when_test.dart create mode 100644 core/reactivex/test/streams/sequence_equals_test.dart create mode 100644 core/reactivex/test/streams/switch_latest_test.dart create mode 100644 core/reactivex/test/streams/timer_test.dart create mode 100644 core/reactivex/test/streams/using_test.dart create mode 100644 core/reactivex/test/streams/value_connectable_stream_test.dart create mode 100644 core/reactivex/test/streams/zip_test.dart create mode 100644 core/reactivex/test/subject/behavior_subject_test.dart create mode 100644 core/reactivex/test/subject/publish_subject_test.dart create mode 100644 core/reactivex/test/subject/replay_subject_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/buffer_count_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/buffer_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/buffer_test_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/buffer_time_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/debounce_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/debounce_time_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/pairwise_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/sample_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/sample_time_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/throttle_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/throttle_time_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/window_count_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/window_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/window_test_test.dart create mode 100644 core/reactivex/test/transformers/backpressure/window_time_test.dart create mode 100644 core/reactivex/test/transformers/concat_with_test.dart create mode 100644 core/reactivex/test/transformers/default_if_empty_test.dart create mode 100644 core/reactivex/test/transformers/delay_test.dart create mode 100644 core/reactivex/test/transformers/delay_when_test.dart create mode 100644 core/reactivex/test/transformers/dematerialize_test.dart create mode 100644 core/reactivex/test/transformers/distinct_test.dart create mode 100644 core/reactivex/test/transformers/distinct_unique_test.dart create mode 100644 core/reactivex/test/transformers/do_test.dart create mode 100644 core/reactivex/test/transformers/end_with_many_test.dart create mode 100644 core/reactivex/test/transformers/end_with_test.dart create mode 100644 core/reactivex/test/transformers/exhaust_map_test.dart create mode 100644 core/reactivex/test/transformers/flat_map_iterable_test.dart create mode 100644 core/reactivex/test/transformers/flat_map_test.dart create mode 100644 core/reactivex/test/transformers/group_by_test.dart create mode 100644 core/reactivex/test/transformers/ignore_elements_test.dart create mode 100644 core/reactivex/test/transformers/interval_test.dart create mode 100644 core/reactivex/test/transformers/join_test.dart create mode 100644 core/reactivex/test/transformers/map_not_null_test.dart create mode 100644 core/reactivex/test/transformers/map_to_test.dart create mode 100644 core/reactivex/test/transformers/materialize_test.dart create mode 100644 core/reactivex/test/transformers/max_test.dart create mode 100644 core/reactivex/test/transformers/merge_with_test.dart create mode 100644 core/reactivex/test/transformers/min_test.dart create mode 100644 core/reactivex/test/transformers/on_error_resume_test.dart create mode 100644 core/reactivex/test/transformers/on_error_return_test.dart create mode 100644 core/reactivex/test/transformers/on_error_return_with_test.dart create mode 100644 core/reactivex/test/transformers/scan_test.dart create mode 100644 core/reactivex/test/transformers/skip_last_test.dart create mode 100644 core/reactivex/test/transformers/skip_until_test.dart create mode 100644 core/reactivex/test/transformers/start_with_error_test.dart create mode 100644 core/reactivex/test/transformers/start_with_many_test.dart create mode 100644 core/reactivex/test/transformers/start_with_test.dart create mode 100644 core/reactivex/test/transformers/switch_if_empty_test.dart create mode 100644 core/reactivex/test/transformers/switch_map_test.dart create mode 100644 core/reactivex/test/transformers/take_last_test.dart create mode 100644 core/reactivex/test/transformers/take_until_test.dart create mode 100644 core/reactivex/test/transformers/take_while_inclusive_test.dart create mode 100644 core/reactivex/test/transformers/time_interval_test.dart create mode 100644 core/reactivex/test/transformers/timeout_test.dart create mode 100644 core/reactivex/test/transformers/timestamp_test.dart create mode 100644 core/reactivex/test/transformers/where_not_null_test.dart create mode 100644 core/reactivex/test/transformers/where_type_test.dart create mode 100644 core/reactivex/test/transformers/with_latest_from_test.dart create mode 100644 core/reactivex/test/transformers/zip_with_test.dart create mode 100644 core/reactivex/test/utils.dart create mode 100644 core/reactivex/test/utils/composite_subscription_test.dart create mode 100644 core/reactivex/test/utils/notification_test.dart 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 0000000000000000000000000000000000000000..c0bf8458db1eca272316fec07d39592eaae9ea4d GIT binary patch literal 94259 zcmeEuX*iVs-?p_Z*_Z5vtdV^i`@S0)Tgnn)C>4h6+4nX3z71n3RD&NOyO3>=kiEf( z#y*zlxu)O$zF&1e$Nl1Y`5ca>Ld|tupY1%)&-oQ+YNAU;!A3zqKtQFhr)5S!aHWla zfH|1A6}bZa&v)$14dVxkmdKR zw$cLY=O4l`7Wjf={7Z$TB_m;m(SX6o$Q8?(FT@=JCllo4PO&w4BaVZQ!bMKLe5qPg zUk*?l4ID8N<+6{z>U!EWleRJBbXfn9@7KMBFM6t*q%tlFp*I->UU2sN7yEUVRm9RL zuv4k56u9kbFTBr)XIP>&v@sc>w~9p@K^CB^W}dp|b79YxYV(KPP$2=DdN-*^>3V7V zNzg{k z-3h;y9q+Z{dmINZgPWPv-!Uu82$=qXV&$2_=p0>IcyT1hJ}`(aIC$A{kU#6X(>SxO zEcN4F;8?+hZSmOU#c@$?qss`c<+aS%`yy9uI!d~mL|UO_w1hlrB(Vhl|1bZ~o`J|1 z*=Oib&zp~zXPocFq+C9KRr%cx!=U7UkN*B&J#eTZ(dt$G&#tugpU|h+Tq#$6%IkNw z6^e`3Udj#f<-Y*f$~m-y0KzvN<$5>$`b*dGXmI_vm@O66o$n z#C5;_UtLlIT`2LjQ85k#@$2h_Y4UCFpm%hJj`;j>!)_>0h0e$2bFA6GEUIFnKqfi9b>{nw<%O7s!PGtJ8%M}Ft^2s5 zf+kkO`~!F~C&z)Pe?H3O-C5cM$I`LsQm^@(`O4LDIZ{8K0Cl=)%_N?kwuMDD8UMrSggWw&S~B zbOKrb9+J^^dF#?%qE|E96UMzw&EAl?;3vv}{5DG}V75B{0^%p-a`2QPc&JctUuMF~ z_hC5qPTzi*gvG)v-&p-LKck}wVtB%S$#HV9xxKAJ8*xkZ=fTGcSeeo(t#U2p9D7?m zCKaJX-k;YxRWhkWidrKbp9;+LSWl2Jf5bR8XW5RBUxyS4f*HiXgole-V>psAEpmuA zSE)kQ_Whv)%&TNS0!H z`_}RfAu!tx+QYO68Z+X@J?P)P@v#~Q#6~eCwd3e$as|Hi!Ns7!an*1Twt=#q!`xmK z%>USRh5eSE&nEOe_6&U#TGK<>N&J=2p(1^xhTFGNgQVPzRmHm;acDIn;Ve8GtyuZF z%e3!A6LbVpy7EaK18I6?A$^u7W?6{hWP8qyu2$?SQKDK-e}@oRoXKl|U`ZcnS3#?8 z7V04m(ZvVt@7Ug-fDE&}f`p@M4p}KD6%4%J$9)OxG$*4S*7de{7bk?dwS1O!w{63$ zD5zR`ry$kR@zk`4ky$^2hkMccJ{-6D>s0Fg10tiKcqPTTCsE2}+Azt1Hq(Rl-k6{M z3kAjBoAWrEw1KcSIt8(j7P=2Y);kS;n8eYnGkjQ7z}=sD!}fdi%fLEM*5!2s3x|^g zhrQfV(EE7hHyc+(PfFK!iN<>hY%Mr#6+&7xreMBNc@2iePKWHleJ_`Y`aG0Aei_aa zZR1=DDeIU3UuzKdcx9q&!pLN#kGNBrAwX8=D#4#U6*v_bC!mPFeff^b@Qm_-e9E{! zTEJ_}pJXGj#zcP<*XqsCr~u1f4|K5)9-y1rIG=>I8i#32ui$fXC8&PoKzV3VIz@JY zYk~(v>TT5c&~A|jWPErIN!Zl#cOOtHV}hNHJf!5t?U}IuzCHU3c_fyNt>#Moz3@t* zeH~mj`B>*VX^NPp8B2gi9`hEdyj84Ci!E`7bv1>J3*5|u;k70RQ)kLk7_Jat;a?2n zDb%{?+rKL#=o#dmT^fE6>mv5NROxO}Z%JiKXVd{1>tPkH3KvP$W)Q0YikNC}_`T83zBR?&K_vQ`7%(|L6&aAz_w;+IrG+hkj~g(HbtZ>csoaW4WQ~f-%+*Khyrw9&z8N4VNHxxt zHfrb;#`B3sdX!f_Wj7Oo|Lv1s>;c&*Z_sp{@;dG-ZaE^%$?zC!u+2_2IOJ--c(!Cm zEhhhgg5V&2i>M|CIr!09B|T48-G@}@`8DT~%t|Mu7(Gg;3`LUDF^?Q6S&QA*H;_r3!uo`6iN@sjwDj{wfxJJ zFe67o;0%Ew26XibwxvMVXpB4mX$nvz0v0iCovgz#STGe>N{Jfuu()0%>S3|*-Q&YN znvUE^$({{vLdJb{8;>ga5Bp@-oizgttVMZkt1Q?`?OuBsv1ZLBGZ_F+<`L%0kgDdN zsB~4~V1*lS2v%4_Ta$Eyj;EfZSYE|~EA=%6H_SX%cETeW%u$RF+f?8(aWTDyZWNSZ zOb`F0RZ14i7m072Wc=yvemYyqTgtm#`;|BfGme0Bk5G$A!yPiQP+20^>pY(H_OzNp zz2CxaT{u|_W4;ep68D(>kiFA32FoYJS%+(O-0}Dv0Ed8D;z`rB_*uBcsV4Xb1k&Rk zhcx>Y!)oHn?};7`vmMzfCf>l`z*ZD!>GX0+DrWt}JSOfnh@%*m6c?I(99u-z+w0ge zq>4{A$;#5;l;ifHv!1$6+&8xik$+5!`USzWvXBFj%K}B2cI8YTztgjO^DBJfaPF2X z9E)7tPr}ohYMG*%h& z&%DGBXOH!G9SC<()cK?Iu=*U_>w`krmhBOIQ|(q^B)1T_Ci+<0VRUmDYU)5@s^4m(06tA}Wi{~54xL^6t=cD`N~oOsuXBjV zqcx?n(*rV;TY%?`Riu5pfkU8{NMX5>=*IqGuYd|&2jw_O>@7#W=Tb0^?=%d(^ZRoR zjkFBsaTdrWvGpJk+t?9LEcA>a{7dz8ti@mfQrtL;x)Kf z;6x5wg_b`mp%>qT1i=}IAqE#+(&5>tPhq{D_bN0CvT=8wSCiOv^}!)q8HuD^qr(bf zVyUn8WAKIbJi+A=`v$roo6^uTC@*xcTCnlqDP2{tm8GxWV25pslW?$2WS?{AbjX=$ zvJl2J(d408ClGyOaq02~Go#F_b*Fv+DbYt1bv3LFgR8_CuA%gw)2dV`35hepGNc*G zg|?JA1yql?fEenJ$%BDMhri7DWT6gK&yK6Qu2%#4nx6-)o{(^@o-?2at+GFz`xPjr zNqcxA*?oK%cXT?8gpb_h@{AN;55Guv-i0jJtjpXTT#@0MuwC7a9~q#H$GG;IgZEmzS_zOPsoGE?-5yzk~m&-QWs3{-@19&mWpy zzt;9TxAw+Xlhc>?^X5U-mM@IZV|OmB0^d?Jf<&>8ODw?Fh1of<*9Xc|B)l(uQ9`zv zMxg13CgsU*$`c`tALl&Girh74-$0$_lvnopo~Fb038ZS>owk-7m$(c$iW$}T3M;KD zC^9_qup@VGwzs>oqV);Q4U6;yNR4!NFSS5vCU#`8-+0|4kIB^ODAU?HKMUV)%~(q( zs$#B$rxR6j6a#ZfXDVABe& z=_FDfSj5}BdmoTJkB2Bc8Pr~ZQf|fPubd29x*Ypi{8)|yeR22-jmo+G6w{QaE;Qwy z*H#~98Q2tVGMf-^HE8|59s;AE&tIvKLg66@rji|hWwP_!D9r@U0IOc#!W1C*{kecf zI!m2WpbJb^eZS5RU$O8-Dg$EQd}CQSq)0x6HVdt!F{ow;lqG3~K;^tSONp+-5^o{V zgxf@!Hs^ac#eW8sX4bJ4oOZ{Tw)^M%H8fm9vrGo#-%lNtoNj7rS53Qd9?l|CO?cye zVot`%e00?X{HqUtx_+@jGOQ`0^{+y<+KaM7sr`l#i5f-HRG9tO5=Adi-^s^B#5zkY zTLw^VHKiJJn)&c_l(j}Xq88Vgp8+7mq{8fUlbsyk1)jdECURqxH={oYKYsvtdN+Crn>` zuZ|4=Adxsdf*v*+DzsJJDlFw(wemLiSIbaJ+$j+SaYYV{|yfd<1k zx{^lK08bYvkM=!$Wm01jvZcK8VmCuz9x)wbY_s((Vt-1C?pEQJc$!5F9-?*9r@zL1 z-<#&x&pKFB5c8x?$^Y%q`gvxw{J8in{N2-s&!_5NJg?t(a z-=T*Vg@nYp)ka`doP!FBUh$M=AX_-|(D@Am?pG#0Ig?eI{&`$TA#i&0TzUZbH)+lq z1jpxKmp;j#3e-)%k}}I$$PaO!V;O4Z8o%GfqO`7Vl!pc z@txR8_TkP{u*-Y9s%dE#`;qhM3lmU9^mprBN@r7wG+FgYA$@=P)neGR_HBuVU6)>(kpyJ>7N)dTK?g7M?xIMSxNZvOHUCgy@SU< zDxcQRID>Gar5k8GH11F`z(ZoLvPFoG(Vnv0nvnfPy<49M@9Xh`HIEIZx(+D zPK2n;l`c|N;`9W-I-F^X`GO$-SC6)*fpFFwOR`^^W^z=48or$BZX3zoGEJ_oIcceD zj%;SKLpM$6yYWCx?&u3#m$FiF+3WZc8C-@TF^anOHb_HT=9LLi-80s{6Rq|Q!wR?Z za3uY3%s!kkjK#5IhNnG7ufUS!az7*-M=YY{-l~*lvNIkC;3>F!NyXyY$yE3$fK`AG zyMHeF=qCv6v={YnmcMDpKDcl@txRvX&1^5syk0I>;zb){r9yWNeK7+wBByd3FLka} zR@{95jJvRv!B=#xO}SFrr4~&#vw--^Vwv74D(*cTP1_2g;D+ z;3KjvKqG+y5(wQkX%5T`Kq;U&4&?Gh96&eM-4wp-HqcgM{=kg5efV`z-T$B{9@3D$ z00E19-ACSBA4ii~np%5?3&`A!?@-ly!7FJQxE|~7~-8=aF z5~mvw;M09}AFc1!mBSi5gnRI8heM<+8|JP%*AiEm{!oubR^=}C^maA#1bM>g{kP9~ zumbBY-Q}Dwg(avu96&eZyJyRzFp-mk_lxjjA*ZeVVDiS-#WKj4|G}JWkQjPb_~2&r zE06gagh}oOyRY#bXkIt_kObct{hGhcss(i!t-ioCQe_=pLA`$~;iC(2IbMl#qy%(6fi=4^h^7mx4DCAC46OT~wHRC5CYs2Dk3c0X$m@oYD{ zcxs&=5dVbEDd04@)B$BXbfRSt@fcn9b+l+1Fwz+9D7VpD`>?Ty+r8sQdHa#a{T416 zEqiA_>OW4kJq5a zMleb!k@#*&QkWP9F7^B__NeW(Pv`xPs5QuS$O!alGPpa3p$0eQ2F3%@(cxgGj;Uv} zK`HyvZw&KQQ~=PGo~KR@GFVPT_xO`cstW)D0ne|2Gg z4E;)fRYF?4#`9rFIaO0-uas^J81#^SO^)I`Rh2%C+o) zs65_Pf|JWemU=Gfuv|wE!8KsMI(S^V|AVuki&4ar1&{coq(FhTw<7(=WABVeSLK z3r}H*p@ItL^{w(hBrkw6?Z0ta=Z9ToIV0wT2f7J6w<6zDCfG38uTFAJIIVFP2C*7! zO+MB7K=(B^0KBn~i|4ah2-0TdTXgMUwfDo$dtQEStQVmo{4@JvS!3|%is<-mqRN^p zHf}V4sA!a*8cGvnaO}v$<3WVhCyN-YSfv!lNGD;GZWN9Eu(`sA61v*Irn*6QCPoN| zKarm$^h81N`y@A&C^1uO+gpC!xt7=0q&MioNjB&LM#;mZu6W-CCg-j$$oK#{d0iU7 zpbP^7$zT>pFy%(zf&y%Ehis(KjDN6BH1ap)# z+a;1C9X^TNi+~wejq^KL{~@5-ET1GY@jw|E3_@$%0LrveE;RnCE(}C#NUP~mbrdJR zM2zc{D3_O8IiHlSEWKtx4qb=HFRcZMtpP9?Q0eaSx!L0XK*0N)t8^nnx;&C;!=;iz zNIz<34ZQ5HC1SgF0#{O5IA5lSyE@2j@3T!U_E`MMVS+{7BWY663@ZifZOabmb8bwA ziH+m9N(ONnY+pBYUs}0kls}vErB=D%3?C|MjwAvIX=gr=U+qK6*}?d_z5>UMem7Ak9?Vo>KEY&f0;a;taD(n{74)gL(SgD(KMa}wmyHwqRsh;Z@`fc=f-0^CcO$2U`T>e-?p7hpijD|^(kF-HHb@zNK_-rp zGbW9vt+xZU75tW`u&pJ|O%LA&=y?C+?w86hj_S!^pqm4tPw!sbTKbm8t&R-}&92R1 z+=JhQLy2sqoLRos_Q^vW`z{&J2bztiHk348aFwd3-$hDz#blS#TG-b_g?X2f$vo%l zAQ_C5n<61zfFxw`0vf#%z=@}v$U}F9@O1X`Nld^qugOtQuF^*ZW6V72aQu}NmnytT zP`I^$7GnIvA_o}=lfky^14`O!N;`Dj*@T>2aRT$rGMv(Q%>Q^V8KPiCOI=}M4TpEMmuTm0vvi0v^WU-E=K&mnqMfE9u_ zlgR+e#9S%B7f|G_(x%cc1c^Pu2|E#{SWm9BJ(>-=0^O6USI9dXXd}g|I?z_-@Pqcj zLahJo771zEZ!1jhJWv)lhm$R+LpJ#{5f5NrC6!c8RH`R%!Gr;zU1!Cq*_)16i9pW- zR4Ih}Gb?!tdS#wP+))Y{ty4BtW34#q0dYuU?&&pU2F7&0$q{}aQ zG0Of=D+`AuUkobFa`auhQ_0yxTo6o&u70d|voI)Ee=DY;M;P|_iSg%a{u@ec>Obka zQ|mx(gXe6f-25HZF?kIeV=ARENeD=0E>&d(7JsnuthHPYyAe6WfMHy&0Br~*gFz;Y zPCTFdBa8NUg9+h_`E3?083MsQ_H_q%)ntVbOT;?j{|6}tvBn?A4rU=fOOVQz1*f=i z1W(6tMllVoq@DOmXDpU0`4rkFHT9TC76v)Hemja#Xw!Iu66fFM?lzOje0?@_{b07y z5|nPDY|vEBoeBrg+E6-?=Dlc&EoD-pYFsxfR4N7;e}@mq<5)#M1NCjq4s-7gUC@el zdwCb)&nkx(5E0YsRmh!7fKAxiSMd&Cbh>g zX&w>)&_hmVK975_pTux7^=WhkK78adEz(!-yvcS)0kOMP*eXUSY>>6zYY|zN;&d8m zt^GAR?&g31cL*i_HV`z%-W57WfJ&iW67m=3JcXE-Sam6~E_o{~MT9U1zw(nvH-Gx$ z{Lf@EXdClyA<+NB`hL-h@1RA~5y?yG;4xl!lC?@h58wXBOlR#KyLkrRq~kZT?8dGS{Q`U-2~l@NR9RmC zX?75#3kx{8;)A+0aK?z;oujB1Uy+I`#bDiZ@NmStn(x!uot zV<4^w@@#}PjNIVWrN>HfZeDP-#wV&`!ptYSMY*so zkdlV64XAUr8PHzRcT;Otz;g**z)l#yWK+>z=WmjDyEbWOmmMG$yO>WC#ErA@elI&Q zxczw4RpqyW7|qXA*E7BwS4Os?175nPDc!}Aiy<9=*ru7ddoygcKaYlu07F*`^suHI zz$?dOqd3@7p>aFnNQGK~P50j2AC=S*faAgUOXb;jNfN?`=BXP=d!MQ7(NEbg>Hk$V zJrDU+q%~{-=tD0Xi)L_a6gaXAjbZ>GaWeQ!>@mRk3T?$NZh4 z3+U}Ztf-4B;|2?`kY8MszZ{$T$E}Jy@bVo`uz4V%Y?65?2gD#JPwa7$EC4=s#DDz} ze1(pRMz!!uH$a-yedyUSvwd+Nzlg?vWH2iL;M;pYCJ#_k%ibsr3vTNG8eTc#;l|3R&I6q1$Qqvj!7nK7z*;ADdE*(o!sIzb3Mn4JsnkZ>ynG?k2#cZ>sw zpN2Aj=;pq;AV+cQ0cT^2$_DxjV8)Am0Bi&o#%xUm+J=-ZP9|*1l+%GIp0G9QV{?P2 z{d_-FP4FKT;dd0q;H$>|^m&tZi%&jAmzJAxgp1=V@@XKm{JV#y# zz^{3UhH4N$+dr$AUpy7MHxl+(<*W?7ogUIXTQSFfrVGM28h}Xi$!hjhfFy}KOF%O` z>bo(3@HHaAt2VGuD{_gfNGJo%A2no|_|H-|3`|~Hu7Qkmh5y<3ngaWvFo1oKaWa(| zEAir$qyOysO`DC3jr$CS&PFBI(n3R8I((xp(j_0#~{QgvO7oJuHkruB(#&X8|BgHiJgMl=*uTg&$ z^dzeu545jl?FxVSKz&qnJ#RAe-@R5rwl|!3%?>Vu@@yXRK{;G{;cj8MmWavpRD32WZj0d2_F!$rg zLKrF`gXANi_&}n8AuTbTNWTu?H*!i(&^SPo1L|*=yXTkzUjU#IlWBFz3(ivjF_>+j zx10bi%}Ph+hzlbvS`PFTyw;Z2r-5h6dvOQ*B!DYIQn$V}dFXm#SJNO)pizw)rJoZ_ zzk@FRdNi7GtnzO0d#ltC?5U|ikL1zt0|rO5As|zT-~)^vX_#_hzXW7@`AYsIlQM*2 zOP#V`?RhKl%tn21*-&_7Vew_>G5bist$t;^smtG96Y0OUaGUS?Z?{a?!z(}0ko}M{ zsy7|c&~zATBN*kz1`LqoJ5khw{sxBGIRL+eGn{lae^-k4MV@5fnSybM#(jliGkoB0 z1R0Kkp)r7ve1S={e=Xs<(j65?QwUx9k(33wMw>&^3ROr0;EMiNp{$e|%@EXfV7R@F z#?Yh~b3Mn6-NMF$`=T#|sMuF_DfHD!ABfzLQK#J5exQja zXt-*(Iu7F{28LKJ+KO?zI{(!{Nl0srgx(cmhyVD({lt@du0T34(zup32Y+)0@3ZHNOjao*e z;XNVd6oF>R(-)VU^qtE_~G}xj9^`EOn!K2(Z4Ds0gl)7iWGzd9|z{ z-ZMaB2VHGzGTFp#gB7)IduJdkINlcLyiHx77}}}hF{z+AKBY7r*7m!phj-8URi~XZ z$_-?-3r7jPg5`S$`7;7mkV%t}Fb!j@+jx}6UrnS?3ZiJh=Rg*v$2cz2z?lWq4!!+F z+H8}tz$?pqb>gSU<1F^5Ma8v^+CZl*!;RDeQ?wFE(-GCxnAo;Cqq*I9z-5`cI7CkM z!u*AAlv!+dkHkq1%s>-H7-a=$%B5@925_slUZCGJFM_&bqW)&0?Ubjh`my`2&^DfO zY@{kJ`HHL&&DX{~ape*s%?Z0Y>~%DOs+D zbbBTzRmr1eGvHCuOZzXfjB$LD=-Yi5`s3d6ZYsZ{v?fR*iT9)v5&0!hCD_UKuhnF% z9(vsVbW36m$C&f#Sa}W*HXP>xXTv8*ejZD1&uyu*XfGo|??wx@XpKj6ExwTzcs*{l z){iq;=#6Z|Ry%2e)$vb+nD1-w-`vifSHqsHDCCs{W2*%By6An}Vs?_cDUdWAcx9oX zYlAL_kP`yT>B2e7V1VOA_^0{4DJL|q5){ER%WHydThXwCS%CtR<;mXc%Tj(8ION4N zzotvt(u>?D1R7{{h9>-oz$fJH7Cz+=Hh9y zK4@Co>z*?Eq_gYR%lGrZy3r|j1}QO;0-pN~_;5dm8Rj&X6H1Z^?({-yo!vI@Q0deA zfz@5p7rtqL$1fw9Cdo7##x^LNWI673#LL_GpsU;FZP3&q*PJ8w-wfd5)Fd`fmVQ8& zndUlX9tDy>pNVX`%qfsYmnlUxW`pyTgc`nE=aK!~l_W`W+sG&gK6SXsQQl+_0Yh2E z!XYWd5_{9nyUW!%+S82$?DHB_{#KP?tz@0dnIF@o3UdQO33eI`e7}mQ5j-(bd=l~g zVVX&+@7LCVcJiZeP}}RU+gc|Ll{kB4#~~IZ{;{h+m!dG7tbKB9QC`fvqYtF2s1Yzn zb4=bT33U$e|JBDmrP0~sDPDL_lqFDjHs`qD^=S{_*r*|g-yP|nlD9aoUEs%?AM0^4!*UNkZfaXZ(4F#OHaUlpNNh5Pm^_`#WU+;RJtue_22jMgzQ$AC_d zFdUJLjvzzl7z8}ES<@A4zphc_u4K1XQ>EA@NtCCvl-v*5Ahu$jtpI2P_T#KolIxAM z-5LjXl@FGW*fH>r`+!UVs+7K}dBkJ@Tykmp*DHUGI*2UNZ>(Y=)|YqJk+KYJ^H8o# z$+$_$`LMoxksYEty~6bMJ}ad=ciZnYm#SKc=a1P?##bFzhn3+FFbNMJ*_oQ%Fu4bS z8T<}W)YU{BSujvS^Y|9YlFB0d%z|*}__or+>tJDw9qHXjLEFBS^ ziF7u&2X||^k?D$o-)r-nhJYPevJllZ@y_+JV(n7Cj-Bup_=v;eOH}pjc_;c8zNdDy zM*h8)6B)w(ARMpX)+*mDHQY*{kbOw|v+PG`eV?v<)U;zMA0TkAs43@yHi%Xt_6>Sw z*O6mvNJ{y~i`p13qd_XiZ*b?u-_&Q@_r27IS>CM8b8P{Ab@AoDM%~gfw4MN&*xzqA zi>G1YwXP$qo%FcffpTo#VM)s40o!XAQZndVk{)2t;Q0Vz3t}6xTfE{gxi>j=*X2X` zW!>toWZQ2eS7x!1+1e_E>Imiq{Fiuxb!7JBn$zqxUY?(H%i zjd@<1Wx&*+<)IIEoR(?_8VKdK)+R&0wDdNN=x zxvAqk>g-V?6@|$`IRc%f4zn%EBA?L{0Tb>?B(5W`qBYhP!$^l-aW*jq6%IM{KK^TO zLy{5#IB&GhKR!(m*%ME2bNCfcB@#p3>I4~LOvc^wrEH6ZnF$dPcfPUTR#KRJ9V&R`U^VgOF zl_Wx9C!UgAMmiZpfZbvWzwM|(W0FAkKZgkV47qivEjbw#i zIMFN(U{#MjIc7N0l)npX!2~GqV>UzJf;?J4-@5Q`a7wM5Hgrb~#8?hta9@(*jRtjg zHIH{(?*&jCxl^|gtDsHY+AyxFSWGGAPFhF}a#qJ5)%mX4V=8$U6?79YQh1dmeM&7l zS-p92jea4WoDBe2q5$ixujDQa;AhDYu#f6K`)lXU;w6DD-ET6r9F_;4+limsiInLo zZr{DTS{qI@KP*sWf>Ze?TrMfdjpNMNktZ)Dy!bPKCaZvlLD> zVbBtY(slkjpkj*%bxtS?28T&9S0dSk91PBjI*28jJt+LAIAYdjJSBf}-rIlA5*8xSMt0)@<#1S8uo#3BiIq`J z;?=9rK}1ur1~0HC^xV$S_*_VqM{c;72mz}Z`liL0r)i+u2K*TC^0l>eW4(hfM*G_G!;2SR?L6x zL>@W-DyR_ZsW3pqfHxv4|HG5gOen~Zh`2O`w$B!9=~-Oj2j(&H-SJTi z=DhQ8W#k~zyR}w2R0so)bnC3@|1#m$wVKJWBKr4d_C~AI%Sd9UfQU(8mYVVeDhHsb zhs1LaGq)Qkgx%oAiM=Z_Wns7XwC`j%3HJ(9IW3zpEwn8HlC#Nb$2$DK1(&}-6S|f9wY6ghcRkaej1l3!z*`fK$OAeV!Gn>6b|rb9ItfSi33BWTET%YTIvorwt_Y zXWQuP3H(dRc0S_m+@Y9zD8~Z`^%LgE?Ax=$^E_aW8^BJl&5Kx6snLjrg|8!#eh#o~ zXy?^RPgx1xEo#MQu~qK>fcGVMXoV?;k%dqtF6P5L4_O6XvqiA~)5b(SK=Wq)v(-N= zv_uv}|KxbVemic8H0VIrosn3L>&XE$-RFI21CG3ndqKA zOdBwk{`s}Z--7OZQRA=2Xb$>#{>B=7Eee9=FMk=067rL(1@aV;D_u96bPy z)XzHrPe-Fi?Swv*e)wwBh}LEEI{*YD5wy&AUo_CpO~Tbh_aYjxm}`BK&j^}ZccdrERKnEs~bSzjU3QUb1vm9uom`XGUOFtFR20A$p3}v zM0M)C=Zf>h>NjIoPGz8Kirep^Hn_Ke+g1c#(y0pp0Bl-QtStu|3UJ$OY4!O0`u@ag zSD{%Q#mfCp{FskygvM(@f&&?GFBY)y$#;P4in<${Az(X@@d-*B6J%T174X-BMACjL zu;olT*7(ZnAeY|Jw@;qg?wYLgUCTugsvSyznWFwchmmf9(FbQ=mub(Ildt=|NG%rg zY6q%h1h~y29s=))--$fUwQ7k_IR~`!T3StDEAq#Z1PJl)F4ZRye+hPMC=HR^V!K&B zbCH}fg`u*yMgF!9a7fNivYt{GF;Q6=oC2dyz3(7aYz;z7hW@sJNzQZ3ybz1ATHiKv zrI?gp;t`QChmFDCpl%knnq=tc0=~&~Lxd-lNSoN-dNml`9ofUfOxd?7O{`JVdsw07OmT4wiP3ZuTY(|?+{0;S z=?u;<%NIHbZQJMbn&B7*=_y;@`CYsXYCY_8?3KFSY>M1hROlQH+*I4|PV+fnb>TJv zZ2El-DB$pPjjG#deO0hD9+62ig`HzRs@gO-sFU=R)+!ypO-Zu*wR-)7i9hH07$v_t z%YXeWfR~pHww`@V#PeUhhDFP#E$@3Q%P8rS#CAR>`Ps5jw23klOA6VIxS^G8Z+FL= zuE74o_B%u5TC$?f{FC>_T35e5FYwp)Be}f9ebl}F;JG>sxNjXRTOW(9@4~G=0Q=$% zkG8%3iD>qxEIV%pxF)=d&%c!!`WO8BNZfCx&Qcqdzc4G$EfNmfp?mu+(YJhkJ_+!1 zsAXxLOanXZ!=1oVV9U|PZZ|u;QgQS|LoJ%-D)o!05@3A~`rz0lX^lTlm(odQ=_V~J zTB929B#bOld->PFSjzgS@e+jc8EiymMhome1fdw4Q3#X8%|H7XX7TdZ+6~#f0UF2f7tD3erQK4?g{{!^|0cI59CNwqS9*^ z|69?AMk5k3gU4jR+N^288t4*Qpy`?ZooWT|Z?+>OBM&MjCN|TrJt!?}aKseX^k@QS zB+}Wk3_!+{9hirUa0ulliE_6F$y{=0-*x-fmt?BHR%oW@A4LGSV8pxINlc?9Q1D#i zFmI-nh&4|QgNZQc3BwySEw z@xqHGC{O??cRp-O&hP=>_UF?1=ss~m^6u9}0JfuSO#TVU_bqQyw4S z43i!j(Sl&ND{}Te^tt=6Wzrv2^3LswOjTiTb^N0Cg_i+|D!d>fR*W%^=C zSj_+=qxv}?=I)mxN7k!Bl693NRb@-`nSa7uxzexQo{6aIc2!@v*m;Pw99W5Bw?M$> zKBS2bk8TDjUK~!Adygs0fet=Pm0$1JjRGaTW8yIlCFqZT(Q!~n>iUE8aseB1edpuv z%LyRm*g1oCyN=lICA&Yg|LBygP>GGBrD_JwbsutW{E@Fh-AZgV2bs0c?Y~IT#1v#~YKE0&?QIf{;%*RU~*pD~OI>4_YgO3eEm-fHU zvJLJ(i6o;9b0+(B-2BnFyfurH{D2BWO{aXtl?10v5g~CusVs`}dX3@-qX&Sm7rvRM z%N4hHRQDn`MqppD;Hz&J7c_>`HzzpfBOv?cJO?P`~p{=HV`-&q2+s{u(?@8pdfE+oz@?W7H)#1cVG|Wz<=jyv0cC|+7 zi}rO}IEtDb&~Je_Jg08kOM3Rew`7_9czMn_+qP_{Y5tDQiv^$Zd+%MeQ-GQ(KmYkb ztQB}EXMffvVT`+?z`35&e=!QQ)6IKh0hV10emP`QngnT`JsfwjegleSqRrnV7o6zN zjq2X^vrUwzsIXo9X{JCeX??v-ACwqdSLQV&gwRslK{DVHM2KPs{^;9Rh^(iCRh>8h zrq@Sg>c=$qFc28YV&dHge( zccspsjmEvXTD>z-XiE901!7VJ%mIo<176^K;cMawdk0a}J_z0aS>(-4q_|t-y4~6e9%YS{b6bu>Y{`moG zXH8{NPxJDmCu+k#oNP*c`To*b)_Z^st)yzh9CL{Tz5=Pj@enu+-&Xg|p(}i|GXJSp z;i|;HER`0>Zo@*`w`z1xR$8u<<2?{3g5G5?X+Pm+b;J9>F(>c?HeD*4l(M8WP@729 zr{}t)6vPlWejP{{aFmmw6R>PfIM45Cy=*J*ba%pQn|<{p=5_Q zLzVYZy{0TuYiN_}lyTprl0rOfrMAB^6rc2NP8MInkYg(6jCS#YdAIBSDce;7(}oF? zIe3yD$GsnZUwpB>xmljlM3a5iJ_`DwWq-M!O<8{dbXzmOWPv{oH9ax(t8psbV4}_@ zbeY?uM$Cn#(bN(z(oc2_NGAgpat;5oc8*`=n2f`ffx9!Dxjn<&&^T<5i$^ruKjVNx z^6s#acc%~PeZ*T%%jMvs8NkmNcQ{*{YNCAC8t|pc8Cz8{9$cI~8mUacD(arL8fa2fzK76R3&d^%sd9 zj>%EJ_JRi&R{iIdnW%VIPwWHo0SFykZkTwGv8CPu4AzYxMn90(*t9iEj z?77-6;J-?G+J5w9b3*q<5-n$h@~Z|_)EXD=pY8x3u+qtL#KdY}LizmD526b(7%7Y#J#o8f`HQHwRTb!;n(o@62ayu7-f zGmcBi2bTle1dGJ9Z6nqtz{+!2bnEOH2K`*JJBXI&TRk}Ar-=FbuDy6<6jQ3p_r921 ziiJi)iPcFLk#t%dlnN)~IE*dt^kb5~>CfU%HD>ZMSiuVvtg&@}7)UGy#DT6vXFUsW zOAsD5=VME!QDOVw9NY47nXpn*5vCdPge+3_gG^kp=9uLFGN6*f6+Pqkg_1v-gEk94 zgeqx**-<3w7d8nGqwTP^YT{rw)~X40@pI9ZQMSBC0bAm**QNe(b=wkyV%^DFKTJix}v`4Q~HGlo&P}O z@ixC|w|?H47FEZLIhdY)pVWNiyYiltpC<6K85Yaia{Ox>Jlr0;p6(`utlMd#9ywqo z75IgGDWyf?dd6fze=&KDI0`ar)F-2h0^71F=91rmgPGC0czXT2Q!7{J$1fg2@8B?v zbPm@in^>N|aYjDEzxBM_C$~u{ub(TaCM7;=S^xZ!<@nGg2yWE;=j;+y-zFAR`gU_^ zZ)Kk5$l$ai4qU{Iuz69N^l93NEW}m%`j3X9i{F4z+7J^oXJ7?O?$k!=^jn)4C`_T4 zO}ZSPYVdg*vkLLjgj@noQ~l$Tu1O5d7C?PICenUA)K+mj9fZ)gzLx2ioF@sBoXyfH zNz6Za{=qwot#i7f6_zC}L%Cg&(zQ#vdx;wfPEk$jx!)tnkQft&BuBOhr z6vk*v9XUkX(lw5aE8$~%rk7%;nmb3+rv6Tz*BdF9CPckLo?cqPp;$XqD~{n-)bGah zvO-ZSTA_b(>sdaZm&v%K_rGpRCGf3W#$gwNo0Of|aosF$MYpp;$3M_*3QS*W9aHy& zXWrdzNjAVdC)E5|v;@>M+Bfg9$X+IM=2kp+t8gqaxe@kIlvyf4mV6#P!L^b|raR^PM= zCO}>aseLNu_nJeG$m=wx*@Tzmf4XiNG!6YO%ql>r-EV+%h$;5T82CaIHzczJ2Wj)wnm z=O;o|gDLdTh5@QDgh7?|4QK=+WA`S=T*0qLf&yvkPSUm{ zY?i$$zKATi$^o>5O+oK6Fj#c%b$tfW-zezs| zV7nPlY16&VXe4^D0Uon?E^#4E${uaVd7SuReX{k96E*ybgAMsH21m-c6Ss0_#RQf|9eT*q`i!D93Qk>9xHaqi6 z7s981cYLTuO?=NxZ0R~AetGx5--?&KFjy<;Gz$XLF#l%BJB^@%JpX3*lN{kc_cWsB z2tNiwM}5Y+O&uIMSDCO6cyGZ9;Pmd|Dnzh*V+Jd}OH0>=-Q|-1d_=Ai zgWn^sua;>X?(XM${eo9}9liEbq_OL_4Q4Fp_5;G{6F(YkZ!^ zc7^E_i~dKg#k@Tb?ShtFy-Jl{k5Ni2vDNi28gauCr-M(YCLH1wec7F zf1YD27iN&Fd@QEi4Ip~f`Sp$1R5r#Qaj+&XlQr!F8SmhleB1L@X!$iQ$@7X3WiE(F zK);&EVxl6so|pGZhHD5DI-ZxsGN)0M$T*lZ3pS2iI*j21r0Ni6v9nP*ozTrHF#~9P ztAqJM>F)mX`N-w>V~A+8+tn%~uMyTd+L)Rne;C|;k(7Y>7aWKdp_|hH@?#W0u!-#yln%qqU4dXkLbKj5nuWQSbXN8>-Wb1&2&;R?+Q$(f;tW@x}J&I@uXY_6tt*KUdeQ<#v}JVLov=&(osYtmBI1 zvom+O@J88mM%QP>+Ys{5@fWaJ1yBk%w_NM`CY}i8Mi+xm8KnnWoh{4zq>ac?H*$GX zJ)Vkf{4xop-&BWs4NR@1F^&AL*NG$0Lk~McR;uQ3a(V1VmhteZni1B6RWJmma97yT zNZ>_{ol1TiDLE07L+9L64>+?v5r$ zNMQ|oL$rhbMVnm->6GwZ=)05epI?2{2>9itf}qMF`vFTSopfs<7exvLGV2w+<|kx+ zY?qEj_*VQXP60<*fwoEL!w50f&!aZ@-(Q4llRJf(8H(u?2eFU`N-;mYdMhoQP9;dF zu+UknkGzC36e#*Zi7RrTSjtFUEU#BP1B}%F(p*Vn2Iji4fUz2VJAX?o?*I8RtNS_vc{X3`e+7S)7T-x!1Yo-axr)(Q#+FsJ7zu6& zT29m_x7ETHAwZ}Db);v zl|pKi`Q&p-%cI!eA*}du8IU;EJTh+14(m@=_et&*rn#$Volnk22n8PbjreP?agS$g zaC|dP`n2n8fJv4nr2yB;oKcR-BGQMv_8q}HBBTRQ-R#Q^eM`NKW zC*dJ0#l6Xdozs0cxXkS6zag)?7?D*c&j}{o$oIdzrQH8wQ%E{od9S=Gg6d*io{XF2 zTX?J!Bl0-nbhi^J@?oT^P=5@a%3P$(yoFvU`~i*sNqs`d=MT8GB*DV7v4G_NxW`Fj@6K@T{mfn=`Igw36cB5nCK2vD}Uhu-FNLvzrYq6LY1_GwFf0_|lDQ1B` zq3nqCLbv{-jh3QirrnM|I_vu(p`{Z$dGM64@cW&!p;Ob4trGu>S)0Oe7%1wf4uXA%6{NI;Jlr`JN~jPV=e){SH%mi_w?lI{B;a3t8uy${!t&7u>v72xF^ zeYm|#rv3-y;fJC5jY06O&6DhL5IQ;U9dc)E? z2T$bn*1W%kXKJu4jXy>?I3X-r(U^U5uV%l=9S-#Ly20$t1iOwR@ z%W;#>WwFdJ8cY@8Q(LUx{O)jJj2R2>-pIr=2wzvtq z*0D78k_nY}600)%_H>Z#kovYQ&R$Es)sK(Rb<|npcmZG%5X702Wiwms zypd-jL%(h_tia`4s8La2o9@4`k%rHXO-}00A#P1~;y(wq0Zogn;E$Z=6wteAJC9w& znn#5mm@5CnWHZ(Z!;6WEboEGj$@??!i2s4T+0`#fymXTyG&(*Yg!hr($6Bj)fU(N= z{JgMxE{}HMCcmXt_j<4@INA8uMesFYsmF%nMH3NCE{j? z=>-LV8yM`1JnKX@h zIi*{1mDg%F5t|z?0kQGUBQvQlEt*CbqSUuTKiCX3%-auy*Gmg zxC6{|SM{g6s1uqFrG@cxv;DnOS`-{z+%-_Qj5a+p{1Q?zsIe4x%{by>y5{cv?Ayq4 z9(wxn_P~UqWdPE48V|1=hGCYPwARd zVTePYA?HM7T+&sAiDzv8vby~x zdIo1WAi0U-n2WWAk&5O+H9GwsK&L7E<}5GdTs^mjPes2~I3Gwqi%7W_)gOhU7vbHRg8z!Q~}4bf_T!mUCr|;9FO6Y=W7*>lf7tscH@*jJ2gT#7m5qo z#gGDZDObM3`dH4sO_ngGB}h4UozqWDZu+N1@2CEL>%MLk2YK#99%aExU~|FzuF3U{ z$9!&4M}C+9T?gJ}HI^NdiT}Gr?|CPvT4UTwP6WfdqNBMS422#seS3x4755g4X93h} zzDqa|+63)!-cXjHsGNt(wgdW`^9Qr-s8vO=0^z#9w+~a;>G~w>t|_0oNcdo1VYn{` zQNJ(Tk3?=s-|e(aZghB=7P(gS{qP1(4OeZ&^zX*A4@;!FF84`FBE`JOskWnSx0;Zn zaztK-dmlWLv+ZL0hFEZ#N-Hk}Vn_{K?@3kzab79arOogA65)vfS+_%jz~h9z3R`o? zb~MG~uTCBJ_M46iW~McPegKH{P!FyH!aal9#b?k2%qUpn=aVGO>xD( zaTk+A?dwJ3jyMVVbVh%F93Ar|Cf1z$IF?*cGr+Hjf^j31LV(KZcfr)-H(spd_xQg& z9>-U!sFO#x`T76D+&hX$*ylz{R8h;iH-zL9vckDD<3$Df+ICeRZ!^P1kQ;T&r@&F( z3QzS$?Bbjfs)ni~oE+9cB5kAD{O#AoUb@27Xo`ANZA1Rh^HMV&PxH=i*jCxsvfryp z^84EyBRS>XgqxVVa;p^EnWf(GTCEROb_Xp-ZWA?kxapG3TADc^mw>cAl(~3R5!re8 zhsG?5&eS?R^qKRlKGqmPg?2adUP&d?(fH;$T(qP%;w-yDa%mZjkMxJ_68^g)TV`46 zi1^u8Q%B0h5s@a_YdMo4dUIi4@Cco^jkwoc751CvxZK~c4((N~x_f66=1f;nW}8Hv zAsgBePVn(xuo=q1$?GfA{6LGYzaI-uW&_~|TMG_@yMgb9zGU9MfxF(&T78U`y69FG z*fIPaBKhQXq)aNY?d-49-<0$9C|Z|(-rH838<;=WyX-YmS1(v0za2e{VQ`x^a(_#_ z%H&{*VcrP=y&o@!2g-%@aH;H<`!h`#xh%a_|&`E8{gHw*-5jueq8X@y^ z)W6eVahGLIKdKEA^&etd5K(zo&Bh0juKQ#M`3cirh@gcvhOGc$s^|kUf1T=a*an?k zE!l%2YyD9x#!CH`RdPv2{kg|wQa?7B)oUk^0zxTL8 zXu%_9aCtQ&V=jvR@T#NEHjed}4N^`+=T&q}uq|QYeo}Apd`LgegHKUewi&uDEY`yZ zP-D42PbIUo-xvX^e(w=60=Bu>y(8KZRjT|9=D5V(Tf@>E+T&`9vsC&iYQ$!8k;MVHkS=L7wdkb{nhNPT*_hjqZf_cQAY3{_9mjIsdwk7pJoP|4QQEJfJg)^zI5Y z)KeiL>J%_2)D;M)6wS#vP1~CM>1DVDj#Q>*i_&-3BhcOeLNWz+g1PyDt-y*Vg-cHG zw<*PKtF=kW>`W(3I1nYvPtOE1uT{7HS>V`=WE@N?DEK1@94Zg5QB{)I@_P)HedhZ% zv^jc2Qdj1DI5Vhfj^4eG8G~Nmb+xI7hdasu;1DZo=5&o`<1gXER)fK$BYN07lJWvB z4U67msJd|b5)?TdSZo#bb@9)VMoJ*1Q53@OrwxE{!yYfQ{h`{aiuPOmmy1_~t$rh)c-|4dQfy@5vH8!T&hnXlnnu08WSgXXV6RjdZ z810H_ZQ7wyLy}*Tdv+zw{N4cL%o(#D7U*w7$=kG@gX_=sGm0ynIgLar{E_~y_yR3q zArOQ+red>uqtX~67k(l2-pa0Pj733>BRsDCsGcrv*vx!twD9;Cun%;$ke zo(uGYd5MKndVjMGxEYE}AdzxKGLLWnh=)3k)U`=hSt7s_9xy8Ml|q6(N)=|27oOtq{>?SV{&`XCK))OV8l`}JmBp0wWS#nk zE9R66Vyyqa50peZY56|(@KS1q?A-$J}4DB58=3KdiFI~2^A)Z!MFIv-;eRdu` zZ&66l;a_D4yjrQ?tc(8{lVAC6sx$}ul4rkE;1*cWUt5EMBdUDeOV%?)1PSOU2qj!k zV`L;-CK`k}Kh`!<^2=cZlD zroN4AoMQw7b#6!2yG-Vm#2PO#?$vnl<4L}goS^~a=ASGXmG&J1MI?Ap(itiYAKb1a zJSDb6i>7!(rf_NMyYgWS6#t!V0>lR_52y7$y{#|mu=s$eud;caFu_%c@251kEaAmZ zXKs)f-1hv^L3HL6K6?j-Mxkl!5S|6|>K%}EI-w&m$RNp*%%Q)CtRH{jxbU&^HqvQ6 zCfVWam&bp@LmAB&3%zdrb8aJ?@4;eg$D1P@Muh(s#C86+!u^k%Q@HdDunW7%n^+%z z5gG&nLAL1V6;NYhtyPts^*I~-5j3kb4L4DxfH_*-26i>3aWi_4L4nW`v60TJ8{o`7 z{T0a1uMC_Pm})xdaCA+mt8oDGG}EVGf<~8Ui+BZ1j+8eMhARK1lb#m%T)=?bM z_n$G)2@Z2G;1Pd;)x%*nj76M1D$}ja>6%D%rNmdA43Y}gUoF~;OsCcqirJy#vdKGL z+TZq5c+^HIt>I$(P1UzL7flwITpq|y97b-BS%Zq;F1oQ^B|10xFm~=dUb^}QorF+4 zqW21nGp7T*R2U{&zm%_c49|jXcP!K`AO|aF&-3(?&~avb+Tn}1{sJt}#~H2c{XbNi zD;!e$Z7LA8#)vJHdG1M!TX$^yjxsj_q#9B|8&rtD*wrEEB@3AH;fgrEjG|2O0$Vh21wkY*Bi9a{;Sub$OsPRn{e@T>Q&IaWf)4bgltn`8M&587Rl^^|(#g>G$n#-fC zTKh$84m98v%uL=hAhS*sc~D51Q&wfBrnOskJC}18dHZf6bt#eNIV0F^a|XuO_-lWS zdEM+3617xp=cQv;Z#UguAueH=K?lf70YjGYTt~aoF5r8-wj)d16J0SNFfljadzv@B z$X4Q*>2_3|&vBFE7=&XIkqEW!XV0y`-syV^rQdiqnyyR)ABzvHJ+7}1YT4{YImoE0 zjtYN21PceK?>kSi)cGKT>VUxMkE?8fLfQe1k)mJ*pPN3Bv%drPnyaiTWKOV}8Qg4a zR+;};BA(s{pt6$v+$|tde9>ya(Ecni7NkR8&DJ~+a_U=E%}z7jQQiJ~3Hb_{!05yr3!#6=i{%sQ;`+xSC9ROL<6=#*^G*(PRMJ zs1n@NZyT>N4@ANx`ElrUKB#K1ggwr!P0{bD*w)6MVDc0LNpLoWOEZSJO2}{o+Pw{= zzzu))N|)*yL=CP9a&f)`4Ur%n^^h2&VtgCb(39<(I?WYGiH*u*R`uSG`SdoJnL<62 zvi+)iX9bwngA$7f)9%sf@;6Y9xy&_I#%txXe*L!brY59D*+ag)toPaez?@f;j{Nn0 z>`{5-)qSkXF-NSPo4Ix2NutdX?KPv>++=RIR#C)R;7S$CkW?N4|1+knxkkm}rI z2l`NkuE9BU(Vbwh}4rd2?9usOJQ_kK7ztd)`27F^r zBg;lT&6!j*Ft}=t_ph+u%9Rn!QM z)N$~3vXS!(o%^irlxsYP(q+rv_7|Jw(m3$1(V;Bnw%$!iG7 z9qr+1C)d_seqLfP_5>3BsTz+l5e1Dj0a%JunK@~J5RhMPjJ$+?)8GQ|EK=^*b4-gMM0xI>>}0QMnV(S<0Vd%o1Vk! zd_E>;SN7S}yRJO15s$ql%;nT3@fJ(RO)x&)9OBwf?GZ#{G>^5#T_7TPU9lQ($Tin0>V_0$NwfJc>)|dP>dlU%N{Y@_n-w~B>Xu9B#w}(lTGgA=FV2#uP zS!p&xFVF?(?_;U3+fAVjo-`+vUzmPC_;Pl;OlaBM=-RP^^&c4^_%Vn-`D`)akDU4h z{CsvmLf{Q}hsA9MdHg3B^7)e-{W|ePIb#=+D^Rb909-%LZeOdmq8sg3BZM{~Z^2sU zEBx&hQ774hmj_zaD2^5HC^c?L6Qq*u(0A6a`%u7KEZR6t2J~W95*kKAlc1oW9wmWZ zT&Nw(2%gEGHGffC=OmT1+!IDU{8!ze?S*WVtRM}#I@kQiV^B~^z8m()(AiC%;luMc z$&P2d8I8Evy*>14k2Y_Vm$k3_=`m6Kdc=znKGU01ZTEXw`36uF43dCsw)-w#!#uhN zgy87ECO(U2{156-#jxLt%Vw@_gUh_N0hBXPS^xFnQs{pKbzC{W!!Hkp%-0NQQld9@O8S%-Dg z20k+$K*oG$>0DxH3^I&ExT2%~Hu!@4XrQ=T964kr@Y8IyymO(A?iP&%W6Sn-POReR zrHMdOIIG$INg`{I&tzhk6w+E?yrZ_#lJ@<3$++%2o^?ER7jbtF5H*P9XRu8s5Lw_4 z;jP7Ii8v_Ig@5O4k4_8nmd`C!JL~ekwRCI*yeJD~ocO?+QeK+kk5V8{PH_80UFC-K z#9fWeNL(DKpfapN17)Vo+xZ}+PLjrYf!K>UTXTapT^X@kfS27?k}0U%RWzHsw1r_5 zs@c*gffpzpfaVkW4Fd-W0G532mxNI$h)=PYyIm|oANi55;j=oc8+dp!V#HW*ENQpb zKRzlY4Svgt#+t%KPe!K%kW?kJEr43Ui)<_N+M*-qWaJ57CC%Fn5nsCjd`LfzzjFzq zMDL|*Hrc0>agr#t_9)G={vOHj_iDn*+c9q#)Tgj(z?0&a#fl2S&pRaCXX5R~0l`gt ztq#9(sIYe@!aai0wL|hCL^<4ogvgsd<#e>I=hlQQ{|p#(Pt+J+Jsx7!zSiSVAgn|^ zbYsmx0`CuqE)ttIbp+IhPm*&|8$~hB%8dIMp`y=z9hQ>rw~#hUQGBpg^lhWe3r;Hq zr&e$udBpS@88iNzen^TXMFI7(W?dkFWR~Tlz;W~GU9DrVBZVk+^qfr7%i}7OVaQsH zAt5Kk!dQKW-g~CA?Zc%Dcbuze3vpc|sJO@vx%4-BT(Re(DK#0_Cix4=Dnixk=uIJn z*BnvKJfs^B*wS7e$szmC@S!rn6V8R{-n$+dvuo35Q|EW}-$r(&pE*1X{v*#+1&yB% zVz)j5v0=K%kCnCclY}q*zVrK?Y@BNS%@plV1)Vkmu3b}qMQqubzoFiuPA)+!i7kHq zrO(M;5wy0z%bNSjDfB`ty@h*m6EsI=qxo753K(5Xb6=1Aez`N8G<9!3lt;{Xaub`h z2VBt?Qz!X+&_6reUj=Q{&2;zzR`B6*CjJL?(B5!*ncA>YbF<`@GGJj2PnzoKq-IOX z8~?;ROh{p|;>;N@A~rp3>Ff zmF~9>d~cy3?{=%BW@4;iW%(sC@-&S26pm#Vd14Ck6vwUURjE*xhz_#~U9TC3L%?Vb zrBYupl0?pg7THs$w8=`%Pz0Bl!hl9F)Ri6Eb;lSRh$Fu z#0#REWdP-tT>`H31TptoQi(#7~j7&8P@u0LDREc zgGuNQ5u@C^4@LuNuT)8K>J>1PeK4OZSZvfQYYia29MWXX2@`gA@9!o+u4@LY>i>Wk z)YS6JW;_tU(f0hj@n^HL;En+_i%HD7-ytCL*1&%99w934|2lsR)fHQTxkSSd)zGp9 zNZF$lVE1{7t~YLAmoGT@&lh##2|BoLH}6EZvHz739m6neQ85bwXRmebI(!82@>=KAaY7~qQ5C2SbNw2)b$ginc*kfypsaXFm+ zex0KSD%99Y8VM8mo@_YA;3c9)jz1Ju$a@)s0?s;#0{PXxy!O@dUL|nxm7}rHUN3T+ z>TNT@Verk`(;X3@3}Wuz6o*}_k9buuMq+^(A})FN$+?z9<-belh8@eVZ}}7NEWG!Zjjlm8_r5GJ!WNjX98Ev zA((z1kyB6N^p;Xy^XEAAAvix=rGu@BKkeL`CplCG zd9{y~!ViDOpH2~dtrGT|(%F>x&01Ri&OUnSG+%I!G+2uXE%!}=z;uhWwrM%wEH{~t zj%XdLWY0?UTl;4T(O}EhvHp8pR0)L z@BVICVp`+45cVL?s|fS(($wuofuo+4mDNI3N19#-O{j17st0&qX2QYH z*Ju>BXP=fr3RaG}jG}tDnx$BnO|(2lOI?x8IlvRkiqDER-ocN4Q)OEzx-I1o6NU#m zZ3x0euTnvvCJ^)Z&~b+FIraD#q`1EL=UK>e9$Q5l5ZL_cT|o~*sUJO6O6>BGrE)VC z@T2iK*3QfSAVG zH_>2MMyliJG@O~rbrczz>46{ayFY^7OZ@7@YO2{uCd`e3gE4v>@$_rJ{4{8!kQjWwCLP|nJO@1Xjkif>Z~z@1wjfud`P6e2P~`xwzv;$E z_+*T-AdW=zMc`AHkhAy>MtDdzFH7@DNMCp;k<`pbq6|h7A3nc7FSTzI&`F>jfg&># z#TB_+?UyQC<%wcW##(Q$TVWtl)8_ogv*A^?NS;b>>#Pq7 zR-Pi-#uRDuTeQi_!!Q1EFH;A|U>p;V8-{E#0x8rMEKt!P0Ma(8NoefU=5bEq|0Zd1 zji6Yq^GiiYpd5CGSr%U!LkDG?-EpQ1bwry?W@n-DT~yzrW;8R15eWxoYxTb#+QP4L z`W=6CpSe#u2Z1x<3wp*1q4&15!}QDON>W$%P9o0=8}EQ2kW$d#dEoH@P6vb|t%T5p zL#1?>>u?BT)R_=V?Xj$i!JKpujFIxpHb9b{~#E^H<6;O$k>{H^l;@=R= z>l2cE(g-~H^0Es}DThm&%!((2+D<9qW;9uRl(0Hv@00c@Z=?`C*$NiJ`#@p*IoYsB zo_rVnzcV~L=DD3V1y4ng>ZY__f|NH2F)F>apal@_#a?69wvrboPoh*@Fi+?x>d|PJxlJm z(r>@f8?gr9?wc^Os0v-77>p#+4!G#SXb9@~)65BPd`o>~d41W!}zW5a7uX>WQQi-QB$1nwK*jGtHej><x-c z5s*IcUo01UG6x#J+E+-_`IUXAE;)?@(rK&y-?v4<_>1@}U-9ZytO#DXSvY3c20P=uD}sN^T?V-RksE+s(3dYVSs zmR3urEwX21N3xyt2{o%DX|;^4bADh`C!N+G2You7Y*9ZIXMuF!D5x6^`ENJ{Gge)W zRk2G$kWnmjr}g0orMwsElI5d^kTa}9@n_GH4zt6Lyzno2K>h*2sUM+gaaSno-?9-) z_<84+X4^lnitYpHr9ix&>aMbv=+-IH_O`bz(%Q3W&vaR7J1r-~190$GV>`drJhq1E zXLvb(x!nT$`;i)JH2bvdyZh19##*g!afnGw3L3}fL5bZjAy~h`%7>XgC&hr}=KF!B(Os@-vp-%_i}kJ9v#;{}2UJe>@9siH z3zXTW)aYKYkvuvk+jxO-uU-~&E&^>l&f{vx& z4lK6v_)vO$@=u?uiS$+^ZiQCG)m=zXyl{97-+14Er*_1yN@app0>v&x5 zc--|19zWq{y}B zbp%d#bxX%`Jy!^KJ<^?Fbg`TIGe%mj!QKY8BY zD~lCJET7Iqzmy1RuJVaDdGEKx2Tw!L4u8Bp-BOCwEYn_Imis=$RoU{?t4*)C6v~rv zi9&~2k>;MgM@?9SLXfZo8X|vY3%-V&5Znq2LLNx^DB*g&YH$rDA#EN)?l=2`Jt^zS z8+0g^hMbKI;XohL?)$rr2oN7NHVgvh2gJBk#wM7z<41`qi}l83+hyxE(2d>-so3Zv zvkVM^`(EZviLZZ>YXL8Kf3nJr_`kgX9_4*h5Yx+}?PiE?vmTX73Qe=d*fOWuN1 zVBk#t@TtM_d&$-gAL|}J$mbCtXyV?~I#WN(EC;$K)P(1|#+eD#l}r+J9^L1|5NYr@ z?$--R9*hIYgSsOx6%3K(=hVn4$`_!~oI2UK9ok)zC>Aw2c;%#9_P&3f4z=ZjMr_ai zH$feGL(D?TY=<_Zu_~_(0pHu;)WY)S9EZIMh2f3kAW^p5sH3YJTJCwLI}B zwIIzsXx8Lum$qjspPBePVZ)$+YMgV zpE2!2hhx5VD%ba#lfVtP7*@?$sk$dKG1G{Kllp{F)p+0L&e;T&p zYrQ-)*)v+yYYuvxKH-0PXxO@YSzc#=L^>PaGAlO17% z|6nxg=2;Fm`pxu&=&-r_fZfV{vpbry6eM+yZs0?!zFo}yIue0*mqB+I{*#BP$YRsG zx=N_ixd!r&^lL;{&=pYwsr%io8KSiHXneQ5og?mXzgm!^8*RrC^6)5-CzWJEBj{$L za&hjdF>(n8_n#Lso!{vobu+3zMtsY>{&sLVq97#&B2NcM*gtyMkJg-h5kvt@*F=M; z<@};pr-2eGmBXtbP7Z4&bJCqG^o{Xm>EHE%PW$Am_M9Dw1jJMy>qwvL7+rpqj}lvi z4fEf!UT8J268#`%TI0-JflnjpoK#f9m1wD8JXEAVx_J7{Tb`WITZ=lbnHif^O?K%pujtV;C#H_=#fZq8=v0e+q;|K%YoIXgRF*st$E$I zJ+U#gNjLdY#Tu#bG_HP|tBL^ZcI2ob#gJ3rZaehI&n^@07n}Iv4}6{Dv`$3bm;ja+ zK;`%B7Ey4&vM1Kj_(M_%!exGl^YX*0-(Nl3)}3F@MC|Hac!K}7q%N#4e`YosmXB*@ zsvIBLU#j}5m6?09jw&-@KZV&7w!L0?6)PAC0Pr9Rh?&P6Z3o~=F0{nwiKqw(`w5vnt_7lJ2q@&6L7G&E`+wdc9zCa$^A z7phK3U3{~P>qpL8)4kIfubfk~i_%HjoEm1GnEh=BKGmoVk)nS$5cKcPNicm@@iDQw z%IdBZ{}=B?7zesR#?I>O`;WbIT#Benm|6rcI`EKV5~8VQlDGu zjg0k@>(ZMftT*7AVgi~{UR4s#^b`%}F~JDNn{39pB?h62tk*XepH}@HxmW9hUoNq{ zt!rpXdv~ja(_=JQd<1VhqLog+@UM+EyE`{)N2DZg-+yTMPE`C}LIaa4MexzpW~KMP zo_wyq_7ax#JJXKVbmNU$aJO79wHQm7&hI}5!VPP;df5*PHx_z+5S=+!yji?!o9s=z zU#b0mI`l3xZomIKa+?}Ej><(I{eueE2Lyk$y_yKR(WpdjGa_

G#gfD{lep_6lsC`;ik*cPE(s+MU~d+I$^YW@<@E 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 literal 0 HcmV?d00001 diff --git a/core/mqueue/assets/components.png b/core/mqueue/assets/components.png new file mode 100644 index 0000000000000000000000000000000000000000..bfced0ab57949867639198a96363f32bddcfa536 GIT binary patch literal 30338 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 us0|D1!3KEhENOwt_bT1EG|W&# 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 literal 0 HcmV?d00001 diff --git a/core/mqueue/assets/detailed-view.png b/core/mqueue/assets/detailed-view.png new file mode 100644 index 0000000000000000000000000000000000000000..2ce7eee66245a2f02d25e6d36fc062f5975a4c9d GIT binary patch literal 138254 zcmeFZXIN9)7A~xah3&S13W9#5#kf1c_C>?1Ah)4;DonAtb5Qx$uHPUOq z0z`Tzbd(k#6bU4Oguu6OA5Yo3?{oiu_uqO}vR39C^&RgRGa&)@oL%jA?YbD_7LC*SeD`Q0 z+Ns1$T1GD-~Ewj1Tc%W_fgo;$KfLHz6kffmUs@kDXp?!u(4koWBNe%=)j~|151SvWsvm%WS^G=y{Hl$Md*-w7B3%egfQl zcR;$@;uvharNI2^-XXrnx${Ds_4i&PUl}^@+NHJ&bVJ?n(e4G@!5X_eM#P|HuL`h>)EGVTKs8g zACFDH?d+>zJ$FOx)=f;$oVQ%;(GzLUynU9KSfgwsQ=d_sUWx(Huuq}9j9Y`(czthG-grW}NSIrS_7A+-eEJ9%=7E;sJkBIXgn(fQ&RYthvPRtjpmxBnkj zdidC*fzP}TmlA8&GHKdRn$w>QmxIQ=rkK16meYJp8HLj6-LZM(N0rpN%pl8&FpFwK zc2kuJw|CcnS*+lN2mKi@2GoD~;9mi4^xT7X1!9d_4HL3lD1fO^*E6i7^qX(b9JF5l zWVwaxSKZ#|2m+10@7D2E;O0`_F>kQje1G)R61U?1~&~Cr{g8HSrg^!0UuCzcn%EYxsj*jIqQ&5vDJmj6nG)IOhR-ht#nOf9 zxAKJ`sCAndoHaIL>%>O8%-`&WCux^Zq3hIa+M(Z?_1AEr_dd&G`9gZBN+M?iCYqvd z+B9LoEROidGaU(bF)lTW-QC@({Ny4Pg+lwjdHtw09P=N*U@jvowiFPVtJ$& zqFLiSVkPS&!05PQhl+LDYHTantJ3adT`E^ud!3Ap)87On|MY-pin>|MuTJxqYg~zZ zj7lZoK;;gLo3nig0P5zAsPYxJ{)~rRNi^{N^0ss2G9wo0Y9-@ZGLb z`&PXs&=tP%GPmJK=k-`*@#0oyK-Q7}-MZTFf#~|~vR7Y^{6psC7C4KET{zqWpY42m z-REVkNLx>=0C~4Zw8>{{_l!WsVtE}z(C_x^1;j*)M>FG4Hy@w6_gewd=JaP?>ibX6 z5-*R}x0NyE3NQcpw@}p@{UPgAL4*D;cKR>B`w-4{^GOpgh|HBQLP^$&V7nwm#METB z`o8s$vmCF5GYHM7aUwBZrcMX)d_#F$C`!?b#vr0jIlG2laHe zqW<E0(HJnKoEIDFaE z%*^iz-=!!;`gVZ-9~5ARdz*CG{x@XYRb;(slXUow!}DKU)P{+0InwsW=OJ zEwPoYtX%s>o&v8h-;S9P#wxvt+mj3%OV_dumBQ@&!44wHjn=`?KJ&k?RT~Dj(oH?^ zjqe{!^ZwE3jQH!Z_1_!^NIZz%3((2)su+}}r(LYC_K?!~aE9tzzd;4{Zx=zF5AKwuG;Z%b4NgW73KC$9{F-Cdiy1b;X*d)mvIYn>xadfIDk=zG~_?);M!xT9dy&To$6T;o>)B3?`%Ni@O9prNhVKEawaE)Cj^Oh9X z|H|gyB2VFeApdAHZngdDa*IbDlN!Xv%tQ_YE`M$hdTRf;SBEm1yeL9BC{go$qz9wt z2%buOhmNsMOC{~w49!iQZU||rj98T^pjER(>sxU_R^@5FL}y0)-LvpM4b}z*=G0JTiLrU z31W=HXB5}=y69E!xO0EU=1+HSjeM-adoz$a*A(gkx%oO}f8GwlxGT0BeE*bSndrh$ zsY`UH)S_L2%r>p6|BTts=YIP6Pxz!pU_;46C&FUG(|FE3txGjfChL4jTpBDU!sNyS zRvo?Q4;KbjCOsDw{461EzBQYl$iwyq)2BffLz*PEugc-yZ(j;6&|zue2rX#w3tGma z6=C*yCqym*H$V`?9_cAkJ1t;^fwnG1_LTLq52HO9V}2~V!+;G?Qa zLPx9C4kS35^BXl+p%Y+Qi5X0I_eEWsWBIBVY^|I$)2#V|)&gC01|M#tat(jR&Veti z(!LsN@gS>Ho30PnW^bSA*fER`h6iRtZjrYwo-PXr`)6yESdaH;J2attlE^Pq{$c%U zZ@gF!^QmlZM8~CZr(S3}!#4zk`t-E3VAq(bxS&h2=!XPp;puAxtLVCuTe1q;be&oc z6V(}ddwg4xRt5E)NZnem9Rd@s$bP~%pldjT0r8pBy3pkC(gmB{jPlR0-N~Z1i2em8b{9AlCSdca6myNm-bnnKXKhr(k>Na&BRtgk|-Y5UTCd#t{QR848 z(oo|&A+WveU0F+EXpMhfPKQO_KOeTK`YrgL)L0b>@eqi?!U_xj5IQ=~v5>h^39qXC zSVDr7uLTNk+}_BXRPhbylsk;Lz7L=JMM7fR3gWWi6Zqg=-2?#A_g_cLY!8WiChNUy zy6`khX~_{&vR%B{y_$J%q$+EDHUJED-O{FIp$G zT)O8v%sjZ%{p??1<1YU(F8B45g4o%8MS}UWFUXqRktYwRy}8P|25cvxGgimpm8F5x@f&}(FWHwkb-J6Y>pw5!c$NWfO zs5t1=hTq0sN0E=y-ybX>IV@{%tsD1~RyS((=9BG;iRtWlI zh0_vttHk7tZC>9fD>s_Q)Ws7&4Mz5Ka2o5$W7X&$V_AzY$5?|8X?)<76WBh|ANEPQ zNn?yb5*&xJ>j>!Z{BvwVp*+)1(>5Q)&{A1Kh|Mx!D`D-sw^y~kNL=)YuB$NjF$)uT zTVr0(aN~Gs*HrxL59EX0cc!9-QW7?5HJWM8pRKxSk2a>~j_5EH$X19C=Lsr;QRnGa zbJmCtX=QFSJ^0k#pD?D8|FTl{Sr|*0Aofq*Qza{g=?7Sj4%}o7g*bH8pM=-kvcw4# z_Fj0U{10hP@rD|^X=ICF6QM@u(6g7m8bZ94S_>D`XAE$5NisU1-+}ID(d6}^jgft ze+ok=byo43@1Ig)zpoe$8djRvsoref-z0Hvdcb3Dp*^wAVQU*$9hdi@4@ zMfL1|m1iaT z&9v2c`ASY(B6>{wDGidfVD>PK`rm-z|E%ZVKcNO@L4DHaI6HInv05Tq{WF|4rVzl7 z)18x7|G}~T{V4FlP7&bg?wki-{{-Y@%i_l!rlQg&VLmN=GS);%Vg7H%@ZV4VM@sjP znwXl-`Ff4EDtk`d9RI6Aejmg3#o2H_PGy6)rnNk5UZC)J*EjC}ixuq({)2VGA-`lY zT}Gs}@z#z=NBQ4i3LjxFWM>kGH2`=TI{V=FmF!Qn|I_IAvOtRy3`|5m*j{8k`~Kd6 zSz$vf-9fECV(KTde^bZ*&UVfPk5>E8YMe*vDTE(P29Wb7_@6i5zYvlsD%|*w6vlr` z?&8_A$z_bqX^EjmPm2N{f3M}O<1z58O1@D4y8P!z7Q}K zWMYpASqjKtn8`Vr%&WQEGiINhx)l_Ev)TXJ_s-=5!D9zPF0~9r(j_*ZPqz36eY>JO zj&)n4!Oc1-GHzH>eW||6M#kw;Y3ZQ8GH0|t>CQcU1t3bZf zPKLPkV%K<>{YA-rzlQYSD=!|qvmTbK(+ar+{cLzILzXLF^;36YcCGxfs2teMt5cRv zNxWf6h#M-QARC$I^CF5p4A!}vU3XNqAY_M9X3XSBMo|Vy;DZa zX2Rktp+<1_d@55$UPgPk>_q~s&UWT)69Y@ayfz8XH%u9n?$0KHUG;r(rhl*_rrIBA>0oPy}%|^*TI4^57B1toB+?)%@r51fhPk*=o%*Rlu5;$ zdsav#)m(FDqz!vm<4~i?pbU7XJfYIRV|?6w7sxk0`#_?gI3d`8+ew)L#{XDWxzK%PKkX#w*U3$3uANfmZpLO+89q zUj{XBF$bB3gOYVUp=6SoX zQ8!4E*BI?@himn2D9IedNFj4Z{BwNLowY}+bvUh|Ne%tYX&s+6*To4z$%zRG;?@=x zHLY81vWq_`gDPFMIzr7h*^)qW`#`R;9a## zDDcn`nPF@Hf<`TA@7svN^4IHS{S;RAZGuJf*%KHPJRB1dR_GxGsd|BHF5r_n(NML3 za_f z?6KZ4N@>v9&AjTNsgc&7kQ%Vt|72Y=u$r7Ng%dy4l#}edI?@{Ek{j8ZW#4PK)9JH-qoaSmz?7a>Du3{?pi3cv8jdu|O|828&tiGiVG$B7ns)p<=zY++IHRy`Hg zepxnrzC((IsttpUNC{&nI)XP$^i)N7r_!=E638I{Ub_Z40 zxJTedt&L9(4s%^1H40A$8-FJxcXSmDIm)%AZ#Q_AM~=BR zB)}66ZLd&QSU%L)EAgz+4OwQ{Qz0LrV*?uOxC#+Q=QKJeqU3Fp8D>kqOQlco7dIXm zr(7B9SS{0QXmaVVn(HexE-zwh2VVg>bK_0D3qUqqWzM~xC>-C_LxKn=kx2uQ{;GuX zKEFii=}kGXMqvRD(Ye7HCD>zp0itC5<=qb5XDJv>udHeD`@Rz&fSP{Y-Sx@&->IWh zm<8GSrtbI(1P^w?8QWhGD_7?v=2w3-X;3L}L)zFpRk<%jtaG%ZLdQf@`s}$0;v|FH z>^eKD(93DEL4>8WS1%<7vAYx(7kwFPbTYlr-qUN?h_SG_zLu+lY;4HBSg4+qxxjyf zhm(=}pd04k%x8Osu#_f98LUgo0_^Ox5LSzH8$ktoE<0`ZCG4SUm3Obj8@p`0e*(=?? zZNaXSJb7GZS{6Pjj}>k-Q&?YF;q7x7;v^s`I1*eMorcv=X{dh@@kY3z9xt3YaCftD zkVHA3CCo6ij+f6|zzO11+A&Rl7wfQeObI^a2B|B_>85f{iBZ zkaDqt45b_39|3xp(53U9(WUFc;6q@y3KWD zQGy3!k=HUbyn_dX{TF-6BMPPZ?r0&}laU4tS1Uf3{A3B^$YR>0r>$Ffd|6p(>6(gZ zUF4;!jwcwScP!v%XL;-S=|=WUr>ibt1;`lojW0OQfI8N1qyOCNe!J;#*o@4$+|0#Gr2?S&=#(Q6r1Vl69fgc1}cDsNPUr4rpYL=@)TLEW#< z?5^c;#3SSXzS*6uynFZPLXY*Ucb6)`MY9g^Z57U~*(Ab>@DMTZTt+Rvu*#iNIvcG4 zZB~ZdTwI9nwvxsSnkslO3{m=pOI2A?D44-;otUOsLLmWK*>T+x_+Y+0n@S&@k#W)2 zvB-5nN*~_JR7@Adnv!H|#d|6u+s%F2vleXgO5LhTTN^6dkTu)c@|@1xWD}~_t(PA} z514#vZj$p*IlJ5!y1XZ0KyO{YG zl+_J+tfVIs}a9T%3pF!;p};{>AtnLs^Jylyxo%=?Rs}uU*hac>-6F zv{ZsCB~6~eP{->{j^*Yy1(0ivyX;P1Ja-x3c$z~6^YX;Sc>$*y6>^~nnqVketVzX*->7pSn$>l(HB85?agI{N6E5pG*j7=Yo>Y8?Z(;zY2mKuM zMp#hRl@*-!(aTsFkVDC9ny%eQp`E&|BK z+(x4WU79s(a378(%)3PzoC4cV+WFma42As|_LR{;!wQT?sspI8+KX&& z^KkT|_#C~VW$UuQg&!Zf^&N+0NoQ|o-$|6|!_oAWW}m7dSVev9UsrTb^*()eGS(D% zquJs&3jKFR4vsw9Y3oesk{Yudl`lTzM`u zI84*pOLwBe*NI$<>F-d6ylX+~I!-OiG?-wPn8&Nr6qxHdwA6B$Z3DPHA!^bU0?!K% z%0)x-%DlD!50G;lq)f{9NVj9Y@f+1F*%xP%3@jNSne6S!iK)pvN=3iCzime5yr>Hw zJ_(2Jpic}`sZ869&dB?U8_6ejNlz6n5Z)XP7GyIaYd7Hc8o_I#`8t|J!Ta7%dqn^N zc6w=&cPC3^AALt3{Q?p-VB3TE&G5wKODws$xj3NPxaOuje0DYHEtV&|hwk#QE|UI+ zgg20P{d^^|ZR7Z2u9H5W@%UCO(`=|EfK1|)T$%JfTwK0Ul9wY4V-IehB#4lOhlXvv zy$}1J7-Yb_12~ZL^&mnNvQ=o=1%hya3|l3{2o6z97)UIoO?q1bFyN{JU=JCq^DWpu7`RzD(|K;f@M zIgbdmxMekf3C^yKGKF(FyG7E4Pkbo@)C(Z>hW%D051$(FX2*qj*C{*wa51~*2Czz}%Biol4THJmN-`~|+@2FH7 zC|-Cj&_ia$S6?v3s~J-Spt>7O5YAIYL$IfUHh*6v)F8kxuCbPcoN#Pa=};-ClowN3 zv}bI}0~>tuIHgwVC2K+qqpD2B#;I3*@2@54f3~(OI6_N}Pws7w_tQ?<`kH17wA;m@ z0n;ITY2SXz6>1_y*hkvKlu<>>g|Fd46vAFl@d{;-hH?SeVvGP z5lyiDLZX6>t_sEU>A;=(+?qJVH$YvMfbIj+2ig+zc z+gob^P_MznWq7lH2d|kwZ<3Y)&m%^wRj7gD^G5*^<-+^c?&itans`kFg9XFLK`FI9T$vgA)i{+ACI3q&&xGJCC`=2~y{?jHS;YlBuCeH#j& zvf&9C6FOt?uw5QqR$b5}E2WIs3B08q=BON}?!qSjI}nsc4isw+sG&Q92OW4x&cz@U~aiA`|BxIA3j+>cyAab5&Uus2a_DA7O|B zSP~?T5xy_STnT(X!0^9AY|Xdsw<3hudU+-E#TDJLo>Z)QIKLWQgk|_Pw+)tL8KMjw z=lkrzuw~`RZ_AFP^k-_PSTF^{y}*Fj=pBcJ)A%*%E{8}`vGiZNc@q!JZ8+PTJow{R zX9@}5$RG@J{fLidRqLTGY1A|YWsa2MKM(~0S{I`pp*1y2 z&fU6k7X~d{WE0&(lj(YKBO&dk=U*&w`wbR>LR83Ytt-DM?e0_di$kre$$=u)atBD8 z&*4CI?(JS@9HR$)+CC%dvW%ar%1(4cHkNuOr^YnZ$eiN9f@Q>1zNf*GYk^!lNR-ZQ zl$XtHDt=GTn>^0U%N4dqciZ_HkTCRl_W^vzXHiwX%K8@~$O)etp{t@Yb9DO&J7}bR z432MD6FhG)znX`fNKccd0_<0)Ji|Zdrh$07{p1w>_DeM*md_c{Rz}sn5ri8jl@%PU zNX6p+Qj{C3Y-!uM&EM2KyI2tHU)+w)$o9;;H(prKxo~cF-@TDHy()c+#NLOdU`5$l z!NI$0q$7*OtS0#j0rQ9SlIMKePE06qfSdIKtf?v|owYak7@HQx9$u)l?Hs@+L9Hg7 zLOlyQw%m-8#pN?@7!|#qSBQ-%0+t&SUyi$B$%}gd65G#2`4#E_WyEsCjcRRkpr$fK z<<>g`(~T{V`6q&HTu3;zasF1|6t2^l6c!)n#P~2q!XC76ZT{#O1kqVQwoS z%EP0bcF}!QRZXWedEc5OnJ~^B(yoYAylOUWRWC)6vewtvhY=GOUy2Pr5<@%b8>Bgg zj;3Lz;ggm=qm;5?U?jvkqaEnQ&mPzfv6;L1=D}~;Ji#_BRiK37*42b6XqH=Dk^{kj znqAwv?_T~PP0G1a;~WWhByu}j${s!MlAI+1)t*bW>*?(P(z2v*V77`Xxr; zu=rel(jDI1xZzijjW{61C2}XvhtaG!koqc_()nG=t|c-MdztBpJnD*fqzE)L&*2p2 zJb25W5D-c;U9%ikbX<`0SH(nxcp0wT*t<~s0*#nLxCn&xY= zykhc}HU1Av)`qdoLhg^YD1 z&^lTeM>7*G7FzC`B@Ud0kjw(l^mSbTD+u03L`C%ta|7NPI#Kdq;zjLTuRdRH zT(#b2h4ZLC*eO&J+%{Kgf+HdIp%7;&oMg5Je#T}w=_DM#4y}STV@!imb+Pd?2wJJj z4tc%Ss88`<^c#J>URb;XgTeKnQooemVRbc*z0%*(+}QS95As`lV>o&co_7m&Ni^>9 zSXAr>Z?$ou@fu&FvHe32gV?hj4lE25PV$ty4tgS5si;4$q)OOs1O4PJPhd#ss>p!k zaUiR501$tMHP!ytJaqIm@9yEXP@bUdHs~31pDP7qgnAq&5!Fw07IbsXk$mD3S-a+z7An;Z&g zT0_Oxzb_pdk)+Tw7S@e0G(56?NRDhyt1IiIers-nLD2Emq<{MKff z(@e3QK^VO+jH{V}bHK#)b&B76-;q(Dac0=tC?L0YN&zXOMD%P0(9`-mRQ@ogXUp!_{EYc57z$C+IZfk|8~{5*Yy^|h~y6uJgG-;;)tO;=$g zMp7o%kdlgT4-fQGW>)5p7#*;?=Z6$oWR$6G8ML z^}8e^p~QnqbCn!s4s`j%hzEa!L8m3T)1BEBA2cu%*8d1R42%F*-^S`tG`6Y+n6FUtp3j%l5AQxu5(dnnFNW?P`@3*< zS}RZ0QoudV?gK>G)f&Sc9M^K6>}9&+|H;Ey!l|<9NdBwCUnt4_4rm}y>bMDD`T^$l zJUE+Ti*D#oL^y>~!7kk!p1Ns(E0F`ul^wB!Nizj>v%tC800l+`q#Ywdtx63t1-Oq( za*jMQ@k}M(QDeER{(gN%$r>8!Mtptap)95xJ+*p#vF~MtF+k4=mG&i~4S?dg0>UHH z7lD{3_KCBY7bulplg282gIc<~h+!f?F4eV~RGfDaB|E-I;0ZIkNjeEBZs$M)l+U z|Ao>2efdUZ_ozVb`y{!QeUlHK7WeK;kBpD_Vvlv_<_QNFw+=VIMsSbpeJQk!F1TlD zwA$awB`+DC6!F!xp(&u*nasVQvI4&!G#`Z~n9FY+8&4S-NiEMx>qz$*dmR^oC@-#p zlBu5+{A0Xg^w=1%@nag)nWzLK=@SieYZ1%6bAEI_f0(7glhrEuBxCE7^_45~b5${3 zw^7YbB2_-UR$JUPL`oyVG;{%Hxir=zS=?_In4||~79m}Eo2kb-?RHbN^$wj%ebRmD zyFPwRAW7rp+J&aOb%Ljlhoh(TJ=oJ_hrT$=l6WQiHC2b_d%lm&=LU&YsSbzcas+}t zHD7v?z8u4vQ2wGeEh}KJ*cR%~b#WenMGpi$s@cDf9^gBdndrVXLR6vkk#DAHH4W%Y z4e^2kD!rVY=R7jD%DZWIv?5P$IC^l*JA%ku~CHwF_4t1mGzZLV01A77<3sG!F+wU^--svQGJ=lo$`rf5G%c= zC-n$QD6mY_VXWxa-CC$UJAKc&&Ip39biO?JU3DklXvtfi7v+^8)M+;cYR~hoj1n6kCu1E^US9;s7+A9!;pwM!~XH}mQjex zG9Z8#ziVDPi3RuWOHv%J-)x0@?A_+p7-Bi@T3XVkw2Yvd z+q~#sG`dVw1Ou%d6ntLbStyCtrX_~S%+Avr|H;8c*=r0f<&eWCg9+lid2QBS9_VS@72U z{KUu1=kbA~&l1$rKDpZK`InBBaLOCZczkC1X1z~MfPh#BFNN$GKRy!#b&7J+wrLQW zcFZkr`5<(7KN{!Qk-GY0zDv^1BG)5e7fn25pcE;V>=Wi z&HYtGibnNcswD1-jgy>>uaCuiSr0l|O#tB$B|S6!-Sbj2!cxTG;M)_b6T^g-0qZ!O zsGb(7apY~qD1np_VKXdy+p>k%ZEow$3C=q00Qx0TqFn?L#3baO(E)k-X5>k7OO$oQ zN_rGQZ)3u7W;Rr-B-$Z;LVeW7TdM}c6T>YH<>>B%X~e;9pNM`~xD`QvycyD{qSF}J z-vbRH#D@F7U8}ReZj_cNqlmKETXIPxvomSTtxUGdMO15&2^_Rgd!0Tep&(W##>A-; zaoY-_8u6?mj@qSv?B*3R{JoXTvhE(0H_)2g=i>hKw%i_A4bB1Q%+2hjr0Z!QUxXZ* z*?J-d~`3Fg`hHCAO@TTY_ua99{rDEPDN} z70t2nOjQfV0O_UnlWW%O$Hs_^=KhAIIdW|s&VWrc_Rp34*pdfNTNM=Z1+HvYPP70p zIGK0t&3?G4L+d;9SpL-Wld8gJp0Xx%J--zrxg1*`DfDtl-JqYoZ)Nd~^gcGxY{}2H zUYVOZpjW=2kFT>W*tdDb*+jX~=lQHfYvFaXzA@o^0`HH@ufCLm=v@QV{-{x&6U?V3 z&IVslm>p|h&0Kd&A}K0cX5B?uR;fW=&V>ajlir}j0Nmx!lHB(jiutuvFi5tXDQG&{ zCSOeR%9-kQNR5n0E-rJP0~pBeQt85TLoPuUm!=W<*vUm15AY0vU{~8v)PfB6+G(y2 z$uhnOH{8HC!t_xvi)Jf)Lg}6HiGdN60`lz>(6$tRH-(4 z!AGJ6i}U)yPN#|?j^bERQHtWEEB^HK)`IbeDS_~?CwRikhqhn^^&8vDTi1PE*k9q+ zSdS>Nd5WSqdpn)v40-p&>p+McF7}pOAo&SAXmCP|F{H*???=l zkD-!HYLY@8AoJ^QJ*iO@Snkr937T7yjJkW~kb437e3CYY<{AvM{sxoq4()UQB`#x| zqECwo=_Uo8TmEtfyk)4T-4z-XQpRWkNd{e@PlA+}5w(Ob1FG>fBrKEpr*BD*3sWh( zYG6Bd&H8nf=D6ztS7e)1-ft(f9JJ252TaTM^zzpwB@W+h9V9Rh!cA+jDg!PRTioac zvr}N%{zOYc#)6Svc#rbSu}Y6``A-erX?a$&1BiY^(>t7b^L?&iMDc5CN@a>`(Tid$ z34r|u-?v`#jjcnD5SbCWFGw`z>nb$H*@av?Z+069w0R>H)Z^=@tg?D%R8|g@yy~pn z;5-HehLbpje4oW`8Nu{q=3QR*dR`7(o?e*5rK_BPr!HAek8Pj4yvTq8I-ao2tdZ!g z)==ZvS8ofK_>-4WT=8z4sO;uwh3xD`*qy|UByw0%j}X87sp?g&ke`_}KvEXGvPh^u zr+mh#HE#rMyGsUc8heIHSD22A;;$XOYnf%$eIvR{r$6xX1`)lzK*H8-^}aI@@eBQO zneB6xwLnzghO)xobb9~oTldqNil?Qyl;iEc$0Baj-i{TN?)q#+9E0M|_}S=;D`spu z_(sLL*liwZwtXpmtiJAKLpk5kaVy6ZvI&Y)8|SfT8b2{Eq4T6k2ZEAc(Xd>7a!`Cg zoDD=@ZLg|7dWyl)MZV;@SP~g;mxfI+sTqul$4gp@FRJ#CsO%}onkz`XnXYGVmg6c_ zY-}@nwGJs;Yp0A`jSH>#Ro0UX`fnRH1LZTqTjioq@yPioTe?M;Nr8+O!1~RYNOAn8 zVD=JxlMF4ID_Amb!fYqA1T~6qlqHOpj0z)Lu>s%>gH%VnW ze(XY{&$w_yTmb4_3Dg~}=7$zY0mMiVFjX@;5{Z6Uh0LjMYT`ADFc|ExP3q_o1v(}W$?po0-WLj00IY3M&&lGJHt&#k{v!_HQXm5}8 zf2~WbqCaW%itso>%I)6fi9No$r>K9jRIm9On%%2+DOSdsKXCIIdKQ3S_w$A@a;sD)ba%Qi`hQn)m(D>s~F8XzVlc*?UWf$fYGhCI`MWBvTrBJvF^Aojs;hw@Pv^ym}C< z*jrS2W7%FI5g`BZsa&yFFm?A>KKGa>@T4Zpy_fg$-og|$P- zG#WxG&>J*QjNsOf5s2ekA=qzvv9pQtbKIl2d|KF$az83j2#>TdR7I@Zx_*kG#BLH- zA7Dj;bI%m347w<~yv)f*w|KH!3eEXb|9zU_6mn}c1*-fbky)%UX3EW1_&shb>c z6rk-UaHkNR094!qj(%{3xvcEAdo+ke?yHc&`_5FLL3L8V6pt-kFG*a~*+{DL=@SP$ zl1S*JOUq>TN5Q-JmJLoPhZ&%3$B?}pMNs{5gOEd({k;@ zYhK)FOhscwnbH%7@d`yHH_>Yp8~UTATFU*}CM7VN6GM$ngUKGBigt<}Fde0gj4Tby8+Wg}l)k9H~! zIN{592|egZ{&Mw#^JLwSc|nJ0u*Rleu2L4UxJ$^br8J1j?5^F~Iqu3($<>K%Cg!V% z)`^x7P`br1Qa|YvWgu?aIEVmCmDuE68gj{rogzyrpK}BYMI^C*6-Y>$aFG0kECFVK z)z1nZoOLjBzoOSD?$77SKS(>YNVxD}YNoeM%>(Oof0$u3WpJOPwt3&AGBz;HI#8X1 zr#`UF`U4CMbPe*4Epod7Z5;6Agg}t`qYofUE)`?vUKx@ie@PJfPi;ztecHa zxVnkq;acm3J2NGcKM<}dLlLT-zf5VUQgPSRBfVml$ej!Z3X-ixeM8F#l`2h$?Hr=Q zD8&*KX4M3JdXGF>Jma_|QWh?z@%6B(Hv6$M9R{Kn>&^rc)1z1D>k#%0ukrT2>9x41 zjUF|-5i4K3U0iVGIx~AjX-Fb!SmB%AISxMQLWebWMtPH&1TRcf6m@CQ1(K@d8!^C) z>1B}RQ+#yDZFu|E93HuzoOQTB`5RP~rx_&ul;uhEkqGV+muwh)V>jdj(uqW@sU_Fa z@vAQ4udr7$V!{*asn^qk4QH+a8LaZI}p+-$W7KXinvP-yAXD8zO3@ha?c2^ryl3% z;*u0-z)D z&F7yvQR2OFBC!c{Ro3oSnKEQ-FiwzC2SIsnIV}@(`vwQ}Zac%Rv7s-KqVK13445La zmS=K7rW&;#weuyOopRWH2L{+wKWpuYNV&bN0e;8bcyHm+K3#}f?BS*8si$46-D5KM zr43{3UML5OfbM=teLaB>8C@KcbGBZCO0pKT(q=@K@}a zdp8v3Wh=KFvbV=#__gREg$9f4s*%PfU%gH=M!uN0P0o1Rq`O54g7X`3K6I&SAV^sE zeB+iX3q9-zDisDYi?l|p?{6@^)862;h%3u)+}80Cch7LhwR^iRVViJp10L!Q*HTk! zf|YVimbRZ<8_4xP7{sK@NQKG6t{F7>dqm9MvmK*Z$wJt`hqgIqg=jl0s~(xrYF6n>{5GN!0jAh55E_~`$(f$>ML?IZ z)MMIBVnvEmn%WJ0e-H{(Vmz2f^lomoy@zC}2fvm~b;rf-rJu(V*h4D1jf2SnX zp0b9@_G~(QeZVhQIVUoifYVXLlrU-~%8yx0W4mCrMJn-w2dG;cRdjT>;lroU)SX5K z*+p+YtewHm0}Lf`fEhSy4m4EvMWKf@L<8t!uq)?yE#&4ywVgu&3PX5r+97C8I*1uX z9ZJ-8jh4siP&*6lYx#4={JStB(B_Sdl}oF*#IRyI5QzvLbYLP$*b$3_jQjtg(?NnX zcyl9dPm%iu8*a`Zp2>SnpX9$!7KS@nt~3?cqeoj2hB?YXxZh) zK8r4dtM-T`S9$h88DCC394V)%cA=#;i<=(N>)DUa!NX(CG&Pj;whap{8KwC zm$J0ui`z#7A?yAj(^2K06i`m#023ea*6#c-cE5Ad?X3HYzR|nV{&gQQbp2$gqH&{` zKXZpgc<1C@HD&OPBY-oc*W)o?Z6348Wj3WzYW?zIL^Y|70`SZypnU9u- zQNCHQ(qlrs=Ep95ReA-a^rVb8FeuO(80{}FOHcs&iAg1u1LF=Y zjUbto`IEu-CYtRhVAxi5Hn70K+3X9s2g4Xu8aV^Q-_i?1_d`pgKL~_eQasmm>A8tx z;181aOUDgA?v{(YHDWc^R;`4EZGlsh5+|=NZ=-c=JE3)q(d92q2q7(kz}snPx~96u zjQ#?Va>%Q5Bu{HJEj3Y0nLxqUqt%cT;)N{V(sFGe?jQ>;$oGtO7#`2xPU7#1fxxIJt zz=Pr}w9NR@7KxhQblG;XaBy(Be+LvL>c)4bk5Y_|m2OL8%xQ$9@s-E&ci1CkBoH;7jiA;J8N0m3@5-0P# z=%rb{%NpCxxqKYcNjf6{jVzM!p-|yy7p=)ebBnZ8U zDj?G)z6Z8)&*I*BRC6X*@D+za@7b`#K3ggNxDNH?ZV5UJAIw7Ollkl-`KWsYlD_%m z0e`S^4zASZ)}K%~UI=GU59XzaQ0zF9(uoCUs^_wjHN7gfhKxXWD^31C!rnR{%Jh35 zMnnY>C6thqloq6=47!vCNdW;Vi2-Rw6s5b7K_o=FWk{9o9vY=#2pvjd;Jt^{{kY)w z{r$812+T8gp1jU=Jlq1SXJ_KfuPDOZK*Y9&UXRu?ya^wjUCVrx>xYe@_jg}fk7PY= z-L^|G2s+a1b_H$nw^4ui+`XzE@+YjJI&i~BRQjCP0L7C6Hk9(;gbV;!{Q|Y}$pAwr zD-r4y4FH@nFl%2PW{#+Vv|Y`$kBeV(1jygPT1zaIvV?YocHM0IN?WDBSMg~8-I0)M z(BP$MpHQFKUu|_Ymm{D!xTk1!9$5JN>8ub~!#|O7_1Ouvj&QkFuL*nk9d#yUkm2On zMLr31MDVA_srN($R2ROrJ_m)jU@NgCThQOR)!e^*huA|PC}x+hwZmxpo#_K=^4O2` zH@D>USer^Y-_1mYyU6x_I#(sp>s6YR^CJP1)!jrFa+xGE_>+4R?;5gXKCNQksGcaF zEr|q#821e$kP>Tq^)T3v6=oz#D1{%7vS_gUN{2T>-$iW5kPJ;*UY11aSUFG{RZ*i% z)0ZGWH^gYd-!rc#QNs!Ou}APJ))T|HGuJKnM4mp9w~yrE^5#Q~wn*i_K8gvFY&GD7 z{>9pPe_K+p1z>3G9ra@UP1=|Pht8(M)aDh2kwYItY`(j?Q^yu@XUidIt3EGMRR}w_ zFi)_&;Wm?CS85bS_4Peoz2ZLU9lYo%w#~%jbQ_eS$VVdPk*s^JO{}wO@{tV{ib?8@ zRkYU1z@wNOf=Bm1k(cXLXHngCKh5yU*Z zqvWY_h*?Z-4=ZE4^`2?LUJ2@mUy2?J-D_mZ*PaRhmbNfUp3|Cgc-qUgEBR{|k`KE< zs(3sW=cRd@993-dBC9zPA|@rZn|uwABR@K$28|SO)$7Lhd^MQ9<}J&UU^uwX&| zv1ah998R>XqStcx(>*?b(i4jSasF`a2V3hi(ugk4Ttt4)u$~!DN@%&!39pOj@bD8}N8A#-moK*79L{jKJl_r9l6%w~ zXj*ePnJwO9?G0)8QZMntKYisYj2y9d|l)voY$w^nV ziW2LK35(4!_x$k6@>b0gla;TQ;40zK4cLlpMsM7nl&SW_5%N#>iSD0G3oj*CKMddR0P1AkReH)i4kg?1_{PSDHzO&Vj4i$ePJ&PRs9Vm}8K}3RF@6 zfWdU2Zas<{GEybma0*#4NARWZhi>_#3rgJ)Ac?_?$ui=$0L5punEJvh2| zdNciryJEL*&QQ-E3dF0?R^A+leG+?S!|C#&+l!xD481?vP+s9x2*v{0J%MkVXw|${ z_l@1w{QXv~fN@G0>TM00YXgwo)ylEF1V$QaB5JMITIIrB{fC)yIo^7Cp0WK$AJz3= zXCHg^fs+ZNOiueg{L8&kXXvg!YckfZc4_MIW81!)w)Zmx$RRaJo_d+BXWw;F8Vb8p zlObAn_*9gZyf<#9vo4i6$AUVfa*e*{jL0}?&dox3{nr2*sh3pUsV7D@KbG|4rUAkL z9bh#(mqWs$&k{S-t)b+ma?g2}LM!(GRFO-Wrmi=XW)Ni-O+FM>GAJKVn{^P1aYb5ssB#W>!3lyJKBC9h)@#yW% z?)XsiR_@FtN-)VWr~qMic-ZQ0_d-uc38Ulo8^w00IE$csD z8M~|NVK|ZQ&2IbBB&tsbv&fNHAV$(en6^XWJquN!CjVzQRR1MXmhFZF)9p@S{f z_n8qYJC_BQv!Aqf#0RX){wz?fA0ACq$tqR#EbivBWzY_JAn|oGuLjMucd2^IeRmKc zr8HL=bG5R++6ARyha*HTto z+U^_$OQlPPIcIi#%c#!y+{X6m(@q0<5Hb0Zw&7Covlm2xb5gSwZi-|vui;~4qA7GI z*PyD1d9c~UOk_vNoTg~YDi_a?}SP@F5cN9COu@_ zT!srMw|%RcbpG~|Y&FXL+k@V8mfq`$T>%*;S8r0hWBHz=U=UvONg;2NDOwdtpmaS$ z5!fKCryslhMNU3ekreDS^|c<(w@!ot_n#tmG->dk7^Hh|htPUfj;C_uzQ8K)8e)lB zkb1+Pz%AD*p(!>&<>;| z&xGB2s9aU8^>By`VpVy)b3H_D!#hG~Wvs%nW3KqnOC2p@zR#6B2VnNho1+Fx-a(N7 zxTQQ&c+Iy636NWI*Xo7xPu)9>O#!Gl+CYVt!Ma!k#mI-%Pt$gJA_I2OYC@->TO``y zjL{G4l{{S({GrJQKEfx!U-$`FV1bTRO*YDh)DN~n1SG3(vR%X#l0zPACCSylljis14LT#r zB$`(?@C<=3Q)giz!JZgY*r4=NTi=_uH%Ki}Bc%YY&=B{Y*)yo~YAyisrVBVYCYITE z>Lo#fF@?pL0#7y12YpvN@*X&?)w`G5Bz*8lRs_sZ>P~C%2JC@T)+Qw!yCJgU=2y<0REL9i^dlme>n#{G^Px1E$uNR*^2 zv75J&2l8!Vl}Q=kdH!U6qkgSJfKUCWN@EZt4;zH6 zmem!OCIZ1x7m-<-$c?a`Li9i&=moWbwQ38)G*HjItX4afeZ5n^+cc>BxKl_PBg3?cAMug<_r$fGPwUA!FF zrkgic-=CDk*4MG&6dBL=nRRS^?>99nsf{XTTdlnvIcuVq?ccE?lxc_nIHMzwLX6aw z*D}Yn`i$DJd)Hnb#4|@#k$xc|Vo%Q5X7>?uMiHPzTU zS(VsyXtQUglSXlq2dfb!8DsM;FJ07E;2>VBmLUCHUurt1qiB+-H~>&9C^ZaUqYz7I z@}}8vYt8JRXPsAT6$d;9pgeFost95K!^}P;!AUQvI5;85AEBWUS)P6oDu-LbAN~?2 zMlRK#;3%z~#0ttkgw^PSPx2VmonyXIOxZ1%Q^%zHsG-*Vj4lHfVOZC-kps-EqArD3>Ojnd@mN-1@dW`#?tXSfPv zdImjK?e2nMe0e6^f9-yt8y+txyZkiO z#<$BZLdo$cK&f~2%~istIRWxEi>n=n->i1u!PQk0Ga9qFNNPiP2b?1T61%r);b&W_5xqL^0#xgSn$k7ihr@y!b5PT2R|&TpZ~>Nis|TdU@WzY2DpbgvMK28Bc_ zmb@zft<7a-xV;{gSZt}ZMG>5!n(64IuNGptKT#~S*IQGLK@)+qXNz7i=j!}mKilY% zhZc_n)@^`)t;^YU&YYaR^6Q(ftvmfMG8VjTJJpj&X`l7LBQ2|JLA_g4-C(vd7k6Hs zfq0fD^(`fX@OueTn{PWshfeO=pOXfhoR!WiA}Mkla0>Ipq`9K9(#a9cxub%+2enH- z(=Ir#j#WIdgY}aj5=bLID(fpvFKfJ898jfrAM8C~{9(OaH*#By4Ddu~m~Zf#(>5(8 z2rCy#2%1xYn&r}^p*?LYu_BbsLx}-0&)n(AP6o_-4EfTmrj^l6ZZns|ILFezCZ7}HyfOPikkRvHQ7%3YIRig*5a5~EFg2hkH0RFN>88ZLXkxZsDW5d@p~dMT1wR1*H?Nvej%olWldChMrAY^w zG2GgmWuZ)M<*eD+JHZEdhcY~RWu(rG`+DcAnmBse=Yn*|mi?O~W_^|rPGqE4*9ssc zIhR^JFH4QxNB?vQt|aDpiB?d3F8uDf4g1@oycW7dixXATUYf{cdQpsmzYmifSd&(M zl+~3)@#p>hWp&0eK;OxPa9?;1EQ4f*Kkj?h7iQ9Hn$+r<7lr6$s`TP$V+awLX#k4#-~hVz0)w z#^F%4(aZB=7(76G=0f%^rJ;yG$W3>NvS5(nqvChv)Lg@y>3C`OdDnkzH}XE-1isr3 zRLCBVx$nT$GJ*)0a5Cg1Rm=-XbT^_%UAadd(n_S+SzT&tD>r~R=uGx)1Xlb-`~MMU zTOfpf^FPxDkADmVR?RPgl~9I&I9SDVYtA(WuV*dfCpdN5Z)qT4FEi}LS7Q1e@3)+( zOny51Bm~xBW)>hv$rtUf#x8nr+iDyXYIaWKJ&d19vuf`%Kq%+JMBXs) zEAItGE?Q@2BpqTXr?g#kBA3_#A}tKoJu?cmC=US!ce*5bnre-NI+pU*ViIIJO^#z% zj0}-^#LvTy;~{RT~#>8=5Dk z3^Jt7$P$YFA%{pBtbT{gB&JNwuh^Xa$Sl+mAcvVVK$!Ckjts^PD-6q^D}>FjWRC6j zs0`e1U{R8#N@{#G#fsSgry$8C^U@HxI(>{p2`2ekS!kc8c+!&gk?g}+t+8!GVZ5to z7ZY!rTuN9^7aL%(C^VZ7+5)^oLdx@qnvXxU8pJp8-?D89!yb7L2qP`7Hkksln2m9v zrUL;>)VA(M-+pM}#QK=krTRksz8Oej=X*d8L)a9L=35h6v$OS-X!Hm$X=8S7RF%9!l2o#^Wt_{r^p$!H5dKyj&mAA+AoC=+l}N4hB^)||*0_ldx%u!& zRO&MldTTQ9eHwP3-mKa)&*aT;voxj{GAIT6OrhE>ryj9AOMN30lkA?#@oXA1| zZto13y@EzY@;w#s70`FNpUE1I?)G++M8AwScw+rQW#ydTm&2hsy(7YoY!wj=Y8Zx% zqfb~mAMnMvg4@N{yUa#vj^KY?TsBXEsr6gc&zD1HyNo$KWw31ZOis16$($OipU+yv zfD+LmvX-zJ_v}s=#r=s7zf8aRw|N;B`Bs8EuAsQbJ_NZi^z#XQr-->8`@C9^Bb%~_ zA&}wkwlu_5*zNWfUt&}U{4Doh^uw{4D+Yr}Ur;gZA<>pzQ$yG+@t1!8p6`CvTh1;q zN1(jS=5FV1iQ=ZzLk!OlU|vyd^BOng_e36;0ivO)5jUFz@tsneTy?{ZY>vJ$ISz2k zJm@c(n4^)8fTgL@G{g>(=t}h8u@!%!Gm!aAL`K8Cc8d`yvj;Hf!tXLt`-GjAh1{$2 zyyhCE_jE8kOhJ&1?7#$3B)NHYOdy2KIsDZ~=3LHa%ZaU}Y~JoQ!GSOM*ydMHRcw39 ziJv}HbFvfZE3vy*4Fu@u>x7;oB5x|dC(}eU^)GG1zj^};`zFH;MFZilk(!UHTNp_d ztY0U36i>7V`apbd*h&j50wyn9{=(bBR!Uyg&C2;JG54rzhjTA{;N7&c@EidN^mL3c zd647i*m&?E$*F4Fb8rh@7Fs$e_?)>*ee<4Ds$+*(o=vI$bWT2jy{aJ~q*0%n@GmUe zDd8}&n}(p_iq>50ECVyZcvz{KS~`7u8@_jYej$Q+bHM3gJhFJrkx~W^dNbI^^I#i< zk~S6lYKS&T(K%&Ao2hshLx%!;%kE9|wDq&3ZlODRf8lU7qQ_3CC48)mwR#hGjZ!x`8W+EK!g-^0c@*d6YB68u`ffEb@^HR6lrl zL5Wz1eV&z_NJ;y+`w`y}Z6*X*&cOByaSVz?m~W14Coh3qQY)aS=xYsL(_6=-4)z2| zJV1(<>+xNXR8wLjF_x2)Q{T2Q<2U#8|B{TqgA>0aFp?%X0W{Qn9ctQDSUbq%Pk3h& znjcZVI)HBtHx_PM-4YyZ)TXd0tzAumEIWGt7&cRyjvL9kwH2$^S~q24Haf+p9Jym- zw&bPQ0Pn2sd!YhBaFehE7DqQW?{C{61J^ZoMBDE~?&{1$dWOb77R!ByJSwx*wG_ze zx=T4_dY~ih57_EPFdGb4*%zXu+++UH(#6BMH{P{7JYbv@i+1b3)=da4#!$hFRx};t zSJ_2ukIta!g;!<{1ZqpX6(?%B*XFfh(WB*dhk{?MY7bM+i7mA09rmRky39;YtH|vj zP+k>5)kAq!te%q$Ym>1&vCVrD@@OSgjmE@U)Hc3Z^Vdu5=?4(h;O0v4cjg~EO=!LT zN*T$icu~1rclqy_P5_dQIAgUCU+?vV#Q6AQQs8J=B~L-wTG(USRd498C?w3>82fIx z6{%!(Q+a+9X(47SWM%k7PaozdVsqM5qf~2nBd6#fhc9^DQjfSz$UXRFH6X=s?NDyB z#7HOIk#Z!SR>)LNEpC6))3ip-p36<0x@z&8jZ9gS}YpQxsT!WF_KE; z1p=C-4>zyuspkaCQS6Or+huQ#?XvDIWDaM}1#N+XSCd?u#LA%R;M`Q!-7LqM-ACRe z;shkxF&!@QfbLH+QhCNPO^$ubZq8gEO#;HT!|6!5N$;y$lWZMQYrc$;qVCUDst36} zxv94ns|>$Gc66+r62dlW9iFYY2{wB4pst931QYcS_y#&JS7ulVn^-ME8%`lo1#Gg6 zdq!i2m6{YNGO`kH#=lN27erM3-Tp6Gfvli@6z~pgzJ*{ z`LrYQr6p-24{JZ0kjBDjng`cHdwX_dfTqyj=@Ier++;1U0sKr#N|Eg=nEVFN9CK8u$|lv ziec}8PtI?>*G*&Pjfa2|Mb$&J(3O~8)92F+=J1@Xg^8Qhni+8xFoW&=49Hq^aHM6z z2KZS`QbxvtIbbYQ;!*w*JU$zlzdLdoMgA6zp}yR5%etGwiVfQDp!0bVeKTkxSM>p! z3It6F=&Vx*^iH|_Bm+PgBpVKtmRS#@9&^r?>PN`XxZ7<`9CC4SdBqOa&k2_F69_H3 z`>TUW0yo}ybh@a_NE=FAs}b#=geuZlI@lyHbnO6i1dzO-Nk+2esOW4+cW(K_uJocI z-{~LoUcKAngZ~flkU|fYQ#LGA(^l3m&(gB5@PCDD^kps>@@E1}PAimKP5zMS4uQ4h zgsow}q)kh}DcPB|!n{0f=rK1JghYNE)6OrHHBTxA=J6n6hSA+hv7?L2N_CP!jW(0q zs``~!cG8y6JjXPjvKqx9XQ%Fy95qL0rAS2X=KHMcgT(>Gd65sPz8S2HB~sc{W>xhNBuFI*Jyvg(1h1SiyPk`huYaeRFdJSzFDq-026luQm}csgX*8m60i-4y1bV8^CfnJIXGJ zWw#l&Z{^lx^!F)Gv65%i@Jr`~gIZ-rW|QpBnkYY^x5c#j@|(e2e)7P2;*Pr_0GK1h z;~BGrF(gNh^qBt^t88Y1&@t=76?~IOxMLaK-ly&x=Had=T?`D9FQyZOmnXZu-ppab z%(iHCZ_JO225K<>JhKM@pQt$K-_5Ea9S$8xLP-oD=#_-Eq|sA{OFu$`5`4X1iM(gM za%=I{`_D5a{cj^y;K`pYQV;lwI3AZnOyF;DqMG4(m`7fIwlUEGu!^Pgm~6jFiqXSTWPOI)YW-UPzw<|Vh; z3u)e59M;!(h5=+6IbcV6aCgqeN<}KvW7rRYckx^MVIHBb#N@G6MR52>Z%gC!TKGqy z-3FV+$r{E&z2pi3gsp-@d^QQkFKFii`6CIr<$FigLLK*$@TSnhi1OUE8p-*bzVH$%J08Z>qmEu~~r(EN@e#+ZMdre`OaT>*WFu#4PSOPI&g+^p0lhOZ{uCPTnxyXxhiw_(+5YHDzo>)aB@U>=WRKerA0pB z%m`aHJ8B4$K*+FNZDVp2F-^&g5^#TRf2nfrnp=^T_=_Ks5$JO^U-o`L(uICe!eTvN ztr^)AUBWumZO_tBZmXL1%7Pbg)j#qb?=M1aM$4#)Y(hUY63c$SoL_M^QFh#F=3qcA zQ`xg>2=T#&l?3vE7W`)8)puwd?8EU+b;vKYYQ+pIy6<8t5!LK5E)N8C6F;dvn1V5gS&`c@(*lN<)T*2Xc zrgC71+zpYNIOo`q*{*cvTF_s77b7)nxRpHT&u4$6qMg~~;rwy7R$i_lXezTF=KbZ|I9OmafI0gOH*NX5JzQh)z zs92(;UJ0B$t(S?qr@iaP{1S3LvFn`T$Q3V5I5c~!30WzjuY|u?3D#se`18zSX7mY% zzA}%>Vy%<+lcou#@dT6$TYnO7R!m-2rnyvK0ik~RKT7DIlxv(D_BWo}=NT`!g^Md) z3OIh6KyN2TlGjwMe39GNgXQL)+khX;kPMYtA+8Cx=C5*cq7IsrIg+VdPfP){3t@UX z_w{px(}%qfW2`_L{+Ut!t_OFn5w-q47e^jUT^jN0s=#9>1{oi`$%XSMs#>lhVhCn1bFR3tFlE#ij>$s4tuTXP(eSrUI%s z8n2l139J~={$Ah|p3$NkEYjq~)uvEdyUT?EbTRZL3;6#m$RD~o4)A4F%5(Ub86Q4b zCgX>M%7+l2udUkbm^r3j6sb-*o5TynJ|e`S3%gBMh98Jic=4lsCXLU zbGD#)mF?9Xn!>M9%@6JnfETws_(O7nQDnT3{OIHEWAZYiemxEk%1WPdiUWy6>Q7Q` z7U2idGrTqnprKOy-EHoF?)HW}or7q15Kh5z;F-)9o+ePP>Ln-@I1hi!56VXLdZm$m zm!w&hi6xaCGz(ybekZ?nWx_5zyV!#5RtKZi!ZTsRvSyfoh&A= z#vnxW*vl;$Zrr*>dFy$!WQ?UdVF4L7iL9iH(JDQ;`h1b;tHuPS_o~ zFf%yKnVgq(_lUaR^=#n21w(v9_CXI+#{M7QBXe|q=Ad;bX*M6JJ-NedPwx<76kqdy zD`WfQQ>s%a2~2zRFE|PMTAKNe1J`^L*8(fhg4)YG&5vW7q%QSz{{}7BU#f#g zwr@O0S;HdbfBr^WdP<)8*yRU%tNu!ogIua~(poEe=)s+V!mCQh4(SD$&+2{5#-Qze zXjZFvpMPn))_XLdOVdupyesFt#<( zgDAdVyZ^uYMHuIV4ZqKUUw=+Ohtbx!d)hB;(s;Nf-B@qr6ptn&=FZ%I?Nf6u-sfy? z$vUOin5pM;C!@;zpZ3DBBYkv$&*=DU9$}kb4lKg$aZC%R7D z9-%7ltEXynG@2XOuFvOk?C7cGzXxc1T~1&da0s;r7VT)6)$SS1?(RIc;5x-|Ts2_fw5mw@>b2%GT?K-Bn)&Je&mWB+A~Cr*Ig_HurJI$W*0h*ex=1R$M%1d!h&8u^;|nu`~68vOz|T0 z<~g#M`UH5n>}l;Xt~?7dOn-zwKKt*Z{OuIY^PKmwPZ-iz(6@x$STVY}u5{#8g$3Ra z!}~HsC;#f`MJ7y=gl*bCZVI3H5~F80xJDE-52gRBuYZq)T=Y~3$yHG`>63E^dh?&1 zah|>Zdx8EP`>(eUVK3JcL+@NU@#QJ4ID9_oI@#3cZNji(Jl6xQf(w#)PyE?G?`@Uz zd86I>^^DUWM$NCs3t`}@P~pC^60mB+DR`lN{g?#(^=DE3ShotJS|}GzQo<}m*0pY~ z!%r)WUwU_n;~|+=j0g4KZ^|j$s9?-%Iv5uIH1PkK^FnxCyf3~2z3%oDPQkOQ$4#qW zH>Fh(Gm~X%Ck$2pe>Z)G5g10Lx;I6o-ydyhCm?`5zMQ{QNFgz}Y&k#UsZQ*9w*`VQTdI#nxyVW=62GvEKQq=Dd;%%aYX z{3`_h9qoCdFlz133@;vI6x{xl?z?VDr@tREA0T21a=*t>*KM4k2>|6^I{?wZCr5kDb{~C?)Uuf>tN#)st-c0pQvT-lQr7>s#mR(y~uM%KNhFpJ4J0E z?wS8cqTd$?`hn=H(A&QbL!X z_@L-R7sKX$Y5c@$vf(BPe=K118>}w8T2}$O^=8M*okv6WzsUS^ggu#;>o7Bgdv<;p54x zCp@vDjM)BI|EMN8=STP_&fPyd8U!wh^m9O3drWlQr~hX^!p;^fV}b@zvP$YFjp3uY z*VW;+6Ie5z{iDt1@0S_T;b`W;5DYJtyb2x{msJ&f9Qppw9l79BH`e*)R;|0U^Hxj8 zl z2g@V&{O-wE(G3giLM+nwcNkGPnKJ)K6ujRJPtW5=gM0n1MD(7$nCeFpPfxVDnO4m9 z*7Z>G6YEbW56<9qQnuqq5DWVq(_i*}mzhrHoW6GUfH>Rf;qcqv;#_1Kf{X691#DEZ zy_*awkyr#RS_NO6zJF4XZD791pyk@Re+A(0`H>U;X>*cy^IZUnLs1y|N_^w(C?-*L zNx`Re_Ef;FwAFYAZ&_!pod~-~{f9KyKe2^|mq|qI5obASkRv5ep z`^&{h@jg0-9qoKbFGg(bqu60x3NG;H`ph_J>l_ecV`Fw?)Q9cyH@QdQkn9jhO-pO| z9q#abFLo=q6(Wy4%`1D3sd?6UxMMbMPAzr~kZZZ2`m@wn)I3Kw9qAp1cXxLg%#zvD zY;Ui6$L?p|JHYS|PrhMVn`eLLr33Ffrk#x<@Cr!U7~RQ6kM9S@vG7UPwDN1hm$N_v#`Iqv|3M8;`odwat$-3Kw+M5w=~3n0C;4vpm#W zQk7;GOKs>i7A5a$cTDrW9N+NhOa)sCj<=Z=d7@c~lb#aMn7afCBy#JUjuAz(N zd3s-d8~OQT*E{2N-SXw(fUILb29AI#p|+Ti7K=~%S`wwU-p9sLKiJwu-9Tv>xXxD5m@}T@@MfB3Ij!wvySV;n*m5qC%;4F# zPU8h)%HrkS2B(aR{}q6e!x)oYAT;ID@w24JcZ$Hsa`4HkI}BDGa}L@v6yTF}bhyoV zjFrCjwxSL*&G9LDvi3^P?}<;Hp?CZ_2c21~TJJ=(NpfE}yH~P+lCGF!+x$j!&z(_w z-i~Is`_<#KRXEd|dK{HrVw8S#|NTNJ5pf*lH$5=d6XM+so4Agc<{BTvFW*7!(m5=Q zyN?SZxlF_}Jroa7O(HL8Zt~8VSG^CaXo@}eW)f7Yy0pYeAe*9vy;YrGx+dmFz6+t@ zhNE)_o0NVXLBD#j{=T?IPz+F%ybhaE=ym%jMEK&*m)y_BRQ7^kJpOhvhu|%9`<3 zU*)#E$&?i{AkTlBJV#6ag%P+ZHx_vX{<}Zmcak7Jb>xHASrFC{6JnxfK>>B+Xl2IA z#22^OGxw-(GI8G)jNKE!i*wE9W{ zFUf`pk}lO)&OKiNd%b|$tjq|DtP5os2_o(;Y(rIN@Xj$ot)Erh?;dl1;N<-}cCdoG z=I4~%7R1=VlxqV*-&fw@B?{xN^fJ&o9{Xuk2J2kpj*C7aABu1^FLe$iQo0z$XYnVt zMf0C;0D*^AkT-~CAg=$mIf2Blo?&`AttyeREbUMgYwyr1%CWG`a@(~pbz#>>>DK1N z+vVL&TZ)L^TN@Q~6n)M}+OI~mSiRX_xmlvWV6ChQfQSh=GsR=Z1A5|L_^Zx1a9C*# zmj{ce5VpPdze#(1@f8W6k8_=`^J!vhZ*>5x@w3}e(A!To#VYo)PXEHK*HVo?1@iMf zh}=HM;?D`*LRBG!-aa=ehqrz195k32;Wb0#U+N?L9WqKekHcpqby|Z@Na|qqLw2XC z@&_)@2pdcZn3_sT9?p!|j1SATO8v3`lz3lvP+HscdFyMh(`U~g&k_K2bRh=gw^3qi zW1Ni7Q%_6^Zt@(Z3zYDv2;4-C0p0g@?Rw{}TGIY`HXw~&-b$aI_Lv8$6|ctFz?tpk z;bI#b8%|bw!(=Z5(nn<#7i-s06-6laFPCPQ$QRX}(e!L;zt0W`FNK|%O)sN3$6@sH zYZTs74ra-e861xL%4!7GJt?FF77t2guJqp_RA`|k?muo=Lzjqz9G@CnKAs!PpH_4y zFdBw`q~APh<|VBliLDsY*#Yq+__$KDUA^W_%4tv&&x}f+MW%IWF)qv^Y~hO;8Y{JC zZ80#snS))7it>ezH2_+H6=KtfW$r)$SOm8c?IUVZQqqf5!VWXpnl#S3&S*ayiGL6I z4H0bdr)EyYM?{!VAY&gF){zoC3GcGq^P_I>-CX3)y~_Ff@ceNzXRaMNrNveXn&DQ2 zbrG*o}%uAGTbFz^Q58%EU}K`qC6Y}!iv2v=p9 z=wraKEx~n)X*=2{;>OR}P*sx>WG!p9ehrSNwPt1ec7BZ~1OmyAl2}E4u3An;DG^t# zi)nJ-s`j{tZBt`0OUGI11%+S!ZO6sk!(PFP`Ce?=tG+{-WgLtuA2)8ZV)HiS_lQ4= zLNn087MBuYx5Kh?mDTXoe?SVWnrS1t{Bp;2{I0ZvEeaAnncaiZ-~#Uoqn(k)LiaE? z^g@u&ay@F2F>KnE*Jxgw1`qWtnu@^b&Dn|-!m8fg(dEI1cKQjXG(*5nxbQ}j!O9PGGe>r^=lY1qAq#7C^S2A0 z>-6q9HI}?Rrdp5Ur`M070L>RUZk9go|M%iV7`H7?r9fuJQbq?HJHk zv)}fw4$*&Non+;OL2Rx@6*Wbh&uBf9V6LsHY(kW-v*?ct=p?zuvAYzZ(lgH{ioR7N zYH9ug?k62mDXtHqM#ILo^<-_P*BnGtt?y;70* z=%H1iP|1UXi&nDZMf*x+D%e_2u2$W_Qjqlr(H~&-!z%+kyhbw1YYrF2y(C6%M4v4x zad|9U^0qMa{B!MHpI6t93n@uSjE7#VIr<%{|4UA#VDix^7jS0ClMw+l@YDXaG|Yzb z2ay~pRAJM;HMP`Ky(3%qYO40NhI40MdCb!t-U2lAqE!7lKWI7oln;xj^?KP_?Ip+* zcF1NFx?<0u1ujd4dPj0A@eidt%6;!>x_9v4O@OoX@=tM!Vs;~`)>@VhR(lJ`UHYC;bx9W-$!cII>P%zqBUqR(dcjE!h z^2cjC7c@gDGH#V`P7CC$hG6Bw?2_ zz1e1)TJjsXHaEq&M;Q+@IMi#N+1RXjogpJZ-?+)!dB`v=dUwvvXTSGwZ#J-ITkaK$ z{Ei)5B&6V1WWkAYAx{BY`)AU))C+7N*IEEH=6Pm-r(M|gEV>A5{gYhTmH+vPlKD7* zP)|Qrax||i6sG{t7wm4ovL%8{B)6J4gRZXQCyCLeTgTtkP&*3HibRwT}9ZFf{* zCvHX-)(q09x!{twMV~hUrsV!}TMimyVD%a4;mou(yI7BwtvtjtqKG{ghiPAYq$}76 z!)VQ@a5tm6O52R7kMbiL%-t6?@g_$Fst#uh!wp~0zP(3#%w8v4pbs-=iQ$5ZpZ8*k z5%;5rTL9MBEu)_ggV;Ra%|n__GWdb>ypg^wXwE$-x?)rB$JNE88{TiD0>WcL|& z_^>a^su-crSKfwJx2&v9TZ^6c^J<_LvF=MoV0-n)u5FzjuFpGKQ${al!;mkOiBe-x-Iv~Fn*mDh7z#we7+Bh}2$5*Rf zuL<_^PGHUPPkqH(4(Fbk;J9Kw8z`idAN@7LdjuG)4J*o*B6k}qSK(HxUfY$hgV$5K zi-*b;hIHG;tgwi^d8V)#vz3FZKT*<_dj#RP*caup3}ZHi2fY9k9p6b2&vbYq&pl`> ze2>}z@P&9YEJmhx-IjZZI0`lFy2u-srya5_;xnPyu+uE%t$;iW`n79fBX9L%d&7xYXEV8wc#9^rM5pU4SC{_X0=TLih zgvNW+rX2mcL`-#5pz>ZQEonTAC>|D3<4q6Os{tHzdu?&^a-(al=;52@oqAlJULI{R zoR@l0k4<)+F%k>zeVb*4AcPPV)qLFj^C1fBO7~*HCNMH4BlW!tV1$Yy&>Kc;K4-UP z`4i}3s?rut#P_a2D;b~725Yi@V)xE<-AUzQ|31U%;M8qn6VERv!Dcukrq@QS=a8F0 zf5@f8Hz}SV>!Q;F4+)@IE4;`%5u6|aA{SgS`UW)g!|P1sbC*E(yRCdHG_cBSV^K&x z*t{-?#{hxQj*+slGRfk{SMr8F&d^IU_?u6bohrWa=Sn4sIGJOdElufOuVP3LE4Qm#he{vbM7`Zw%ekXQp%Qwf z6|R2C+w(3y@r9f0P=4omboOyB?3f&Ysw zJi|r}msrVQPfBCLi*<~|7Hf^Yk(M}x8!rg19T#f%F;(ij7a6YYw2khlXzPcoKi4?W z*lUY*pEPj*l%Su`jEB$Ix5H@c+B{&914fC5Ut4>Yf29uv1rkfmKq!*vxZ_VpNF3Hx z4l#5AdH-LL)f)sjGq-B|%Gaj?xeZXW>IStRkMMkdND%P2A^){&=JY~-3z?H)^eKb4 zzRk*P`734x!1Z&n9*1wbNNou+(#;9Tg|>#Ub|K9;B8Baz>UnzBxM%LJPl3xlNx!(+ zyEW|~(YRaRi%C11UW$X;^&Mf6RJ(pVxBrt|uSteL<($5FoAcE#Qe(=eDKHB}qAXd% zEy}?>7?{wa%pv99L;Fj0{JY<}I5UACyINoMbjf|Aj_`)faK~=Qq40~RM{c8(lt3@P z-u>R#n*P>@Zw6l>R>~(2AUmllF0wm*L7U1Iz%}FvC5>0UA2joD5=ZxgiEM4L7YD^V ztGv?EQgc~Yc|g00d~LacTl`pUCNJ;7xPj*S)jIkQw75%@I0ZQOt{=Bv>ds=Lc8Zbf zX;BcOSPZhUuAeAuZTu>^%07>YeO}>Nk{%|Slc2igyu3wy%UO?^Hco=WkZr;VNK3Jn z3(7!(^e1#+lI#RX84N)Bf7b)^_?pOqmZ^X93+~Pj9AqJnc0aIz)ox2Z*N&;bUwgW( zsEYfEJ}N<>>@NwN_d=(}DWt_YB(&Op`T|XLkT|mzw!h;5OG-?1j}cUHHu^#P{-F}c zh~J4!L~thzhzJLKh<=j%8*mDp!uiZOJ?xqybe9nX7Fy`(WQGNp+vsyJ=GzSq?BXx5 z=M;bH)qgI?Gy`4j>a&57krC$|)OS{gT2*JG3QbaNnroFsNjGvaIWfc5f!rKRkd1hJ zj#R~z7j?BDwH7;~(z0BXJTjM|Yp!<+4x#ALe|5FUqwR70oa9QXjHNh&-}H@$Nc~(-qqb+1WxdJ- zf>*t1Zp15)~LztP}KZeNvD3AYtgxvcp z8~Kp8=3crR*kue?zWlY4!Tj;`Za7HO-)e_YUu)E3whaWDq82M7n zV`M>CL2+IFRz$}cSA30&;y?-ybp6H+3A@Cf>DML78~1789u@x1>2kEK`638tu+SN!ZAaUp#^ue*vjbn;wRqzz z44S$4-)iSa=c&1Bc#K!5@R&{9!`;`GGuD8lL-b)PO3ZjrXNyn1&t?zZo(o83S@dXh zdxLLusJmMvfYl~FVrsM;PPM~Yfu?sftU?{-J9q0=zI`-%FcN?b6yYiNp28?yB`Nxw z;vlRr>3k5Yl0BimNQ#fH$>5}J+s|PEC_t8;ytVg*EiJ!oUby^CaOL+e5n!8u$*+eH zwE1h%^rPDU#@2t9Lrcr~64vx$;;iu$7{V?6a(Lm!Ace`12+~`ukBLEBLTH*ZYs@*V zh-RtUYO)q17SX-!hdu7!d3?Ppr`eCryC_YYVC%%CfC;!aNemTyBd@0Q@MDv zWE{eu&Z<%jI3WRjMiDaVf51|WV-&ZbHbCP%o&_lh`;Kxm%uV^ zxoU-Ta1_cXK+;6&uSs7ZyyO_%8$Eh>k|vUOjY;;~I>|Bl2~jY^Zl%8FGI?W1bsU(=?HveovMCw|Omkz@+Pc;~$V| z#06vPJ@3qfhMr17d3X~e7P#G?47J}ADF$0a(lB3&h++}O(dt7GB0Z|J#n0jo?rR0j zhI|jA&%fBcRnX@ej%JxkSlUQ95`{P2pUky&-S_*296#ckWT>QZyCUC^;UQGq`{f9W zuK>5!N$tk7soJXoJ3kD$Z@U~@dz`hkP=(rHu)RWKqrT>DYg$BVMXJfRdtK<1gh-X= zO_f^__7O-s+c@%n{#iVvqIlS5gW$Yh=~A}8>xAToXaK+T>UTZu z=6I!Nzd-r!N}xhs$HgULHOebo!G8lzY_WDn94gw`;En?PId?mAM? z{%NAp%=F{`TL=GV6aS^ufGmrwJafj4JRklvo$^)-*>AzMi*JYx4X}C`&`2zQl`&WK6dBjMv{1f~CqeFP! zD+>naH3{^5pOo2rznaVmrZ5Q?q{?TSQyEw57)8EKiVoyRFq13|*M^E!pVc-0Wki+X z68_2r{*z&Uq(XXxZ{UdIBzrduiCjGKd$n9(XZ29@N5&AD)>f0o$6zu__u?6 zU+NgYU+K4%3q6YH-)2Fl%s*A@#HtktS~0_#1*KA1yCp>8_bW6sc{It&Dl?q2wRx;{ zfY-3#m(oH;^Oi-- z!Ri@|Zs(X-+if0K4`D1+E{Y?e_AE%G+%|vFR;sTmdRT1Qk(IQU+Xye{mEz+qcL`VT zbVx?wKwTp3;A@a>7tlCjqu13QYSp;(($E!mY_JsZo3BcqTj|3Dh+YYA3z~BjaqwTq z$KbKAE&O8-{604(h3#MoU`eI(QlaTKQlUOBb-j>o+xRKdMZDM18TaAlp4R3G8>i3z zTk87HNBvv0jd1}U_&mLe+4$jevv=cS6ov#5G7KnL4vfh1wJ-I5W{M9H`>iAIfV1u@>e(EcQ5kDIcON~rsm~0B&tG7 zEQ!GO@?E)BtIemNYL0LfZxEt&>z;z$-aeWV8Jk$HmK?R%P=!^MvHdxqF5&rYK;8Q< zd#$H)eWn?0({SEeCwjiOq@|X>y6MAg4m;NywcVIBJ0i%)mhu?2>wc}-81}vuSu?+7 zZ}DGXzavidUrrA>5?ws4Ab#hf4M(uO&CImegXYT>vnCJgQdXGfv{EN2X1{n(VQI2B zcaz(B77Cps*It|--HDj(X0LWKXN2N@^Yu)K>3};V#}|?YaKLTq%WX(LOfs5h;l;pf zCy>8b^O~zwCDL+yU4>vRb{<%^=knqawe}5cwM9~?w1pG4#A`5Ns{}TY1q+`8*IH| zfxX)yew}UP6)7jkl_x`U=`pu;{k+qy#PV=$aP;h|)X`Ja5qA0^0h@P}FaaE?;>4rf z67ROx3ZJ_36W|Ua4a@8Jfxs567kOezE-)!S9@Fxj$qna0@)0|sD`fjNsOSYK3I2T` ztbL|FwFJqmv^FFM1M>2T0mDFy`Ro2iHw4NLg`*hOUuX2noOk*`JnVH<$Zx~;`n9dE zFi*&Vi*Nr^m;OJIhP-;c{uFKbWgArKPyoJtrbP>q*Va)s1RlY_WGC z639mjA@eC_I`~hY(8U4k#@dE8D48&=-uzJy72uo-lv)0KRF$f2>AP`bygi`Q#|WK& z4?%Dl}} z4Ml9h!uR#?NEQU&eyE;CK}tX@0d(m9Fik|ASw&apmM9OMVL04+{xed<=>< z49AzoX=jz0U2+?KMiq3U_zUJvh5~eY8E0SIV;jFuOH6Id-wX|KKZqNjW?aa1TX2Z8 zk6(M?_dc*1ODX~MmVK!(54^IOjqs8c+6)(K$}mNa*9BTZB@CI@)8goev{O9gjxxp? zyoka+TT};oXv>%LT#E9Nw?p{B8)m14+Qy+fJ?iZHzF^tAY69f_n{!)lugSy5t4Qp> zuY=U{rYH~mmYO%A=w5B{Y+!rH2-1G_kGjS{* zw8f(wvu!(Eal2-gvc~BAQN;AmfWxb_h>_tGT?P3~M=h;~BTo+bdU(oRL?Bz8`M=PH z3f_(P-9)gLWhssK@Zlt*zry#$YMmw0Q@;Kw>HT}3aCn9(x${zaDer6EFNqWR zqP2k}f`hIbpo31+SbhxfS@kUfY>1KBr_>H>$y|TAQ=*BK%lX64BmbLb^t|eHwzRiw z!ZEG-jKrb2$@xpKH+PK)*Q8I+A{*+&g4I~ZmT_ObZI6ep zd=T=hVLs#78d_T^Od00qfR=nE{3Yu}f(yoTZHyDc_?0n!+c#vW1MBNCvo~J#0p6It zRQw`d?5@8gUJ4~LS^suVaX$|9AXQ zzi8A#fpCt+{mp^fR*QH$tyxKnfsFF)&U`_oV(m(rM-vhm)R}yVa47@|A<=(~-rZD$ z|GY^hx0b@s9F-a;@(Vj^3+S!4`|&HvtQ2o~Ub+u9Kw56C=Y13OKJVbXx)KXCgmvgS z+P?hDCuw9&dvH1Yd+$6Oq%>bu&#QfB8Zfjc?)E)|Vkei@SoN&oM7EIHd|R z`VN|M1zrkpTQNcnPGV4zSgD*tVvkH%V9#nniniL(;$}@F3b5R5#ls!Tfo%H_g)M%g zZK~B9C8$JDOxiwB;MC+D7|nqF!Y6RM%Q>JKc3<`K+z=S$*jfojIR39Q`E~OdK z*x_AL)VN(5v~?z|X>*-8DWRwVy(bSd4lF2aevikFl?8&22RJM_aF@H-MZf2~6?fQt zF>D)Q12?VrfASA$9n8*@@!x_bQ#G?XM4fJ8U`pS;zwd!*0srh5=T?qKmT_9GP&3U2VN%4L!+ zD+THDwUI3WzC`@%EZT1eh5i3@9p;m1?2%P(*MA=h%I}onrv(q9x^`3!D%qv4P<#~0 z1ANy=%Ai7?k3x&5<2Fy9L+Az;Nhwnq6Ff>uOWwUH0pluP;25t9c->E_ z7o>JVLm1LiEiO%9ljrp+WR&~4xw#h$;b$`SKhzy1$|uE%3HsDJf?(1WlG1xyQ7R$7Wi%;xD^z9AJK0!H&izhuqw z>y*WbZiBLyE;-Dg*0-!bJF3mnB$Rz{}D?EXohmF>x>EsJs1e{nNZ zy2{bVv8R&qBcHMzt%~zBR9-wSpn9F0j(95=&vhq_r}f$Q#OsKhw%8M_bXsR7C8VMJ z-_*n~?Q08(t{+*>G1)W)44rLF(DqA+f)|rLuixOR(&tj`Z$wqd-$Gg`6lebn^f~^J ztBS8jH!GlLIl8IPy2j;t{Dhp_?kn1LG>!NA1R(Vcf`UFx<)^^rT>`ERWYnTp#*Pzo zz$7ejZBlOejw?S7TWDE$(3C=|sINrZrK=u4cX@43Kp))dKp2156@&W49!Qxg((!Hp z379TB-t^l6-~9~bq$@I~8UG|9nE}AJ=7Z}F{#S3}Z8F(*-A;TDk^rPRzw{fLD^HC+)*kImCN<#M5~^1LoETSQFe?n+_~ z;>w78g!-KgEtny_ib)pJ&+<>y3{UMQ^{ z71d6t_mB?NJE`FyU*FkMd>B!r8Z;|F%)C zT?#SML*1b;(F&w6jig))rUsg(niew`v!Bb#w#tw2qqR<1(R=r_`h)QSlJyXRc^tpe-~mL z2MQ+=wFz~_@eJKg@oNnJJgqs|qX!EC`}*m&1D05{Hi~%%G?^?;8GhO6Th1H#M+oFl1r>1hDG+AQNj=%wIP!lPYs$*%4;G1!K z==O_Lz;zevSYr}&<|%TzJIWFQWORqlb~NIOK$FsWASb|5 zXms~cOtC(X5M%~A)?2QOKE#+ZXjpDrSFsxMy4 zso6Ju@+RfdT4{stm0t0OVHZ7~qrI1i%uGx#V)-&mpQY{QZBKK@EV&Ce{%VDtU0el> zuE>voRo@lhFNnzd>s{B!A3nuhaSY4jfYuH~p2jur!Ctvy}4@Xnp1&;A% zz`C6*T!##%Taj;nfwT%tEWUn)RjT9D36s$4rB$Q>AiBnU{Ir&u>otrdP38h+l)F94 z2R`dZW|xThM+Og}D!{e_Hwcw$sel(2QzNHzvvkPf5`~3vL2V%7@@)phOAB~OCt&@q zAD}@@6~L=|0N$g-ZhMlZ8*d2?3Tn0}C;D28={^^Izofo$t3z(|I}N#83d6_#o+qh`N~~+U@9OI6d^dqwvPI6bqG`x5Di+g0em>7$8wGL>&w1*GK!f%Z9 z(EU_+=uN#s4oWX~?q0Vu1_XEd?h(8=a_@pHtau6Ian%HvZ}Q0NOK?cVX7JFQ_w9W1 z2eUY%c8Y_ZVmGj4{A~w1M<6$w@8G|wxOWVmSvjq2w1Pd!_Z--3rn0#vnl}Bc-Kk)RF7HQ(7gTRQ?}* zI**9=)4_FjF6(|`5uPk?4M#bil` z2UYzb!M62R=;K(`+o~b;RiBm!y@Ma~(vcsP^-MDf(T| z*XhXp;(Bc{Qkz`0L}U1&EReTkAd(^!2P~1Fb--|iF@Z*`><*rU%y8{}GRfetFnu(~ zL-Tw`&eM-Cm=gahf13VPDGJYaOqLJXi+QNFs6G!7OW5`m(^=XTo?NNsv$I~}pB}aR zpmXR9j92zXNF*f<0Y=B-=9d+&5_#fEvmRU)zvExLWRf?zn~uls)yLc%alR~7Ctg^2<&p8L93X_T}Hx>NSFQ$VB7N zJwMX{R|M={u+#=Kw1rDVL7KStQ=7AQ|YX=O(KqLz(Y^<480t+L<@5%KNo?>`nnoghX@ zZ9r>SGuHmi5z~1jcyDGVor=^~0<=O3%6H6^5@7PamUnQ*-LElxYqc7Ox}FZX3taVE4N`ZABhUKmEv$%xKxQqP8*0FRkaRAbs-fZvR z2DI{7Y6Icm_LM7Kz~GrspwjuE|E>F%Gc?hUm_jChZn3{n(ALID+={)9`rJDYr3`NJ^$3G{Xnmu!eXveY2$iBK&{TRw+FAL4{j_*ATwGCwgdeR z5L|c1;_e|TdSTbF#`!WtpK0zOgO=cVdG+?Q%q*sha+!HI4Gzb%=MJbZ4sy2anv%}l zr?|hb@uP)~Dyi|#9e1;YmT=u3jL~*{6lQiSU=F~uJdeq6BwZ-C)BLbJz&>s2pWI1p z7%2gtk90cQ`q(d-()peR#r3(dwIAZ*`%&a(uLp0$&8n}M;3(Hl+&K^Y3SXxiO--hm zSsvcI-!Xd2IVX8_ziId&arkwjPs~pJ;hM}9FVE!3`a4oVnZ-{o_+kPNY9n+`p5d>Q z>Ap|Hih7Z<%wKhp?5Fhb1O zGTR5VY;I;o)+ED-Qpv_OtoU0K$xlKL3No%rdka993iM7GEer8@7$Xv1MKB7RrK zt=r}&UPFwJvwQ*EZzjC%FJfg-00g_HR?W?e8|0#n3~=IcvrXCA514LruNE{)*%MIM zYDpKZ0Xvfc%kiW9oP0j$;ZA&1F>X$$bb(*`w0yr>^bOBP^|=XUnyMR*nH5wFl+LWe z*3%6!bLV-AHJ7IR?4QW0KKlIYYsLV5noJ>R6aQX4SVfhB{O-6a@LOQAe*dcDA|kXv(zm9S&*P@3X!^TSDT|%h;RRq$QZf!P(9Ub9gZm zenv2pJ7?QKvUH$#yr6YZj0LqT%kxV?OF-WG>Gcn@3<>9q5^g=%J6Z7UD!tF{vv!HK z-al;X7^k$T$XDB(LB{#54j3yMDOKN{=4ML>POQ}+Ikp|^AFwL)cTH#xcD~I!T0!dI z>JgDtZ;rBa%VUTS>*SLoPbz1jH@{7--TT^0V}FPC+R%pZ(0yN_Ckorc7aDSpY2yw$ zA2<`OdT!wiW1B}|`jQJG6`P8cpkAd9q)aM(tA$3DqZG!*VbYyYRB6?m6=*GVP(~0A@n71?5IHAwM4Q%aEE#@sM`qhyl z$8u($=KE%QBQ1?PAFNf)7|4FwNI!8nx>lb zxrJ#&epaLCEhym3)3yvGR`b18@8KbgEuP**LpHO%Epx{4c9HlpGWe8mAV!7^5II%|e z06HN8U{(MVXDG1HUW>?fL!w;UrQENCnb*pi;<_OF`i8>DC6kGR-TI`f$y8ON+$n0% z8#kpBGP%aE<8cn4Ts64Zrbvitn63O9D%+g^D&_YU3D3fsAeYcf3tvU(L`75wp0ht* ziL1l-9TQfpd`GmUOMUpca~eKWy%jAv4D~GOpSE*M4yp`~G&rMO7C-w8aOB3h8{5E*^M+nzDg;8>?VdQU6sOE0(wkKhdYwm6ee0vam+3M*wvD-t7n0v(P zR;!5}{Q>KLEgnThNqQHF_t>~y$a;70rn+}P1H|YEKmD_Oz(mc6e*vlNGfeo5?e$cN zvOQEWvYMiLl*w>kkm)QBo$@S{hfa-`!h-dCnG_}((Ac6)l#7Ac^QFlL7f8ATUD99q@(HE7X`*d04)?U?*KCu+%>)+VkJpT5=zc#83ywt{C2O%?G z39qBkKX?Z^aZ=5RcZDjn>qvqQlA}q#a3{?s=JXhU!jtAlACR;orX!X|3{+)=9jP9~ zmu8l%otoM+R7u_iu@tAz(2mJ105`|g=#Fs1VQGEN;;$)d#vLwPbvs)H%m8X}9OpQE zG#WOsmFZ3!ELe_l07X@YWYUZ z@-|_+!c&k@{#eT{$ZV>49Vd^P6D|O%Uy=x#bP+6VA2i`Oj`-jyrdc#Jxsj z9yfk{&j1HwXF9J^r<_8}%xTCyGKnR{p{ho_&tE%q|5=XsS#E4P4S>iaTqhewI=PSR z=UnW2Z-4YD3VZxa@V?;nDo&kbCB5gyJ!f(VpFa*Wl@^eD<$~!44=(rAju@HF8DBDK zI$9||c_N*=ZB6@v<8ymg%pat$h3j(4B(OLxkr&5L5aFl0vqL`7U7o^(XKwS1v}pC^ zbRbR(i*{$j??oI~lGCu}hh(?0U$pCNhyUHJ{m4IMN&tz z*JMa!9N}aVk%KD^IxacvQ@%UgpL0uGF-g&xwzx{SB?@0lSJNBu>*z7jm0N9ef0+!ozqhH9!Ew} z4?5E4@VM;SD=?Y9)_MECqlb*dj-=_fb$f}ZJZh1(<_huPQ0Z2*^96N57`k=T2< z)90stGdAP}+MO26o}l&A8raOzF-xiuA^P!QfXjtqC6N8NE4esEU_TMhE|tNYYhKLy zi8|3mh+FImmEi*B58TRT5);{zq26KXWf~W5bBpf5Oe9~b39e&5i0W^okjGCczX?8a zG@LMpE(Ja}JqB3~-}`$kVqj&!gN)-xBL|>5brB(cU-@g8A4mx^0`@X zgKYaRx@yT&xy7Ad>iJQi2RL+4f_hNGytX@A@n~Tspt+}65v))pqk@PLdcrCqId1z7 zS8AW@1!9M#E;&SRJJ=ev^5E6OLGN~{p!FBsnZ=LpT~Yc|XG*ztY@r8+azf^w0hCmr zdF%v;Y2|~?jf|!@xErqBaRUI%w~pTvuV}>n^fjT#*{G0O=Lu^8=qOvylC{1owEM{e zPiQG>{T7l{i!ySey`5M6qZuEEsr-n57?@6=IhJ&Z^^jd=3$LNKq-&M&T~-87ei5ZK zBzl{QleFJ`iz=t;yV~|ZZpVc6Z&9 znBOx;q%%4`n1JbjIMfXAw=KAxT5sbnudDXMv5k+ zqE7TK07EyFAG6P>s~y`Gy0C$*>ZT66{WXjGyJPo)8y(k(jNb(5W&SjQ2P+e(wJc6W z)CB{EqL^5r8-ew@fs0fF(_EWR_vOjRmS84qP6W6zrDkzu;;bB>bbtk5_~2dkCx6yN ztuSv%A^4akUoLtB_iq9devP2_Kub5NjDGt7<$S~HLV$niOJeIpIKFbZz6+v?LSZPfNbgqMBh2Y;um;ruX~Pr zW*h)cPg$Lr&(zM(ykDR$yMzJDt}+^(?3Pvtg;rhh#@SJqvowN&Wl|Tq3~DwULg;}$ zpzizbWv4HHxdq{rfA{dwM2`F2%%aUOCpP|bs>J z{dFEbkE?<0Vs~QXnUFQE%LLu8^tZFV3FFw}sEV9go+^w#TEEoA+-%$vq|+1Yah22H z?^e!-n$Mn}F}Wx~4wsz6lOpGU-as}hN}YH9ozEXU_wY}T@F-N9*5fswxy0DY zULA9zAFwXLOcsyqLC=Tw{*vJH?G`+aYX)RE;Sy=o-m76S0#1?vm?}Qp}#3Isr^;nuN$%dNRGZ0(dR> z1LOn4t*7h$d^e*aS!lm#HL<>DZ?>*YWLWuiQkw#wO3#)GZ4mMP;TmUc9duQh+i8p2 zguPQNhgPiAl44gcONFu?-tPaFOP|N-M|jQBYgUk|9_Gwx(*arf4;EJvUNJ9iqgEa} zp1`*TP;&SNd+pBxq7NyQvpeS(GwuvO!G86niF@py!GGS4pnBd;mj4iGaHu5A@d`Pr z1fuZB;M<>*QBpc-tDW(TpsLyPn=SZ|If?eR)1yZ>1}?2;!YVhk=C({pH4b-&r7S7V z(AuErTPg)bj;JEq7QE#Us?V;Uufq6Vf8MY;$9a;<8q-kb?c;3f4p3EH;1FU-#@}NQ z^{6O7K)$O(E~^>HS=2#}W3K5PJX}qFGlJ{UaF-^0yE3R&Nf7~75a~cu!d8q;wy}c_2bA@2a$@SPQ(sK|%!TNQ+rz!v3lkM!{@oilTs5IAH zySgcVK`9l0>|5(o;Jr1XrIp$pT+whKFLq6D4Djz={rNil)dNV$Hho14)y^&zZ1flc z9FGT$5x>=m`@4Pst*6*F5SlXiIxT8AU$Eb0#mBnPe^v$FVY$%py(iCgSaCju1W93-hMxLH(N5~DP;XCNN*r9-nTZmhEL*gfT80FztR$a`VvEC-qnhf@H2tVM ziq_1&&+2n)EHaYdZOU9JN8<4E42#k!c$Hda`{BXX>p{g|*_4%yR#bt_CDwq0%CE#; zZzZtA+tnl_jyCiR7-+Cxi91{yDcFvxS}8otzXyAWeuTZ`TV1EQ9Hjc>mR&kDqI)pN zi<0Wf_iUQhpR1}Y&tE-wM0GO_0P=^fybSUBWbg|{Rr%N)M6tbJcbsFGfc~2vh&pvz z@X~vS^LFQh4T@iHX<|iFzSi`LOcgD4ia&>%X@M+mM>ykOiivQ;tL5LV#Ka{l(qcra zTT;w2Bi9NSz|f=LitypW3sTjx+$1|Cz12y70CpU(lORN;+a<(v(bmaNh#naj0Qec% zyu_!1*tS;-Qxo8Mg;|?Ei9qdS0EwO-0dx%>i`o$nYag$#mEMP3E(WDSEa@s7Matoo zt~j~Es0y)a#l|UHqw)O62`3;+zErg3_JW86CB}*T zW=y5MW{H>>H+AFKIGPJUskdJZsWGqZNI(wa4bu1 zB0TSL`+gA3vCePfoh6Y(71@Mq*FunQO{C60<9KcVtjvZT=%v0!IrS(_2o>~NQ~WsO z_Xv=HJeO$eN&5cOuNjDTA+d$KPG7(zimqZUigHrL%FX1{LFT?i`_A81M6Zn-v$~Q z`|MU~@rzvmog!{Nbf%yDHO^mP~F|4y8oF$a1n~PUJ z-=uM+#?)7w`R+D6*xYqi?i@q7acW4Hi8_koXzcgN(kiGiOE1j^+m?+Pld{kGJF zMprdb3Yui{ofwZvVeVvQoxVeYFb!I0rb$%OVm6bKhnLelzOOGwN^N*-bzZISc?im& z1#?Q*0g?{RJ?T#I{Cgje*BxdbY3DXncv0wanLM!C4t*oOyu575ZGQ~W((TM+y@9Z3R)s@G0nK_I z;2Ub`Hw8UXj@G>!Sz$@?$(dQ^)xJBtRx*Rq{EDUb zZ=RKVEZR#OXH_fiTt_`~!)W}iB@4hGHR`*FTsjaH=OiZuz$`FKxz_|Efv$wUf98^1 zVHp0OLGwB6%BOmG5(dsz3c3wPEXQ3Qw6e`0QdDUEnSfi?K2c_L;qq#%vPi@5#POP_o^h2QgUkeMn}AGT8$7z zD|-MMa0=?>sjdqw(eDh1r;}}8tag{tjYRB6pj?qkHRQyRk7~9l(-HmlELBrI?BM3H8nhkA7TyvMySZT8RKQ&@J=4 zGWhIt(^A)<196+=my{1!P4Lst4`nkkriP1M@vpYv-tnJr`aNz1o zkJv=p!YwSP?Wn~x%KSxLp2Yj&ZFzyyg2^9swrk9NqQK8PGW&oVo=7@_a}{b)%!l>v zS8tU4vWEu^zGbblm_jV$W*@;$s^EGVVZ207u5vN@Acu>06K#{WuC6^YRHxo!{2nKx z@?q9k+;7bA^q3v5PoIbOj9DR|L%1=^%nmV}|2q1`6Ie-yJIwh2qi$1)t~P|ZyfJmAnLsbX2G z>p#jNKm};=a3>JxIn!4#+_oeEm7stpO3?JYx4+_}#)`GYl%U%AIeC0a<_&rFHGJC} zXxECWbtyE$ecwrr8fp{buQpeEn0Twc#Ah$ zdrD*!wDGlDH52nJUK?f%Oiy}8(qyt(L@N2(2g zt?ueuvD#Tiomsyy8B+%lbUAVlShzul+q601Efs8NpK%7RGcMVV)fTY%cJtM+@tMFH z!m&LnV$Pk`OZMebqH+H@Z&)^^IG@C9MSIza2iX8DRW?(kP&ild_`%3~5w`Yw6eis( zwvqtedjS{&!C5`8s{1pr{Kx7)lbrq@PX)8Q-i2!%^DEHG_oCzqUQ+&?5FpzSZB-v? zaEI$3DF|jKwnOr|7Y|@CWY?Vz;9#W;W_cE{ zgaTJ_rQf5AfiMCJsKSA*bmj8*4{&@ifK`Fvx``0E^1gB~FhOr#;)xMdMd8y&bWS0G z4-2hC^~8y=0fqvHNoD{f)#1|=TzbRw;t^Sh*v0ZYLMergs+@d`GiL|jvaj+vPG6Wn z9riI+`gen;${IdDo{i@YUh>u-owoArDmxPS#h8{{f5NHm7?8S+b|chfx@#}{<&739 z`oIEO&anPs61XUr2(#($N@4R{P<()~D7=CUG~oHOQ9NTK4*=`JFCDwmjVC9BFRPbaB^{U8EyVZI&B>wErpU>MSV#5H`>!MAF z*wbf-cA4g1la@bbzQkmhMfO}hm9t;4xVxJ0(>O-G(6!s_v1L%-!d~Y&eAFznlj)Hv7>;1|jGQU?|5Y=q)5bhy#IPp0Nq;+A{AR7N^jGJQk&~JYnL%eb~ zXQK$fD79FXyjf<2?HZY*$8^DN!z)i+t#CD|+AeTnu+c=IIaW4ejCKJHQo#K#{Ba@@ zdc?)=g^b^Q+7%6Nsk_AlH9dCj!W@P@y2X{bi&}{iZ0evBfm6{^kak0GO?#>FEk*U^ zBaM))beL(FGj;x(rFdRl*4Q7hY%$(x&AaQGFI#PMkFFI?C?~L(whbvKjBasE?K7Yg zPkJC1`&?j@?;G_xh}&hGsp4}G7-(Hrqe=L>!u{^~-y+AO1&Y-_7pXE52|;7i!b=#2 zS%}8E;vIsyoyu(B(nn9FZe8I`>BlXdCj)b-!95-cEA=a@k1NGmgLmGPEIl?7ofPb# z;i4S;(D(BEM@EX|_L*muPqirC^}VCa&QYc|H_~G8xpI*s6vYKY8nVs;GgffodG63DK08@A)M6uE$?{+THqO#v&NL?BFs&6KAe=HQUg95!;OMWpmsgRX=h$Sg!kAdq z#QC(s<^F`tw=%T{_aE9zN(r6bV?58y%I{wG;^&jNQwzZzwQL0*a1q|b#K{HH_1#O! zXXVtLc@kdN?OP4Aj-NaobMD#N^OnGj1@}e$ba(8k!5Fo|or9Y8sZK{k(M3#XG9&?W zA%IJ&@4Fk<>5g7yy6t$U{(E!#2s&K-b(JyXo~hBTeL)H0fU#Ax#)@U;?}yvzY9b(| zW}(LmqHigArmEG~^PIf{hC~YJ`k78rWXP?_PdF~~4kEU{J=vjC`jh^!n^Ru?viDi( za^)Vi-ND#b=Z?2&8f|``Z%uxir@r##n`BV%2o-fa)g4m6`&QceLz6hFYw_zB&+X1+ z=3$H^pg*Z>Mh+LJH-CZ;P}l=>s+EVLY*3qM1Sw&>z+u9Yt0HQZ?v3)pZVBuYun@*NVOT#UISeR3WNq6GHzR_AQd*mgPE+f!OK9t7VZb z)q@-#heUjU*=0wy9c~%IA*~`k=FnStV-6Hy#!g8|@YvV$@*s>9afH?n z-0h>s+8P00T?vbE@fw41iU>&kl)Fnl6M9f@Ze8EJ+~9DR!R@(u>8x+hdx^Z=-}g7a zvOe|StVZB=)o|926g-?4G4{Iy&Z$`fN1*4;-=ckvMu&A@-M66aJeKYIJp59pMtI(u zu++(XccHtD;nU{1^8U6;61UB7&b&VVwZzW>ZkG@5h}ATGx!Fm&J&5vDnHQw2Gp^aE z6S)g`Kets{kSj;qiV?ceAKbs5{-d&3ID&)oI&Xa&R#Bjuf_R@Fi8N*vtg*Vm%^njK ztaQzhJMO_n^)mqwg2UT5oRW8XHFy`KSY5v_`RPz+VC-`M{zmOKWIL2Q*+>8`08ig!GQ+f6_)BaSO*`eF%0^*BZm93~i zJSCUq*Qf6T9opX0x^U;pSa6=ZDn2PFboGkB&7?c?=cbwkhk9xgYcdt^JQY33Qf^aK zFCdWPtSPpv!+w1duT%4rPR#g@@BgFfyW^>T`~N>AqY@!18IhG)bgXPDj*(>Vb!;a) z>mW0vGLG$J?|C@(NJjR~cCzOo>lnx3_tvNTzQ4b}dGNTd_iH?#&)2waGCrGlBb_Pc zs1X=E`my^t8IRsFf8ktnyo2^-+g3vxJ^h=D=aW^cpxOMlpIY@R{l3GEZHG`~?ndtY zqN@#igXwY*Q)Tqir$&k^C5z( zyb$$Hiydxtw2b)=lfQIK+>cfxDH@1f?GZC#F;dsF969l(Q5-WWCY3>uRkE3tGj-Md z-ek{T1lP6XZj#AgI%*^{0lqxt=N31)l#e}#7R)5$;Y0?g`Tr1wNdH3sMto(pSf`%& zIi_`$Cpbh#i@RNJ^5@o%A#p;`Z@7^4X7vK`+4pZEJ=E~(hHha@yvLoBWGe)}wCduCoKkBl?(-|Fb5h2so$|(RXgHiQEFOzOIza#_X!n= zluZO(+V?`NY85?^u||nQkWG6oFv#>ek+Zak3`?AE#RzubaCi=Ad1C^`p$jnahZWn7 zo6!y)1yO}{#hu;LMLgG(*UY_3s*|BjCt9K=xQm`D);q=zK6{C#=jZ2V5b-D=lI3}( z@#YJE$Z3M~v2T1mlPv3ua1F4PSekkKGDOzmboBBD{1*%P>8^pd;~tt!w(2eIHoU}0 z1Q^{|Ik}PuNgunbXB+4Qr(JPabvP*VsSeE1GPcBZgo3Md*aUEL;`<94R59=2L%Y6} z@8*1!rq$BX+wT&_jb2N!ozQR;2}reW3iWgJ(A{*bZ#wD`Msk(-yeEC2)%L5}9IJzy zJ*Lr~3nJI5ZYH1NXm*Iu$I*tIcOJ%^&~%FOAeLf)%+}cjmCA2eKHS$eENL*_VfMA{ zXsga=vHd_B;*Lp7g?t()y}Mci+s5uM?K9sDa3@P(&YFE9*FO+lD7XDj(w`MW0rt0E z@|uzLp>%bGf?shduigQytc8=ROjmo$d*G-KV!r$7!(SnKiVX-phGq;aFMbR^sOb-1 zFYZizqe*s7SYG2A=TbE;A&%LX$Wl{FWhKVsCJlvC7_f-U3sM}nP{ z7l*xe8|j`rrEf(=pGx+jb5&2foS1{8If(2*@MmPY3FQ?rMmabBjLAhl2^eLe?Q0$W z?S36u7P)5`00qh^_X&I{K+j$c9{q|I&RFRFa%mhuYfKoczdu`)64S^-i*<9%8$;H& z2Ya_i!fEY7<60OQfxD3UC_;3q&_Yg$br$Fca;RGp-YL)%E`0w0T)|b+u&km#XHUAX zr&m*p#UB8Laan1jqi;Aj)2hZ=^}a-jPcgT@cx<^<=F|e)>4cI|LwrE;-iYZAJ*imJKn5(mG-E)A)>y{vt!NlX z;Uqy3M~nNhu9%>4BP2{|Q9~SWabqEf9KP+xI8|knAj#$gLYz6gxr}K|Xgqdl{Z->dh%?mbm|8zXmef_&r2Q&V|4qrL;-pNh%x@{u?#h2tuIIzgN^MV` zKGi%(HRpPOAa(+z$`sPFtU-}OP}5~Y!x{wv5f6Sosp2P#EIc-Asu%vyBws?v5ecf0 z`|3hC|J7;u7mA$tw77<#N#+J{Y~m15EkQ@6WH8c2#8fWL_gZ4xVGQ~ctl{peW8yKU za$3#C$JUB@!oJ)x?s*r-ruZVw>5_}bE(E`|rz5fjQ`+NN7k_j%gKMe*0*kY9iWIFF z;!G%l!)fDiwQG{b$XgK;ieKu+VZ{-x4NkxZzVwlI)gU+hxk+;%rnf(~83$s@0_9qe zv{o<=sgBsXO81{B@Rl0N1m#8Rn%GxKPnG~mz$_3>WDCA_`^Av3&!6exfOBf;mSMjR zF1RI4SD}gNi!w7H${rzzhvraHm8+k_}+UZIx=yz^Fn++m@{@13vztcGiI^~?J zpzU$;!rOgzZ|-{TY>B?5RYdEiXn*b?RJ_EOG2GPC{9gRe&7DR)oG&g)r`mBAgx*pA-F3uKg0Ro$?5kX~ za>38+qH|$D6tV{Zj9}VI{j;;EvUuAb3Q6$My^k3h%_<9&)}j7nx{wkb$D`VI_wGY( zenL3xOSaLU=y2yYVZJ<1Ix^{liFZQB<4X|!n9YPjE2neRW9HBNaR7*v*~I3G)&-Kq zV*SCQvuXaC3?ZENJFU>cV0(GKlJBH|^_r;B!NM2#$AbIunH^{M?)*mTHY{<7>_%iC z>i;vcVxl)d@;+uNev@q4Ig&BZyEG*uY`hOs^eGq3<73OS8bb>Alx)B< zyS5@XV`?gc{X8=*OQE;bBie$TJQs(F$J8Ju1~?}hc=ZS@yuUH;j^TcKwDH`Ad7!i~ zk@Amdfj|r4F_a-K0eJcc=f8@g`OB}%&a$`1Txw7KyLTt+J)m9fWS44VBz1s3w9(Bg z<*Z%%&!w-C10L8oKe4C>ByAFDJE$ON)PlnDR+$iUS6=}Cll3n{3S$~*HrV8fJGInF zeMxqOp&E!jvb(ZzvI61AWkLU|^4qg!DteR3#kT@Hj)OuaXJ1KF4ffF4~w=Z_D zcI9TlDkdQ*pW@ygW=7DSp4kTW_}+SOjM1rgN{Kn;>dO5+^>LHKj@uF0;B=)6bHkCM zC)fS7>tuU+s;B`m&<)zm{cJ3ZAMMR4W3Y;W7RCub2;3hJhK~=7cjpT1l=&*zN?9zN z9m<)Tb(+p)iH-QAc@3!np1fr!)p?Sot8=e;!0arN8)~D4Kx=9FY~&!WaipM@ri7&? znFo0T;`FU@RNj_)Db4;IbM(ZeK763^&EC42=j5+IyZHQTb{HFx8AV8|P}JXR{O$b; z8mRIq=HmF@?U|TcfO9jJ60AP|F?{u{cZ7O-1nf4}1Rh!;`NgN*4w)GnVqFE#u}zuH zHq@W_$GDVazcImofzGSN+vheR|Ey)Ei4yN zmw!O_AYWH1xRsc?YH2oC1oyqNMv@VG8Np?7yikKA!vPyfwH3dKBgOHOW%6mI(`P}5 zTz8)*=a>JQtqOr^IC;}_`f-}qB2Ze2G~w4A#8Q1W8#c____) z=l<#-BEDzY@cc0l?17n?=l(n2oHm`#E~gp>MFCq&Mbeun2JtYos3wHe2l;1TNx1uH zfpKk`KEttESQ9_X*ivtp6aU_~W^Q8m%G4QwtktWT7F`3qDIn1ibM}SQpcaN(37&J9 zyoJyI??8hwyjAMuti6R%^>qjtl3cfS7fno3h# ztcAu*6%=-&kAz|OYhh7^f`1Tpf8(99Pj=kOg2u9@azI`{t2^PBa%4+9B8XcS9)YfHo$D~SlVMhDF{233r&)Hr zN!G?gu4%g*!b?_3sY2th{#DEK`~JO`6O1|8qNsq9)=x)MH?ES8r<0%A3__<$zXNH~ zri!4SjW8kW+xlDo;f=~uFNv0fQCN!FKH|LC%=LGjLN%4VgrXZ{2*5a5Fg;hV$F$=E zx|hi5>1OT;r>zd;J6v-LKX)D6k2AV4!BNHJfjMmDu&NJM#0%|jcb-l@a_UKb$np~B zc|S)@F+~mm7byvX;8REtGgd++2w6W~=$!90oY0@Vp?W$8u5)i4$gDCyY;%f2a`E+T zDC;+k<1Nl{4$1uXlxu7OQ;m*Y8Ka;)oQT#H-1_F2B9N{zF9Ts{FGG7z!)lDw+e9G~=wXQdYtJ4!RGh_#fUlA_<|qCpT(cXg_H#fH z|K_Ng)(O9rtDnSx8;@e(cuZDGqMd6jxjqG+Rs;BV7R%wFuaw zIandsS8~g)R3A~~GlPy78ZUwG%{6S@g&MX#v}k)wO@3SlH)>9S%70OiK*}X)j)sY>G0X>beVZG<@4CV=4lfA z;Nn0{XBT38Gp8L^x=DUPWna{)72n__J|UzS6LraJ|J>q zm+SYs&YMnB43pE<<`16%IF(20xM-n5MQx5sdu~jQGyq2R+sb-(y4?Ghqx?9`uRqRi7}FY^BlN}?{o$#rdzpms3N$fS^Cjh0Qw$JF$z?2^A8IKH%W{X`AX=W<_k z;wR!U!c9N@Au+pcVk9CBS#A{LR;L%Le3>BJH@&3QKeisfGxT*mnn1~pf5rd@^Gfr1 zg7bA>d6A+vwDE!tV~`;HM>NcWJ(2p}w-HHyALSz%12goVJq5(T6m;fNOnsUhVga)H za$}W9eIR-#Oy*D{S`C9mZbGjo8`evT_t;@N$>O$vzf`O;d%9%5H|+sH>JLD$J4sQa zJ=Gufkf-$t$4Q0*a&K<9Uy>nD?ORnL+fK8Y%OsAu`bN4?FM*uAE zEB~bgaCP(TBZVIoLEb7YuMgA?bbNkkt2|>R+EN*rqLLAJNMzP$;lwG6!h+D!E|lnhxd02Vii@8qg3-_FC6K z!osORLE|NpHac}x`O|x_aiM*#zSW^i5w1TArdl8fmaH04TXC5<(waM!+?3R`|3qun zu3>{yM3!0Wws!jlZNg;#oE?ypF7YX=f0$*j*nJP6>aE!z^#f=@iF-uVw^vzX=GX!M(z~9qeB+QZq=17XDg??&V2pG+WY&@(#=jd&1<>o zR+)+{nSr;=695Cg5(If?;Mqfu8Yv`r-t23oF`<>B0%=E>G6Jy2%;BC z4LVld+XfgRz;|G(3vC`J`6H6ti}Bd~&uu~uo_4}~P$2ihlF;~I2lkgY5RE62RR3qe z@*Q77UIm`{Up&H}GyT+mJ@}&u{GmhAUck|Zk==%>K|Ei`?wLX6IE!b*1XJtJ+kWW~ z&i11gAaB0BwTv*N1GRg#x@p*;<@tP@V2{ApM6DKy-^UQEOyBKwIjkQ9E+MeTy<`@s zYG_&MFWZ6FMUpdI_-#vl8cm)?UquXG6C@nr4*fY`3OI^Vox2=jtL7|Ig1nU&Y_~b3 ze(qS(Sb3?j1Mbq?TCAJu`J2l@Di}8HRFA$DxB=w8+5u|=so-<=Lzgt4aBBA;n{;&Q zfs4+I;njRPJk1n30;YEykB--=Yv4WzH2*(ef0qS-JWq^7e$xV>_R=2ys2uBL%qEHh zkE%sGb$*yFgxnzjTbKpUU^zmxNe^sMx2ZFa-_sP1^NQ1CxSP>Y*9NV6dD7yMtu{a|8=kl1zAsh`c_jTCYL<(k9mTyjsIhw9iIx}abIIz#-EcB1Ra+@@>qhf-KB2cmN^~mWk=`M`F^WV>gyiw;fz*5^yXyQp{r*XN)Y&ih7%g2 z0Vm0m^ON0&s`INu8BL`?MqG%@Xgl2qzT55l(Pm(oa70^t4Y>%dr zy^jVoDMuk%*PY(p2>8NY2E(694dFM9ySuu+xYMluJv|p0vza1Uxc&dR5X#@cjqKV) zzVo>e%)@TNMR!g7ds>yQ5P+qTYG0X<#X&~G$-ddfJtV=P8uJf2C~n63hVW%F66=wh zMD;S-e$3+=5!_JE@9s1IAdH*nuEaV?>gv(3+LMyJ-(S+WZY z?H7#2&poApgvwunnA!!j!4$O>q~U-hbq}5P+>{#5v(oE(gQDBbg?$Tbn}a7dKNaTO z6pFWB*WCbsuQMW41d-CrcYmd7oExBbBdz0-l!>Ggs5s&O>;iB@H~G%k)m`_0TSRJ2 zoT1$_9}k_Xw3*ZqjY9ql?m$FShIo=&Jrf4^ME=iq{XuTPDZCV(yf}c$>+671(bJv#bkXk(|{#9-LO zOhnr1O6<>^W?h{77KL8jzSHvJ@bJxvdM~_y&7cs!BpdkzPEu<@?={zsq}%ksgBTe= z+4$oHd+BQ1HOT`pWApI?dmcdmjp`FwZqKdRsS)Tq*$f3zvU{_~!l})?xADX>&Q=f^ zq+GP)jrZJW_XwI~Sk*|uJK3|L;Fq}hf5V74{I<)w4xRy)dKhO69vBWmM-`_H4QA8?e;){UC_5Y zcY!+2s?YWBMOcj=lG{@@V`^C8o+DwJDxFmKrg+3DAsExCaG(64VRCt#-PNx^bHS~8L=ylNtMCi}kTkHx={ zwC^PU9Sq9mg*pT$lMCqSO=!JNW#1}0bBq8aZWqPn@9uSnT=@-?lqgG^`ITB7v-kkLk`2IvF4b8v2%9JRm`D^ zrJp*9`}P|SlP@hxeRa(ZN;Y&#dB079S8Xl~vkmKkpvfCvkGU28OxsiPE1v1L9}4?) z3Xz4!RA7p)v9a^(CsoaZdOS+Rng4DbiX>0t)3}tO{SII79zCtg-7`emwBgUb-x>WLH+3r@WKa4|H zIu}fPQB=c^=?i*P;v(j2i^8;h>TRKJ@j|am1d%@uWb@z7fn6_z)He>L%ZNGSZzO@} zT@Nv?P$UjiyYF-*%gN?1rxepx{?0^qd%CI%ik_qy*D}x(q1@`mxrr?TN_%!ThC{oQ z33T@*qpei-Vg6*5px?BxO01^qIUFdXjq~m_zMW#wp8!N2=x0FQVZRV_0tJr;L8~L` zVeXx@bpXiO_R+CQ2HmdbLGMvflb(N4e#-C`t%KdJx^x9mj2)d8%CLoJzz#@Ka*mC|OtG=zYA6U&qL?HY3tm45as9)s8r*h0e=Qz6y)$Ih0x>Mt)#4jIueaTyz9waQ zlz)wN(na~zT=akXsm))Qm~B1lF#Q)(NIxfg2i4daeSPzu1(%)5+6G@tMAdAO$8JWd zc1J2|dXEA%{VUqi3|QXl^#Z75?&w4*tFVGtBk-8AW-ilzeRo7M z97f~*^E5vpV?FwV(zWG1+C%eiZ7*l9MfcJoWgGw=HMM~{r;&6^X(#U5Qh`#J#g?87 zLderFqtg|r(ZNdcm%Y9i-}d06GoLPK3s7*dao-|d1ap-)mPR2{0w&$Fk)g<4TO5RIZS~eP&-`#x_c5c)#M8xwm zNS?!E?Kqfr91E^fRmM^n^?YR?v1rt@dnJqAG@CU2yjwDb_!_*kxd)C8MFu+zhg@66&^4Pc zyBhTg*V_r&B?zRO3hrJ(`aEU6`y=46Q&z|{Bcso@Y(g~?TaDg3l2aaDf@NpfS$Z>; z-ljJ}0`K~om=$Y2~xIA|hscAFWMC7A7Vs60#&NJgW zVyi%qRL){drIhCQa`T4hjt0#|1NMrTQEpk0m{CE6IL$@aFyCvDbf5ie4mbvk@Efy; zi$>WYtNdbNJNG!LS98^y6A)|9#_neG$NDsiFY)N^Bg!$q5F$Qwl}ooV>T{w_r0jY1 z)K26n)c(*-(YKN9B5j`Fl413ey6!dpaF$Ii&-uHT^Q#?UM4(I@I^S@dPRj&pYX^Zl zgz3sYxi7~o0o>8L?nj6ITRM6#1?}vw;|9*%izdYMuZ@UqQxMd1lqF%H)w8#0fc=5Q zpy_TteZ4XS3sv&E%D`R(B(r%L&zK2{c+qwA;j#tj52Tr;F4Hw=O?+Qh8h4NRb5e^9 zUv3-A#nyr;r)fan1eK!2>*%nhvJAV20-|8EKL0@!aNt`m@Cv5f-?}qpZI_I#p)25y)fe6MK9e zI(ihy=N9z@I9ldRy#$uGQx?8~&g__!+~ZSTbK1dX*JUG%vuKOue@a2#NI~>JxS*bB zsP#q)7;rdMzWmQJ{uV%-m>0XlPC)izyQIobK3!juvp)+uO0`M@dxfJH%5(p=F;?i8 zcT8Q$^m*+BO*7@L_VjaQy+ISFOp4j6qtgk`=-1f>KV_e1qzO7kt$B6GI==A3D=e3m z-0|7}+|lu;hv}?KwJSv80sH$fHsWiF|DuNg*e#tD0N(RH94{2ccog6L$oN0J~v7bpSny)ThAH!M>=thXW2FbRSaBMzU|y^**_cQVaU;Hvy`r zaMG>(yGW%gka1g2#I(<1<(t7CSD%ZMlataK*JWG{@x5udJBByCcSl~JDME1qmK6g;C0yZ~+GbCw`8O z2C7^};`(*RhCN^|@C{!5R&*AXZ0DStVj4u>X1S_w1>#AY@quOhCAVJ6|E1IH0h)zc z7)j@)AAJ?3nL$JYTE~u7pFXGvea12}Fp0kV{Y%`SE{G5P<;O2E21IdVg~8`N-`>>H z;ETN9?Mi-4t!g%3)0A0vreRVH5F zZSTDoW_tOJh~-UeZ3{Tlts0+FpNvr?$y1gK1$^iFo0UpQZ#bWPr2*0PF$VE)z5HUi z1(?#^dcK{;@4@kz0kqAPC+T5>Z7f?bLS7PD+1IEnLL=BU~V0XVw zGAE>i$j`%+7GO%GB=*h{A(^UVLARgA73-3ZtV-U2{l+U)26>i2lA_|`-uVpN10;rZ z*Bx{-l9*Q(3wC_6r;R3Eeexluo{i#KI?zF|U6|5R*|DCN8!Dk$GlU}_!^C~|Rt|F0 z`9%x7d+>1Bq`L3VYOSHI@ z`Vab}=gzlplL&Q!2nv7U9p8Ixo$!AM;pLdqb1@@63&-d1nr1NS%?*m<5#wv;_T1}W zO=nw`gVvG{*6Bn$psp_r|0Q)<%wN4{L&|S#yJ$$$@A6QQ-)9Zzy6f!H*!rC+%7FXU zw?@OdMth+5EEyQDVJtX}dU@=Occ`Ale&^ea1Ub&UYk+LO^M})j6`gKxqa?d25dIyM zIhO69foRM<^C|$b0Vgt-`HG4iR7Fo#>QO_?cJpuj^^=3o(r%v%4Yo|<@OcH+?j?Yz zH2bMLpPJB<8R#ydXpGgM7X-0_Q~VV&!ZF;aez>#JJ7$RBbhos?2B{A)&BirQWWw`#ie%$0760?#FJ^%0snd%@D%OgJkeRH zgp&g_xMGNc6{MnZ_EdXMfC%bK{7 z1uc^9#ZKxxudd$(rqW4CgU|s<-|WFtmSdFVL;^_H8n45Rs*T<;8wWj<{bZy&nLBs* zY-RgiG<5Rxa1+69&e3a9tmfI5CgFW^wf)`JeF?&OI_T7T>GMxSjBK~Uyr(6&pjyL; z5MgeK0ER#oe}BtYSvI+{CSkI)j4Rbm#PI;TtBdS0HDiuoC3cf1{f`Rn-}0NZZ5GEe z$7tj?_=Kn<)xYCbPJ4^-YfdvB)0xt1XQ3_cCi8GSmaw-cA(C&I<%kj^(UO0i-vfIG?B(L)>+8id%oO^x*pz${-#x!N6VL(vphi z@t@|wvbD22@1u!j-BskTXD{ooDru3>|JM}!rQJ!}B;sX$M5msAqaux@4|vNXb1q2V zQ)5VUd>znu1%S%H=xbSV-|Fea;P!4!NY!s{W=Sd{)o8i__~7DmypvDzLJaHtZPiOh z{6RoK&$DGBz{8z(d)D7b>qcwT7FC&@ME%dF)i(&hE|Mu2)&SYs`A&T%{em~C%qAa% z_O&cmKEtXJRi__A@u|qk;mUZ-j@wl8BxubP7k(XbeX4q*h#Rn#HcM4Jek0;NL`&_KGr3@7i|kKBOQ;u zfb1kuw>^LNGAP>L8Vu9<&a`T;J3f_E8R%JT$I_WcRVHIXe>PTSbe9PA?bkChJ@*E8 z)Q=lHnO<%y-%F>SXkB>>+J+9-@G@9kx&3rrY1z9F>rs-Bam5}Lhw11E;s#N+I?Hh7 zefi%mQ;Q&fvQKgvlodhpB0x6>v5a&NiApHOdd5*vFCp}guIl;F1X*_6Lz+lIRUTy4 z1TT3k_W~@R*!|njvM+v-XE^rRks)B>AL=;M^7iA2Jm*T5-<62H{nb((=z8-{n1lP1 zC-KWugEqS$F0FHq27b^FDIhrplF6};U&XI7-mk$ZkjEyMvF-QRy#era8kza8Mw!6N zx0WXT`Cy&h>#%!vgo0zn2h(}I4=dbIOSvEQp4CWkK35m5isdw z;PRBI1!O83{paazWNBvwD|oUff&rK!KuU=9c1a!|1d-`xhy+B$hhA=44=}hDrvE%I zMpviTMxIA>!^>^&2yh4w^XoCC2EeXCj<}KLBHP)Q64+iSA`S>d!m1gSy35Pa2!fG98x)M_3v8jQ76l|BF2@fv*sU z$v}u}Y0d05H9qC3DfpWj>rDU-e?<@}7gYcnM&GwBB2wK`veY`D@DV^gm_;xC{du-- zUgTu(dU4dP@=BK2@B6r;or@5f6#g&k2-lTOip5NWluL1`g_Cm7WQLDkfWjxgm(;-a)K-dhbM^7p-f%_@&#Wq(Aj5{_6tDV#woR)=7A)J7cA)byw!dXvtWW z`h3wwo#y=5>%4Ov8>G%@uO2aaWrb%G1-Yo4mx;cP?KeW7&!Cy+qrYTyG+z;Yde@%z=s6DGOSVPx6N$5 zu|25WA&FsM$`v71`s~vuaRdm1orYehRUI;n?KdC(oMT$|_`b7#)Z=uyOKR3;llk8U zjXwa&gOE&?R-cycK~IhG{bPy%0;;kA1AG(mF4>lDQu70gT^mXOf;cNkqwPk3DV65& z-cXEJ+SA#5!7v>$w)kCVsArxH?9em1hZN_%_DJr~teg{z`I$7N)|4A>ipv-4hX_rmoDmFe}5`+88rtz2u~%GwOHqm z604@;{M#Q%x0%sAe>+ksk^3@h-rIeGOy^9VZk(xu!}g*^T$PlAP2}WCs<%d~r__nP zgaIDD*Q}0=J^SL<8lkqf0E>u1|H^Jx$bIV4mFUEAnuxL)$%z99)y^Awezl=~!MN3D zvlc10IYQJ=GY>nKa$~K`BkKA!n(?7E4Kl)lP`fYVL_#*F#hOo3LZwT{Dn-MLgsiHt z<&6#+x(jVl-jBmd#1_wA=^xh4=)C5Pp9Q_ioV9XVZ*eKoRWLM9YRoI^^QHCP6}_Q9 z{F+xGU%_Q}vH>qG`pDN-(lubwiP@y*nxV7MhSm?NBafFrLnQv#{;*KLLTFm4T5`ka zTw(U-E%^A(4QkAIFmB%SPY2tj#RGSmL@n?j0v70~Rg%$TGa%*4B=&}&5*ZA{={poa zH`CA^BSmu=ao!5stS&loeNx2eb`JLcfGY6sDepelR z1w|Z1lpmOsPb$rQ^`a-@d1NJ6?4#>vPx0uPcBh)U4nkd_6RKXjdC+oL5JK$s^}r*) zNjrF5?A!z6Va;#r;U@87H^A}5Cr01QEsCo=FJj%Zx8}Ib8FTRQ&oWhCpIVB+lCY~;I+G=NB&iENnurX`qrUR@4$lUZPc=uE~H zDo~p6%fs>mwfK0Z%t4krRT9Tt!c4^$-B2zZ`NtzwOsR_c+5~%tG*-W3*!^}!?s-x+KheUN*=LuncyFLI_GlL z@J#-*Ssx2z^YL`~5wHBf*`ei-qTThOaPy3{G{p0vkH&^!O}i(0tW~{*jIY8N8!cJ% zaGzyejoX3sKeJj|_1A~0n;SSYV68tbhrD>>516$1@J)J^k|E{ zOk`T;IO{Y{nsHUZYp48IVxt?uf4lLi zQB2RWYzgc$l@z6Fx_S-5Wsw@lxTJ$$?85x4Vf8OMr; zRG{ga^YK0yKtqMPHeckz(I^d7(PeqwFQ=j5lIl1ZCf=qLTCgu$h3iR5-90&)^Dc!c zDI1h{&6VE3)-Q>QS-JbDE}V?N>mgSunAwbN(!@1(n_KpFxL8he(^UKys|1v@n1CHZ zjs{3|J2t3VbV)7V`}6WXe9!}b^~0=V-Gkl?u6#iC95D9>1QO;+T1gU}%2Qq}Cgt6S zoXHNOq-zh>sSjUl2L>3|zhBJyo~()O6FXtx9dbDa*dNF#oJ^Im$kqF+9{S+-el?&} zs7a*X)6Kbld?mbTBD-(~R~b7bhfg$lgC@=nC4?VsQee5Oh`xk8dZaIN0t27SVK(+; z$u$q4;)kOe4xZV%-B*sy)XEk*Sgwmt=vzM zy`{6xJ-nd?8ID>}<)IT|uoAGwUGLux6eXLOT9?PB_RZ}}tYiR5X4VK?6J>V{jF6zF zUP`!<2vL^IOH53sdnm<+Ss{n4v4G!}t)Pf6#cZ zrw)As37nP{EWh(;25Uc9svrC!4_*^3O^Q6n_92HFJmPnOMMe6(g@i{#vwgK{sjt$U z5BgFyO~c|Y;Qz#SfXyv7B;u}j5IcVkcHg# zEr;_+=mX1Q2porMyMyz76D@ld$aAP^zKe;HD#p{%Tu|fFCU_t8NUa|YJ5lBO=&n3g8O=DASDO1y*=mX0lo_Jp1(0;PuhP2YN)yvkF*AG3ICHj5 zi0Reu!fvacIN;^fRO^*80Q8a){+ClO0vgRsrLLFnfXa`IRc2q~y`RvSOJXnwG;FgF z24~5b;-d<~bX|=G6t1yhra$TwrzrfX3LV5rV{lzffuL!R*D)Pl>_$!w%w|pxohdgK zj&;c%(jvCJL7OvVQ{x*fb<$mGzg2Y)${62vkjcEaAF?`JK?ei76`BN}r7sE-+cZsl*$VWoc_UYtjb}KZB`M8Z;Yg^ zjS?jVuiU*wP36n;2BMy~3Ug$}xFvf{u>!wAyZSLzIQe|N(Irk-GtFzh!a8tEN?ow2 z2J^rwnSabYS#i0JzyAgICocKs-^ocf3dqLea*-e_K9NV zEK{ypZnSjG1}lpUy&Y!nDZM8ckF~m^{n-YP#Y6`2_cxj@Zq;d*k}4mFW4=ZiXXB2H zUrkxTElSvZ6p>Xa&`U7u-{nofVEq$a#4n3OiWCwq!Vsdzswxe4}IJ8=-l@klC z_=N=~j$|u0)qP44i9|MIZi{8Cm!Y>Agyr4l{gf!oRcOqhPqDuGjb4X=ZF}PlAyL*g z>G58j-OqHeiX}M$0 z+HE|x)so&h_D#>-jPm!{(@g(U#R61ea7)^)^(HrO&TFM)`Ueq#4dFVayW&TKC7 zhnvDPAWVDK&skBrI-k?x)eJ0s)f*{hLO{G=W~PMst<}DfyO|0VQ5BgeA!E*=?srSQ zzn@UMDVEPHP8799F}8pBdxY^dYHDtsA4+Xe@=V9grta;KCUt%B)f%0zn`D_G z?m-JiyIF~f{cc=><|aQ~44bCSny%US?lQ!0SY+udlo{>-$Ex`?a?&^zpA;^Z~>2&=5tbg$F-7c-kD4| zbOEsNA{Xa0JB{9E;)Z>K*}dU2OBFZl6b8>MjN54+oPSk*_m1=B;0$<71?2Fmkv(#9 zbJ%v?;oIZT|5Tl%O?b#AB=TV98+Yv&8>`YTwtC--`0$8d&3z!SLNs*?DI}MTPHKm& zijrLFT-1bK{iD^1+Opo1_DZF%ZQ9}t}?ny~0`Ru%Yl+F34Y>hFq^CL`BN|di@qMWz}ZtP~56`Cyt0!DibZmE~`cp zw&sJRIJwA^O!gdxWok?MCOvo^;h|tJp z->?n2dJ8Nlc2_+2`m*-J@QFkVIJ_az=5eOtYjy95jpkjtmFrB-pdNkxLXvuN=vCWP zkHS^=sxf9Wn<4jX$-X&f>5LSwS$w3cnQ@B5Mxlz#>A>bO;(aANbVHk*P9ctD&~!y; ztM$o!iWF(5U%bP=q)8|AS3Mbhz*o3F>a>iR2j=#6ELc-wBmDa&%FH(H^EL5w&32wP zoty7%p6lK2o;->9Nei$}qtW41*tYz^m|xl_`xUBbTH1n&_g%{oo&{FX7uA#MdJg(& z`nSsxNZW&itIv;Q*&1*!X(U!Pccpk|wm22twEMOD;%31vvq$G2ZB+l+|32M4)I?wW zYZx=V(rc`w$vkcCF*WeqJRcj?;fjire_2Oy@uyX#Y5OQXh(*dOF%ITymcGK# zR6Jq@J~-!;psD#;!DrlbW_6fc;F-T-1x1aVG8__Jy%tKWl*C#aWX;tA>sLrn>Na2JFlrVvu~BXJB*{@_v&P& zu1W;kgJ@KIZ%y*BfdsprFr2rRsy#!PoCkZSyRq$DMzr-tCvKtf@0|%Pc5*m18-eCC;r{}ffc6|%> zJzVXhOQh^{%M?Srw`*6gO~*u30@vsON7hz-2iCOz5swY$cv- z=`$qi|GE9kWt(D!JTpyI+ayFbzQ%RetuI`X0>| zx{iKEy+b{KKu^Ym`;?sv8P?L*Z;k1yA(E4kiO-W9z1F;WEjsW)A8Yf<;M)ZEfxM+waotYskEa0jcB;n1hx~N zTm_Y?;EJ(SvZe2tr}5!T1>urL4>i3ZxIDatGH>9;bA#v}Hb@6jBZ@&q{Su-+?|a#m z@z3VSez32&@I8OwDG3GAr<(c4H7Vp%de=xTdso_BATeh7i3;c#E$`!phV8exlU}3he0<((v{xSXIpKRF z-%sBiefqA(ms22)YU_e>h1ri;_1flSqPiam+6{>rFT&8L^Q!KD3I^ym879x47k0TF zv&+~+WSHA@rCVY3Z`j+v(HZmeVRl8XQ@Mr@9VEF+WQX&|mC7>}W)p=h#%`|2J92Yc zjXXP9Utg~>>6rLbbV3-v!`HOXyOr3@q#NN?KG3FFzwZK7ITok_$T{+Bhs_PNFGq8E zNMO$+dln8OI~_|K2ai1`TEUgj^dp}rnu4v?50m*>CngRya_rg{vGr@(0#|#Q#F2^@ z-Z`;;R^E!-pnLFf`&b#C@ckt&-WS+H-}(egJ}K{Z*0AH4@@X~?ADJ6W3A~Tv7uBM*3-(oMr=rp zlQ-f!Q@Ank2A^3Pt;Fo+DL*^=?G25IF7;sg=Dq8S&W0}(S(?N4L2-L)IXAfdPNAE@ z>~ntl_;sW8-ANdm)Tvkf&Nza;q5m=O+p)v5@>h-L^gWDtnPDsR548H0Ox4&-Bl2GT zyxb+rjcvU!?Q1SHdRT!SgDUp%AbEA;wAa;scP}hRB;@k&;&>T3cQ}i#ZMT+|`glOE zLtO20!)@<9p;#|HssOp!Y~+{;d(_!(z#_F)eVW0pb?YG2D+ovvKI|0G}Q%IZ=9;{lgl z@Jq1FRkZU-R@bcmh8LL4Pz-b$G+lKHr8Mn4_jR&g{(!`OVat7{8|(r|^W z{cX0C&zsl>4;&{2U-RjH@;$Va2mNG=(NC@JSbLs2Z6ylhecFq%yj0qd%3Zg3NM9l5 zI{Ud4-+D3P#Fizqy)OxiH36n|wwW;UvV}w-d7S{cEld*W& zDC$rt#}UjAX5eu|r&X5bhBp-@9#YQoH>cB!s%-iTq%enk7js>G;=9)-`X`)$6esc{ zO$kkw(CcaJOE)_c1oA+rv6jPyYW)q#VpMu4Jt!4^}#yis3WDhpZYlqcyBWuA`&B?OX;vJ-z zQ-MUR^ZA2}c(#-CRe4w?t^M=y_?^Ro(k^j^940YK`P`{%7eXbIj=P5BQCtiLvry>s zgYoesb8(pJ)TP_)Bn2$oVF{LO>id?D21Zj@Gi+Ro@^lN!}&W1iHY}s#n+ONj@ zz4jU0P4U*`>@zbc=Dw!+qIiPMw#=5o8qMI<-ro4x9ULYU{}Yl6E+s z^&hv_^N|9Jl#M!~uQO(h?Lw!We}mK|IzPME;kc!Dh2-M+$1P@8@j42k?qr3m`y}mj zkBl`-{GrvWVJR+F#EBpYM;mX$J8AL^9M(rCw>Gq@C@j-v^)5DA+n}tcwQ8X`XLZ^- zbuZeKlkT$z4Hm2U#5`mF9<)(D{h_1Yux50cD7I$8#7K(W@-4&cMAmF0M}qu9rd9Px zG=^bz9^Cp)ZkM_9F(^QTuq?zZjnX1_*O`z&A(f0Zk|Ja)rTsXs$&u_`u$$wO4 zTlsq;!`B94j?O?*m(}cmkGXH_k_BSld?QZiY3Ml}sn`%m9J?=)pz@*CzRUP4$M`PJ zyDCiwSXF^%wuU53W~T3lMB>=X{4oo&24|!~s zHJ$gCdz;fI1&8uuiBmrc2B+5yTS^cx80L?v2xOfQC{IsURQ+sy(d1dS<~fLx<*5RN z4Yp-`@b?cXC|uX`Yjd6sFSai%#hq1&nLGH(3!d+(w4IttpFU7i{cccw@0fTi8Wg_t z_zB6Dy>rRyy1w#qxd+0S2L6v1?weF%B8mgAmo_kbi+Wd5xsBQ{r13>^g&Ah%iA&Ws z_bc{m+j`IC0+SumEFJk|i(V`by)bS~dTW$K>=)~F=q#c zb~~S&jMLa!oBm4|VVU^~#f3NOe(0-q5;vQYP%*mlyr!8iC@AC953y5y&00f^9nh)2 zL)t7{ABrgzzFmnE>?XeLN{i(jU7yUmC6Zb_*r<~}D(5Jb_@cba+QB?yY0_d&mgnN# z>?O_Hc3eEpc<;u@!LjYqj2gwB3oVs|V)seVYBkEUR8WdRS8)t$^AID}TQ|NSNsVD# z6f7Ed5f$IaEE?t-wX*COZe%K*_BfSSSuBk_z+{bZbBXNJO@F#;m%HxL!hblSq&%V! z^-@61OI6tO1reThEs}p3XNA#=tJJc=AHx;Efph|Cfx9*lPBOR18m)3MK z%Y++AFv5I|{VP`AV>7ySE-f;%=V!eQT)1FRby|XT7QJY6T`g5(b>blP}w8a!N~u>)MbV9X=`CNYi7SS&jmBt zWpe27Kp%np5DqP+v-i$f=B;xL{q-!Zl5`k*LGg^8mc~sP3z9SU_(&NXE4l|M=`F z4vKjposF+eC3H=TQ14W~7efZuIEP{EQYRCmOW6q``EokO8cA}kH%lfWDYDR10OZ*h zb_Ni1!9G=ULB$TI9;owPAJP1Rix?D(+G_JELLlg#fU=*l#+h5wh&0YGpZb@m}({3UrvIGvJ?-@QLN9!!LYOPgo>-b)PgW^UYSndur#=0~qW={Neq?7Nb|+n}TD zEg4~HTjp-Uphu1$(J;e8_MoXx_=c3T#Y*j`7=7KGmy@2Owcm6Xj%#3eV_9x`7K+o4 z-Y-3lyevt0By;n@k372)xvI}oC61S22Q)BPo=)~cK$qg_mefOvm5J2Z1C;pMdra~y zlQ2w|s)exjVX4~2LDAa~32&it1xdW15uu6=mllP)2CbB)zR!|lNZCyYV5Ck{l#ek5 z<-aenWELJNe?#J=LDyPD9qAL%t8f!I$;}Nb^IC{!E-4x(rk{DtI~R31S`@H(Fxt7g zRH$=n{z|30S6w3NvVQ{I;g+MV&Ec|diT}aRXavr=x^!H|q8AG7A2}F<>G;evKC8sX zbqjijkZ_R;g7TNo-`K(ivZ%uDhtc+x=|0B|n9xUxbgbgrDC(DcS2oW#c&I-3yr9iP zv_3r7QX_!`PmlVpxys>dhL10&=R$cmzZYynQ3b7W|5XXEx7m zlTTqIUOEr!HWMh6`P_I-6*dl%$y^y5C{5WULp{h25?RBB+d_vs*Oj;M9@Pf?xZ4kD z^JjsleZJ_B%v~HfJKPg8m;zB3>wc_}O zR9cWusQ~519TL-H4cD~BXf4}miinIlu2;QemOd!)IAcDhQU-TkMOtg$(Ud zL#102fpVRy5kTU)uKN4{7wW#ZpQ!+!Jn@2DGuI!7;kq1f2kPGTQ-Z0`AXxdqB5Vi4 z0eoFe^-$<}HGMiiF9co|N`&7=57GCn(?>Ov%53XX^|g*1y0R9aeB?MnEq$WV%E*5g zXo&LoTgqF=@BbExf=rBW>!`Bl&6?PfN4+8@qP%^Z2`SQ8n}eiDlG~qslmV1>pSt_` z|6v5&rI8e_GZ6`P-c;gvF7syA2oAaSn}cS;2-q^{LUBJq!w>;L9p!g#2?mv$$&X*B zGG6zN;VckCppt>PJjJjQuDgFdJ~-&gxN~BO2c)UOni)!XJtT~g!uJy%wxdAcda(W zWS_9|z?RO!8yc>#bKv7CN8CNC_VtB`##dsuuKZ0ASLoZj+xW!4tG45@TQb6t5oJuz zrM~pic!eA9op9iiQ|Y&gap^;@j(H+m_c7Pc`aQaTJc@?MoquBJZ4anTnG^f{V5N{6 zq=4Q?xUNsdN%18FmC}g#s~p*9831l1l~D4xR2;D3t^yKggLS#KdVz8ws>XE-l#qW_ z9N1I^!3?-@9-a z#lPIGX2^T7)?q)vo&a!v0KzN3jm00<>wg2@6!G@0CjOs--9muqn*Jo#q=dt)ecj+q zvEZ_!IVO@bH5gpJI#O8c2Bp4%^bW=WFa2Ng0H1~6`R2w?QMu9Au^?t;2VsA(0RD0s z0A2(Fzzb@IUrbB{Xqg1>g}d5jP4obBA~rw0yGIGwjlok+!Fo%mF1hsKex&Y5>v-Jx zve4=uTH&e=ineF{p#CDxBqFfQQN?S2Hf2r}sN>PzgO5MUkA<30MtpB-jiu?a7gFlB zss}uk%R!+uYE^N%IX!}iqV7Yi{xa6?s5%t+3YS3df`aSo^!Ws_mf3zv_*L#N+wuXI zkwcJ78~{)-pE}!A?+?L@&(40zXD~Hl4auW?wScXNn+mHpPpu!wyopygs>f$;&SH-5 z`Gf&{k&*4OQA1_M!dJ(N94QZxu3_bseYT<}AS~^v**&f~ysAN9b&e>0hg&AZhd)MF zD4>6oma-C}iKa-D-f6RB0IJgO@_{zUTVVkFc73W$FOdF?izT%%&4*}U?d4eT9S){I^n=a~FUj(g=Xo`b z;?#7u#>RzWT#1Z98uSsRT%e6AwM4hBo&olsT^psk`<6^8501w4S6D~so^A80S-o{g zUDpgWzD9GG7BT49G#P(dJ~pP2Jowc7pJ{;q#@h-jZ$YrZ^=0AX**!Y;BBKVwtbcTiqqd)voou$(Y+Z@&rb9iNl5 z`6CQX0l+QZUa{o6;wmpAg7x!zMGxP31=W8(vQAYNR2hC_CKU32IM7m`^=5R90s05C z3-B8&-{=!X9@0M!fa@Bpg8B497h?+v2(&Byne+>CFW&opS6GDALht>7sCr(j*}dv# zF~M(RmyRR!tv>Rf+#hCsJ{S#iMWuy zAL<|RbKCI!;F^ZCE$n12d~N3fDBRHLkNW(OSC0^=+JB53RxeOA zEaH{_jo|fr`;CI?cCzhKA5sNDR(P=sj52fcuK#AU_jfEaCPU&-jiM{~BM9DSSl}2* zsFAidzXgFVqPL@^Za(?f&j1)k$qu)@G?HP_8u!?B{HXry3C3HY_S9>8uMSv8)ku>= zIDRv?h0GgNpzygH8qWHE__B2GMC$~X`v-V2$sd#K;jA}Rr1hew+T;zdLs=LN% z(b#GJm}k{2+pzAaiHV6oiQ~qY=QL;wN+{>}mGhxD8U;{r=KKyu+))QT#%xY<5a_MA zpGc!q&n5@SJ)*S9ujDx4%hZCb3+NUyO8AS6)<&_dPJxgT-6RUi{hnnl{|^rlQTD=w z#@X}D5OPKY?b3|${gUW3*y&^aq@|on_7DMA7mD@DUIFsA9^+Z zF}tOF(+kw#E(g~&ZmDyVNg?!Vm`fY`2z5cAp&A`paZu*@8LYouDDdTcg3OwzzW*}O zZdz1(7yXY6e8n=;`gWhF(Cl{`4tSJ8Nl8f@_0bgLT3GaqRN==7`S0%beA(`i-)Is> zi_H7m@R?P$lb((bNhbDDl0P{n96?<;_wqMi0|~cGRJEFv3eL9cxUGCij+=bNT&cur z0#9UeY1=~8R>#@=EW$kY11PnmlGvjrdbEUjsWko`oE! zXi_f|!wcS&w!7Tb}`Y93?tMGl&}w(QuQ$e{JCvaxTVhJ|j= zIViI_K%)f|^z4NJS}*c9v^u#7@*dI`2TX6~;S7OX>sa}mGGtr`Ov;I_E zsJ#fUxy>L>R#qQ$*VR0n?^fwC5!gO7)iA~pA+t>~dBnu@Fw4CYbPRSYnl0UtL6sui z=W?8|iC6txGPz03syu1;6F(5+6(H*Cv(1c1Y@9rz8!-IURg1)5`}F%%c_p2wz-o(n zQ$MCB9Hsd$vj7IB9O;1KjYgv9G7KtD;5sIhgEDfM05khC4t*l@daHrw7WEVRFkj02 ze(?$ckNMDZ1whCdW2YD$#lXe&RGmD~=}2+MhP|A$$6?xzN*z^k0!~G_GZ4^nfo*&m`a+dfvPd`ch~D$a3z@y%@LA> z%Q|nO{_6cz0__e~SjQnu*C%Xfe}p+QK5yH_AD9m3235zyj2m0_ooBsgXKwBq3E-;E z^U-lmf6bFh7GWb}L6fA?VwE&CRtI|%lSP3q>=EFuZ#fWbacJItT2COlI)>(iukB~5 zt?mV}>?^ckEkE!sJ9RF8G5}@iueJ#~`AeVB5K0P8{xZvs`Si1!IrzWd3TW~dIy!Gw z6q)PWT{$0kuyYF2GK|ay=WM0)DRZ%{9%$dxN{=#YB~tuvd_r~ zv1d-uZ&Bo|&AIq5S(c8fTnJU7s1)aM_Q#YYkEFCMeS!;_#4ATT_EaLN`xrTzcv@&(Z46}ssc|FtmG-Y55OWXEN` zRxNQpE(u^Sdbtz`xBEogNN`|HCTx*jyWlNhOi&aCfmx5_8T5mrQ=mp1~&5T zv1XU)I|lt9XD6x-M7QEBH^+Wh^<|02eVi0%xc|p#{G))3xuw39L-xdir2(_8qb2{WZv|Kb|9kHy(Vp zfmQSyHY6qtlD+gA@L$e{2p#d4#%rkW4Ec|s!%zNk;7ji%d1B>3y)7`S8^mx6{Qi?O z0vk3;8%}A&L@n2C;%R{YXP4r!fp1W61&#NHBdY+<;CZ2JqdVqeKX2C0D8U3 zQvfU$g*`5$@%~O7FE$~=Rr15Rk)DeXm}qopa~Z&y@$>xAf%_bamX@tn(C62xbwSGGL+sJ#M1@ z-Fx?Q2Fh*-TKw%=P@@vR@S>dP{d5-wG9or;|Mk|!3B=9AL-X$g2n<;H^97IY-G(1B zH7M(4^ULk75LS{GDtPdJL zw4`2M??D0)Dc(=d$?~OvK@yJU1ZRkWRZdefqY1`vJT;)XDcUQ<;be2I(lCm5DCjKG z&h>E9x@E2l^ZxoORqA!;D88fj+b9~>M}3Px5^OI{D9)?#cD{ys!H>pCyMQmhEY^J~ zkd=y^^zlk%(J%1}ul}+^IBQUPHTnT(LxH^h!A`4TxxxssrY2)lDRi1g4Zz!gE!&Z* z6J!3UpZdMjO@P1N0*J;MByL3f`+-4lh$Efh7k6{kYGK0?pDaD65pXL=iv@NT;g!*^ zjX?7z%Z+D|@ZWiNx{*W_u^@q+XFtFT5Hac{sQ%~}oc%^`fLuRzxX~n(XbQ1-imytM z9QpA{DmCDl5)!?|J>j583cvJJX6zv17>*gsrJ{SxGLhFVf%+3O)H!K1@uKX)hY7w1 z;z<@B3Pg~m>y&Sn73?Yy>5O^)A0SN)8Y~8ds1Ulzaog4p+3?194u&4XtYl|{uAl0S zfXHCBk`|q6i5>J@^26K4uAB9!mI3g}7VuJu<|Jq|S71sa|jeMybBZ11u|E040Qj=nn95!#xaBV zHblUK7!S7JsPtHjCT_r1hO}(sOTvGyRuJ{!MnLgf4%q70RUgKL;>oUYI~{G7jT8CR%9aqTGt%3nL8p_;xV`CAl!>#`_S zqU&kAMcFb}sN`gtFe%a~Rk-0nVG8?jKbt0%R zFfb!hHa*A6U>4LJa6o~j#!rXyXVBL`TX*yO-IadMpC+nER^JN#aK~7U>KKz(cq*CZ z{FOw9mrx1nx6YE5VCd*3dlm^M5+_1d)N|{ml(LZgH@^UyVrY5{MIv%)+fP^x?*$LNH)h17ndD#}(hAg+IQYX`Ov{J!tly)4Lsg%*Rl8RGsRMl)Oy!0piBn)-Sk$8+ z1{JX3k_k#RKgmk=zil;Tl{Ai3@el{N-r%_(-1IX0Se?d3mH|Q|a}ayb!C&(EDMb43 zQG%;L!nwVUZMf{lUyT3@oHBRyun8h+?a3At-nokFQfZ_|F zi6$~5eTRbmuc(&-BvCfN^SkjKP50j#P~5vxi3g;>%ojxb+6J~i-j6Z9>x9oS#Rivy z$3?Rwir_+G)YtYeXW>Ev<_gC|VlM4^+}`)jmgVpW9-e|%bz=QfwOI+z$LI{rkE%Bk zexf&kgOgT0=GF%2o2Z}v+#IO>VfH(?Mf!DW!-v`tr#uuPfIwr)XWo&&Fl`dTIKEt; zM2*vaiILM#{%-KTc$jAt`V3j63M_vWd-4&ILDeR2^jFrz)4? zHuXFOn!yX*89XY}{kIR}JU*i#La>Aoe*Xh%gmID%0x%!2A!%4Vd?=K-hJ>a+y2xcw zmHtJP+V_~Yb%+6HGn^-NsP;T0=)%xzhcJDi zD^!uPRNp9p;b&t-hp!GVN=X%-TDOyAt*?me9q0wa&H&^lbQ97v0+J9R z*H!h6j8cd&au$~ipXDruQ^IcFx^?CEM10-@KRF)5rDfH&?N(D1a$_Q<+}|GW@A?qD z#gX!b2n3ec45_bq=b$hK8uzY0(lL*g0ONSSNBaho59(CJUlS09-oxLyI`&=f0c7}Y zmOBx(qZx0)dJX>Y3{Z9(jn=)3LBE=c3>ixjFOSXq0j|tfHmuY8!-Dd+!Q?~=-X9QZ zK!Q?)eU3ITm6)#W-&RrHML_z33g5}XEqWM!+j{n=xhp)QMGLl#nI`>cBmqBeI<&}{;t<)t{%!fa8rEj zuV`ZlQMx0csRPOD(BssU)U#c|Mp_~0fkMa6$P3#h-j8NgLOG`l_$RkHLVp`Z8M>Qhu z<3et*(MlX;Gas)(z{dt0$3V{l52a*0IYc!yr~T%9g{9XO61TjekUWfm%0OfP>#L6U ziLU&$4Jki{aKjHMrb(n1IwIs9@=&&x+K=urMG=6JATAgNr}VB75=%Fl99DaHwI~tm z71HGrZQ(;d-0>c5CbwDIO|=eSBT2X&EqovA${ep0>P&Q@kvrd`Wc|zAn@cdMZiq_! zQb2DyEXqJ*)ShzME2ElYFFRApFx8g3g%n{#d)Ui*ff5)ADbXvNnIcWe6z-q658l=0 z@%^c*Li7*`35hozj~<8)(qBG=(Ff9f79qbfswl0(t@}^%q=o~m5TUjt%#PJMw;zWf zfF$~&Y7h1max3m&p&J<-H0I#+k9eU$p-{ZDq)6#hvWt7>@NaoHAwlvvIFlh)#=gd; z|COH2ZSAG*H5Nxcbu9r&YwWtde4|woeT}4nX=SHUs)kWke81<=MtV@-@!{3uZ68B`C^@fH!--9E_w0g-aS{921}jxZjG@= zZ&rLqOF`gkjNfaK#EOoIVLD0U5LNjq7@CS|`1(tDz&|?bu+U$&qvOY#JS)t_s_Wwz zrNlSVND{7y4Mx7A0h);JQ@^D)^{9JM!kQ%BU(pHu zWyqa&bnC9jorAoBXaogl_gMWpGb&$0L}!6WgIz8$(_h}z{XqRDuoWx9lrSyeb{kaI z{370kC46fi*^J^JC5Vs$wr8!=U4$8*h%^}Uj1*LS{TQAd6>KxbYEHDbnV&rO*xD18 zr1@*PDZ`(5k1V<0OeNBK!T3ouWgNvGdiUPF{7Jn#qU`xrx1#!%kEl7|UpIQJzcE%! zSKAKazUL`0uyhnC~JDU1b3~!76BT`=qxv8|n_7-oCG-VKs zYSLyw=J?FfLk)$F``2jtWymOo#G9wx%l{?oBg);zm-6c7S5J%qlzEv{GfJ6=!c4pg zA!u}C>geC?`h@jA{HE-&MvM&+_k0qwOChP&r!-kZut9Z&XrW8wrV?_dRrbrG%a~~w zgThR+sN7*1beF@mQ7dRZG1*#iZ@b;a`rXx~&m+YgugGZ1{gdO_(Z_dxyA9r5fbx}z zaDpp`6MTE9kq)pl`~8N`RE)>SXJIan*9Yjl0$|m`ipbv#2X-X4(>z^-> zCeseh_$9R%pAm$izRr5Y_*Y4%3`g@Gxwg^P@LhQ?OztYXg_e+{5>D5UKZ_yh@oY&( zu$0ZM>7$Y0s-Cz=9Z5tpyYRQx5QHxlrgx z4VKNIyOJ;6RlSuMkKis9MYzlbx^ z0fT2v3!jAjN1cs?D9v5AG3?`6sjmtZI?}PCISM#7s^7+Qt;Khk+Zk9925Zk>fW z&E>%gJlj9x3+X|i;dE!~wuGt(&B>{5^)JxN-}=09T!<_|AmCy|(ujk&{^B+PI@Y&> z5kJzumg|>flB4EjDTL{p6a3o)ZxB!loMr}82zcMB{3Ww$i15Xdn)>$XkGg06W3Uh*FtIvvqcsETuFJI+X%`DA zo^Rq;zX2i)bWJ8QW*vo|Ko(r(jwtE=r&?inVeu8r#tD}W9uc8vo7!~Ybf!xZFVGu- z0nhVTw$g#$4ExYtW(_9yYG8`Mx#IUs`BsuoaM*rM$P=7hnu=?!r8NMN7{k^|JfPl1u zs}5`tb#;M0>|2b+Pt(1ojP&sH9d9=AOE=zH5@pb%c(h?erGq-wDx$VRgCxWHY^Rms zCx+oEUfqizo?SLHeP`GKW`BwBxP|qS8L!@WY_a3eD|hS9XsGf`~4ODcJ5hcjrdoHy;HKB^i$JRDd$aRNG+r z$z(`EuvS-7m}78Mdo@yKt~^@p_G!$JK#bm})a<@7r<1{|I?xA4&MP3`5n-X-&(z5QNcL_|b8%@PQ>yzzk9 ze3u+6tagPZ;6yjT3*^u0+R;vipiCE!M@SuP&>3pf2n(QlVm8vG6Azv?M@^sKLFMnt9Cv7 zn7APY5Dbnjt-F<7=IZO~-8bA6mf{)$Fl$*_<^V3eB0X?b+H6oJh5w!U;M1!oly2%Q zzDStQ0=DDpqW@_^0b`HYU6}`obv_z6NE&G0t!TeLe*(F1+n^~f#8J0Z?RZILi|)hVLazro z@UJDKfMBguQ0!d^!{CA??L8uDuVPu&Zqtb{&BxL)Q@ga^DkmDWARx1qx+5=ugaWOuTkdo6Cck=8!nH5Febo1nELBZsX*ypp>xpy9KjwgN za1!ZYRF^-lz$@5qR2&+e7|y(4<>;SW;=`$nQpBQ{4j_0nff@C%Iifr1_8APe?q|@0(@_Mr2e?iKR*fOg7;s`H*y~`V}5mY&=|XF zBfQB`IfxY$4p-;27IAC4Gm2`fZLW>-XbimMGRqeIo(|ct*M7S*O)KT*Q5!vT)+mEKip$2@$?QF{2?@2JMlL9pU0et1EdUJS*OCwoj zwGV+@u|6`HnlJwZx2>n7@y9f;SKThogY%$jgCUfhpJ(pA^1&17>8!)18%3Vc?U;wE za@O=0d`i>gU+&u~Rom59Ol?!8mbNWh>gsZT@!(fDFA$2`kvVrx@{yie3vH;L>Nd^n z7}UM!AKWyZc>Yi>(*0wJ?dnIJjQdjVXL=TM#Y3-}>}r~Jv4?ey^z5ozoZD~rR=cMj zTrz*JGNu?fD6QCRF4Ds*Q$|fW4#6*qYWk7(;-qAXhC<~MZQ=3xqZM=qPu3UB>#>Ap z`=&JQs1Iyqr2fyTubwCUaQ|UD+CEYI;uXbMPVxe7S?hvsGCy$vog>;4$o+AgAJd+) zRX5NNPSZYbjN}k&mS`b7)*?(*~cS zrFTA}+QsQ&linoTiUebeKft2Vc)s;^2M!jVP|6MyS(FC9twV?IBeJj@IToh- zkXe`*_ChJxC#|L}Wn{zDDJAz)E%hp1EVK=NC|@WaV;wr+v$n*W`Qc-cREd*YVC>>k zl_HAH!L78xr`dkQrzO)Lm$Mc}h^PA;R9N`4m+%Q2XMBCI+Mt(yGgf19Yp|)Z*fX_6 zP~qb8$9Uhnhb0|y(|W7T;{aFgMM2XMWG(IF`754$<{|7zcmKAtl9FZ%txp=3U1;s0 zAt5Z9hO#yURZ2HGp73&+ji+;xvqlh_Um980rry6~FWXF5^N1&(rz6kkR9n@I#ZxHW zgf~P@zeeWnRq^i4DUo>k&kgwX$sROHJna>=$mY@>Tl`R1^x4kF)%wNot>51KFONdq z#k&n7Sc$XU-$hCNgxomu24vrU$%93mJvEM{w?MSkZtO8Bvh}_$L62h3X-T?J7Fm9b z*qX|swj~l8`9mFH$e=XZvk^7^WkZqn4nCF6W0@H@RYW4soDIpB#K@9v>g=B;WZYe2 zdDAFEoS4s_IgyTAk|romF0E?I##S_`Ih->sjH`N>#}VKuRjs9;O^4|6q&W)AQmFOO zEqE%luT=>R^9=74<&LLGl8~9Me27Y?@Z8`k^1Qsz*k5ztIYbrITNzHb8tm=r?CQEu zFxJZ{NU$?rFc4^-I-1e(qsiP)6?$9tOL+Rbq^TsG ziG?!5{PNbD_WL7;@*wED(6FQXAM->2SvN=|$58cE&$2Mi_+g0NscYHHh4xtzbCsmC0ZDOjG z7upVG9N2oK)=c+2)Wsv+sSu&``5d`VFK@#ZPqguYz4}jDO3b?jZw_24q&K2o`AY%Q z49v;As0h60v;qE zq~>KVo-1k=rfIKrUr&A!F;=ht{)NN-X|vRZdJSxxJCV*mLww){de)a3<;(VpAzr94EDr0)@)#2}?QQkFX~LfPZ3zy~^^L-MpDMSm>1x z5k2s^`GyX6yUU9(hBH3$c6`aTw8yH}t0+}dL4T)?gXp6U^NSC!`{;8atf3(#FI|JCyLibv@+sDSrvEW%S$B8#%{)C90amF`^rXgQ}|v zCBV@koA^BAYe7dQuZ&soUg2(oODz-fUD~eb*(jxIt@E0dBaX|e1l3y41&Y!SLs^v5 zJsnhR%Dhoxs{$(LFEerylqj2p<7>eai13R)8e`WFsUGXr-;o5ZldOu6(rZ7L`%ARxQHT)5oi)P$wd*AB-fF-2VOLzNVZ7 zY4c}F^YQ~7e7u$&UF3pwMNc;6t_GYB+@J!R5o-x4A*|D(;s9*p_*gl zX2Z_WlVa_7SA#Zh4)h<+f}KtbmF{+hXCtH($4!~Jcr1B=b&+)C)6+Y-83>^at49uF zp|ahxgy{(vxbY>Fn1^G=sKv)5$a-Z}aec4Z?Um6dBhxirN$#8Y?CJ z#35#dh3BiM&O9FPOYq+Y#eZ6Gtx1fLL4#W|{uebdS+1N)=?TUho6l5ZMJp}>G&g+5 z%1Z`$-fty38Qu&cW({94d7+G4N zm3UenO$pWWiJ-rjEi=P%!boPjEGj3zDRph5;l(+M;q&v}BPV8S8T3)k3}Q|46fama z<2k9dshM4_9S2Ff30cwn!X4(GIojg2TT1!EBep|Ob`QM^t;3xKkLxSzAdB|y+_eSe z56wTd$~y@=FCg&33VIuOz&AT5O%wmKXdz3a4M>hU7B)LV@!~{gA9!S;Ru&PRlSJkL zg7{bJgJu>MWaHq_&>D&TGaC=78w}Fs3iXf~P1>QhA3i0!_@0KOU6mKyz#RJv6nBQ$1{ysaI1k z5)_zVMXq9fujx8UPaPvUof52U-^x8IrmLAR5T2{9Wlq$OW`3{xta-hk?in--s&k&q z^`A8WV*WZZlGg>*2@^X_niqN%G^!i8(qCtP_|n;IG7J)S|bLVg3gf8Oper? zs_d5Jv_38-@~Vys>Hru}rp<>Xr7=1K5?^__T>E9YRK|bo@XCQmnPYzi-KM|eo_ub< znK^hmf$+vs=F{FCk=pHUa}3Obu=BlEhJ-4`r8S*)wyCYX{R`xMkC$&eNu{(Ne`APG z+9>Ymo1|W7k$w$im8l%t{@Ony3ICMH?LA`O6>04M!O~0dDm(7pMS_rGbY!o;1P=cj zgHn8-L4bxM$X=obDRMjTP*8&%%VX3+-hJC-&?GFAF42`)tzz%S+G!{cuz; zcl)x3%(5g}OK0rBV(mL9#12BX3(m+wKFRvgo<^7Lp?7=FcpN+v(CkySnuHY7(P3is zJl9hIJhrm2>E(U`E`X$_`I8=9T?x?0o4`AIs*rLP&`3MxzMe&IIWlpy0ibtx4>Cv&AP4dqHK5_ zl_BoObElq-YLhqIS)93hWA4FCrox;C(=R^g%uhWEB|VN-8$wd4*$QQD$J%(&JeI4g zu732MMMJrjx z1iPK)%o`S7f9tgp^Dp;!c`KE^(u67H)t8(+LcPU1ny$b(41%(LP#^3=J!^I=`5Ne4UK>Kq1#bG6f_!`ra~ zrKZeVYtqvx=ws{5VZDwep3mcJMGz^b4|bI=XxeI9+D|{hyX3>iDs`PLi~Q$y-+X5H zGWzU7^DCp4P8dgyk4ad$)Z9@?-|*Um)Y$mxQ(xw%t|909E`sjE-8At6Alv-^lMGpu zZ$2t#i7@&Pi6~dkB@8d2bthlwEz59|_bX|&6OMkv>@wpVbJ9~&6p%bWkx||u9HD8| znfmjA-Q0l{cSVGzEFVJh3OE)C{h&%-^9{T`ELL>;#}rWDRlK7>4(>X_{-+K zjkh++k*;jJIs7UddPaR;cG7YnD%s@b=tMf!Qa?)~oo&fl`;O0AY7OahmXJ>}ugSEE z_uGBY?C^xLaRu!~p3wE!RAaG=8w(zVCAN2#7XsS;A6?%aNcH~zAJL$q5=lmbvdau{ zXlF$B9?4#1W}I`V+eDNc+fi|39$V%ip{yh8*c8WJStsW>=lotr-S0)8Uw^o|o%4Rb z#&bU(&*#&`ekGk2C=cUN^n-(mZgV;rI0mx=et%FO<-v;bzm~x1@A`D;(kqi7G0>xYx}6W2k)m$sx(os zL|0H-ku}I{mo_AGDzvBn-TCHT#r6lzyArQf3pb2(o(a;2%N+XLmdaIQvqhcLG5DQf z^*3~BzebpQQ4q2@jNx$j8RN&)tgxHpuTiRTMvDSB&nT>A!EVPTy!xg?(-=_NL-XRb zNL?M96IDQX+<30+JarF1&Av9j<=P}>mu~`3KZNYsn%YurdI)eUtbQ*Z$)gn5U& zVZY(T2SSLSVQycgWDpn1u5;+YKzr^ls0JnlVXvQVc?|1Dm$C=dOvR^Ykls z*5}u5&k$}nuO4B{5%FTxz&veU`Mnu?H4qOe>i)1PVN5&0aK@l+3lpA7=KvvQXlmHz zQ*4FOF$<)vCu+^WrVB-l7haeNvs+r2y#6=MOG|uT*t@#ET^ta-?zQK^PfL|j_Vk6qQwdcAko!7=;;4u3rc8= zK`1|k&1`b5@ftHNL;r9{w&db0bEN0D)mRssr?c1HnXlTKoZky_zu-JUXK-*da~Il} zwypRD^Qp9Tdl&~C7$%WP(eaM+wr}a-e!tU z(g-^DA*I>&!;?R!-~w<(ZM%+b`4FJByfc>7LLO0JJr>&Rrt`uoqB?UQ6QDMTH@k~; zip>)g{a4G?#$84pZ#&D4zgB61XG*XY$Ea*$hG^xoMkWU%EG`!E ze#;X>b3+(v5omMCo4Ow6l+XWE31t(Qh+fnLX5B}v{14?SHoWgfrVA@>Yk#DvuW9#5%Kv*{|I&_f6r;28 zy(}}xtgMNVzI_9hxt)pLx3UPMGyH|cGyB8r`nPZ2&`y|{IUfw^LyJpiNxN5kJ50MQ z^=#nhhV0f7tYhDY5n;ArcV6N5mJ#^{`Bjni!e2(?QB`5SQ94j?O|ovbUp&LK&$sL)wrxg4(^z-G zu+Btx^_wyK#8n}ZM5BIH!%k=PjtFKKw2=fGljYyC=2@xk%ei&N6*brdD@wO;o7nW8cA_c1N5SPx)jHhf{TKQnMSG3C6Ay zW@4H_{5V77l`e;3^*)`#S&$GfZti+rHdi`5raQ)sa9tpNL>%Zf5cIf0non<*oHeW# zVefdY1A#dDSEw@G^_@4nN!gN1bYN?pr-Ns~Dm>lY+hCf*zX_(Q6)aL>*^O?;<}K|2 zVGMgv7;I9SO4P2pA@}PF=NSXZNB=xjbM-JXUL4O!Sqb#Jl3yIJ`<2S#Gq@USg}0?% zI001)hnFc7;UB%JH1B)Z*gi;aW?NzX`d_Ns!f(qO%GeqvN-b!t@Pcwb+SW0T25sKD zh_|dcHjl@|c>9v8+4}>98<{jptc!8teNLvmt4w#~G*nK>#QywmTNVo`wFxInh%NO*vZNAE* zCxdL5creOH954EOU(;h*$37(h-1rLOws4yR#-GN~1^Fp*7!>}(kelyZA*c!;>KZ4j z&HMr$BbGM67iR5%hum7j>X%_G*4D|=&?0J%62XLokLrcKJOodc;Bdm2;}cWJy!K@( zeFmOc#SWMxcNSb_>H0X3#r2)zZVtA!#llE~YmA;4pAXZFIA{e##LTn)H)81+^SQ%n z)~B{u`QTLdeNfJCWSXQ~t^pm6eOc<0cH>xb3l`B{zRhzhI01}S&!b+-4U?5HwdI_p zSItESg;R;ES^>2neVd_noJ_=FVCtD7zMJl8UwYNeGYOtPjL!mYBRhxQpYOGEB^`$+ z76l(UyND!~Uwm6o0n47{?4{f?F*jLBDb?%>N>Hc;Y0j$AlVqNIj~5f)V+Yy8VK0je zgy*N8KY!k=T87*C=E0MO0Ifc|$fA@hqB-lgdk5cDRv7umrF>hQIW}j5hhe#Me(M}d zLmD`tWzqwTb|XI|{<@@i1OA*R1pY2UCT$aYD0eYP)x3pf7bQhHEpz%- z*ec20%(q&9sl;Wnqk)rHz8)qc(A?l&GP`1&g~du&pCV?u<)<1ZeckPX0eu!8E}El9 zPsABrc1cVk|CCQ`E4qYN+R*e4RIj2bQ}=pqpG+-ZW^=eNesswh4cb?vtvfD+5Itn^ zie=hQxPJ?_ohK}*EGxU;QDxc5BZjejh-jwGmqJskmrsGAdk#5Q;Z-1{v!02a9HtPj zl7xGe-p-!!`$fKj=O*`HVPjo8gyy|kxDBSUSZq`jo%^^{Mx+0mqNIp%M^a*u=Jc=q z`qh#5{Y4?V?znrt`eFfsNn^(niZO!)GK24TPr7wc667x?FQaP~k8iSc&)L&6ot9Iv zjxD-BnZGbC&HN)IrLZ51^_-!j3OUM>N(`56Qb@w7VeYx&{gztx%{cv=BrRxQ5B+->6~vk z{Z|ay1E2B_TgZaAzU|6XCm|P#UpWx9hmh{03DK-CUaOB>PIz_kH+GTrs3DzVgwU@? z?Np@Culii6?d3HzH{r>xs)u?&;^$*t3xBLOcxa63zaLA8Juzk-JGU;ra&Gv&AFG>A zB>Ka$Pn_+;HP=|ut)WdiW8z65{dHG#fyWJ!9nPU0v0Q*V=)&8!dJOt<@LkM4czQu| zsYFK1AqJ~17KFA&jExVGkJD*6-$%_KEK}MWf1N zd%E+S_P-c)=7#*S#WzKT_~<}C65R$((orBlQ0wJvsbP*x%TCLdWNtr8DwZK9`PZ#) z(*LK3gR+@34Ri0L9viUPFFZV&Mqw1`zTyi)7gMz6`)vvB<72#n(9oj&y$y|wf;sq+ zxd0ogQJ*k1rWY^lTLN;7Hie5yZ)Jv(tB%HXrp)4in)~TufV$Jm;0R0A)oRr3mA?U} zSxc}znI^^ANmNbtbubqK2|uoO`12v16aUqvft|4`TiOGg@2w$UW9!3n_x^8{bPN(F z?+tk2ngx?aZ6fC=5f66{rVdAPIQbV>ULjrLAF{Ph+k4@50!!J6bko}^-|@;v#pk^c z`XyP8OH3~C6c@jqJR6*FpDlDPoc;^o?&6P_bZv@ctl*fhXjjUG-{LvuU@P@{aI+_Z z4`KV}>=y406dLK$vwt~s;1`fX!HZxN%OJ$KP$BzW=YFs1xCXS zC!oHohZsKuh|Ii293&Pr>cp^=6!aM&_=#o-`6=?)c3a6J(+|z`hJz_ahNid23-IJ}y z?5U${(SIvVks5XqbxN|pbLn*fO(`U+Dpw~nGTOTm6jG3nG!%8L(%(BLM(3G!wM+%zwBH(hKrzHgrEJ( zk0U4ka`4Y;E$u!i51LxMWd(JW=fdeh_Xe8H+FCjeS>^YJUg8Sdog0%!qzrev$<4r-FF=I3^E zlyh=3574CGF(%q`Xf+B?n%Fjk;ieyaQNVo|WZd#>&|iE{x3kDvK;;B(FJ^cJ`-=9M zJdqslS!2Jf z#Re1?w4k7mL^}07yswT0+4@K2Po9Fpx6|*ocp$WG*E1OEPed~cZYF&oi-`0xT*0dU zNFH=P6`oSbWp_}SPXV-rZTqwXVTvEOXxw=MWcek8HXj^~^YTGS-u|TlyO}N9R-UHM zwRzmZ6eN>?y{B$Dx0u_O``)z0 z*8aJu>}b@9e=VXvpFLMjPs}SGBixAiGQM=x#YME_eEI8;;~?_K7oPJ^xR*=mfRSOQ zgk)|sJwU#1x*l-u#%v(c$X0Q+PAkGo1v;T z_>TV{sgRfSh9N;Qh7!xRNg3OgJbCCsK+i8-C5!a6$IyVrix{WWX_;@J5#y#e^GVaw z+I^nAXA{SWKJA%4A|80Sm!At<@WhR(hXzs}9Vm0^`j2Yk>&4F-wmrW_48${QC zqj=U_cSmi%=#ac@=N%okO{N`9=>WG-z}Dugk#g`Wd&oeR__L?aqTikjROX^z&A-_g zf8>LjS2W%pSKLzo%C7EO8NHP`pSCp&voA&&&pa~QyswY3(4IYtTi-mN zmyPVlQ2Of_z-9BXzM#Hnr-G?s0Wt_)LJU8h1JYiC z&6#;UGO{D8+D0^O5(L|;3**t~!wQh(I;W>44y&ObJ8Y?Xq-krwU-tH{(mS`JOo=D8 zj2u6-{*d5>PI7UmfS5A-S!%96#I6PkUf&Sd`|s4x=?O(Li8a%TclI!@8zn9;-FXT(m&{@VlM`RS2DOv- z-Z5hG%HAey&*jkP_82;+y~Uf1qjq_>?%TH^cDDTp5~D>O8Lz}{B5E3yC|7YjW6T3( zg3Yv0KiPc?wtnLS8$X7lK^~%Bkm>{yPlXZxipq`j!vIscqZ% zzEqkt9f#U=Y+?34SDr;fFUNAEY$p_uF0gsrd*}T&t779@Rjhk+;#*kR7n`usYSbVYpIyLKsqSM3@WkYL!lUD}s+1SQfIC~16bo4=rJ znG3chDsy5}kBE&g{Qhy+Qtrgy?#*kXUH|fJ@FwE3=9JBX^xspI;0FM~m{K#(P33R2 zhgq`Hc#n1Nr1k&0tBE2&h6C&=ird#7%L>$jK=|3;d8)1F1Z-T9Gg7+6an;)hG5IQ3 zV4M4yiQQYy^*4Vi8#6>d=NI*P2-~hN6KFR)AZk~&eZ#a5dVvgC+M}9nsu|=pMS&8i zU`Ii>ubg)3nR{~Lwe;uM$lG`U5H#Y*u`hiMN&o8zds!_75HhVFw(nV4mhny_lhM6K z(Ck)cp!|T#HWK~4pZ{NYx~g^070}Be)GcP5u=>___c^Wqv0^&J;UC)zngf-Y7)~}a z;hmo#*gQkUww>DVzqWGFQQl>UH<@lq?foXaIvQVhKRfil&h(M6GHc`TpvkqZnusr4 z1sqSqu*Unw|Me}igO+k{v(L3{!B5bZF>nyj%HJuT4WdzArQNzeA%thEA-ZSZN7}9b z+Hd<{PqLb&C+G^w!p+I)tD2@OZ)R2&R@50O*5XJ#>>@kWf221%J|Usvz5B~Q0kZ#A zXT|~IAJfTFo4OPHe(gAI`z0qtfgx`eWCQF1cv{sPX1%G;rTk8sl{NtZ>&*&2bL)C8 zGTSjkdpyOe+%F|?wQU9rY`oEogO-$yXyc>bqssttYan)efH9|B%_xf@$B-Ev8y8o0 zAA~=`GNkG^K~#TVNA_M2vc;Ws*n+*Gk?I^15Q;vE4*TgC+~q{M3xtqZxB$4U}Jnkf=;UGt#M?I9X3(;)2<8KMM;twkkY-8?>D!K*jO)=#=}Z{9(x&{Tp3<& z*R;3~a!N2AIw3p8%Ss>0nG4M16ck93)OcIxlBkDzEuAF72h*Gw!Vx+~lH*%SQ zPf9**kjukv=8|)Jw z7ajc|2XV~U)tAY=zgdvf$!Zh)rt*TfSIhAd6@yy`QcgYs!D*W?p zO`t0CFjQ~{{-@9qbt)N>p{`-HGT+Q(>#L)EE*P$;r*1}d+*L!jMVOzvLVxPAl>TW`lsBo|aYu05?)thqrK1R^RYN)fULB*j z&=g`@$k~X_M?CyA_zP+{BKSzvBEQ+m^MSQuXbE(ux+KK3(6KX?$II?^zQg>`y8{tH zTeMNK7VtdT2H8rtAp-)H)vM@@-dHSSjX?5!gUK~eUNqU`QyQm-gCP4z38q~Y)fiW{%GKxJib{Lk3koWtCxVk?oRf15l)S`gQ9$_gf zH=k)A#F&S~$ZRo3g3R{-IU}y?@R!rfvp2*=Q5*C};rZSsLcddpb?>>!YpjhLbhxzY z`?4%YqF%$Vw+^5Bw1Zs%T`qk6+BKdZQJvb?sWp%}75Q26balAHq2I>IAI_Nw1*SvA z{giF)2GGm`fRhy8r&uxU(n}Jkx}w74UwW<;`OD?3*4Pygfp%!lUOamBdHi(Y+90U! zg|#bW-)w1W3b>9R8j;AF0{{f_!2M!^!@Naty_)|P8Kf*u>v)kO=CVzh@U@--@yxT% zy@(@BOB(|6Bz^JqMdOCH83C)-h9JZ-`?om{U0W7|g)PUlL5h;h39%p#i~?+G!Yakf zBXc8s^dAE*8(S9OIFkOhEhZ{|2i7NPPEw((10F9VS@_WzczfqY>0_UdUSot^tUG@; z$@)`D-#|w5ipsz}-?zh_q^DCPD7n{r7F3~95J(P`o?Rp|Rh~Pn#|^8QS^x-{QnN7i7TqT{L zMG2DL2EtPNtR63ynSK+`#2Y!iBzurm4}WeGcgWrvjRQKCg#mmlyhe`{pa?GqC8WRC75ri-_Q4YJGYX`=w9`7miUdI0@$yZY(cV}RWw zT2ntpE`thmeSmy9o%@0(k#fc2OXdPod5xNEWQPTZd5>=Ojkg5^@iv=3nG9vV5qg>S z7tML^uqtuOav#Efes2XN^Cdcv+Jkki@K9GzyR4Axh z&Q~#Se+r=W!bk4DHZ->wHcj1)B6u;@)z=r#_LnRf%zea>z{%o9QvW6738b=3U8IXE z>In3CKPGsix{UAS@IxbKxBP+$=?dW9r``CLZPU5F+FPjqm$5>}0LqYnmZX&--Blf~ z3ffsbZS`K`pjqO9UU~HHaE>es3}q*+T$tbC91T!Z(qd*gF74D7*D#%I%=z@Qg;iDE#g4dWAx%);0FgL*AFy z*fX{F`+ZxxK4CRR)iVd0K?Q2G_&BHXC5xj%?rUo67vCrwot*gj19$w*2)oRR{Z#AW z*^f$9M2<~6>5WBZzpis@SOFEHbLo(31o5xW~%tyRnPZ`se zo#etkx7-6AcXnM(I?DA-)XsK|7-J2IYH(0^Sza{B!Y$J?A38uO@ti5fe9`;5xhKSL zbJKZmlbKSHnWEUQ3Z5Ye>^xNo#HgR>DzhA=USl~i#J-Q}M`j$9w6n8A?_c!rd*BO+ zq{bIbX)9TAlBN|q2M&%+H%4bf`V>XMQ54mAzxj{xx=3^Ms`%oP*LpUTv|7=DPvbo^ zEiXIq@mSz{zQ{tR%AWCtU;c$9<}Ndfi?&{~d%49wmKPAPD-nu+42XT%fz0Mn3sva885R@1*oZIG&9#8ERw+iwe*k^mw+_|gl?9J^Q!}{e3kicxK z%d|wV6%Q>I9zbvWWc*NQuPxtC3%dz%KX{V(?IMS@q$$w?UR*|(ZMlW5wIdf65lkn(n z!?@H-h;H0a8oTbLdN*A5u_|pap9r+6a=loi!uN4fbp@W5~ zrmQd19L|kM_EDuEU>G1#Ns(x$(TwR3N_Y!7&%M&TWO^lM@oTI!9+e}?`1zuC&>4kK z0o?QHqb|?lh3(RsV`B`3@Yhysuq zLs0&IHw3x~D)-M4XSAP%IL6{1o)iKd36-Z9rWk)rj(3mUisHBQ?HU+0JoVCUnS8Yw>5NjWu7!D4L~$9t-%S$a)Qh*`q*=B zZlKKV<^`mhKlL?HnbdbM)zW!)_~ob#PobJ*(NqA%xNIMNoROW>KKN zR@i5*-`8-m|F^3k7pGVY#%|tV5ya}>Yr3Vt>x-8jSoSOQN6_ouYNwnLg4aw;OceSp z_}V20)QE@%FZadT;Da2;RfGaiME@IoFz)#d!%9CO{2jH_YwFxffQET~LhIl7Lz_r= z&T?G=y&4?TJoVPYd2YFWtNDs9?%WgkZ{#M3j)P{os000#&6m?bk2CWb=+`C$P5+;%=Wqm!Bw+ijy+BIkL&OeY*F9+J zDM*C*GvY=@Q)^pW4rr?QJowZ07u?mfTHy5_`D|+y1KK9q+A%EC zO2h4q`1W-;`O2_AG&Gh;{VS-^b2vvCk%MchJM`k-miho1a@WYPbmWFGi2RF!%w#P> zW66JD${8@xFTcO^=uyickkn(y+qbaa&VKWPbMDeNeagGLYzn~R2t#EE)tkYryGxZ1 z*hE2Fi?nEQ!hPwqu+UV3jp^oRgbM_9i->?OKt1s&r8W+lb!tG%&GjCLI^sxl2T47R zt18=7MV>gYReCT_L;lZEqsiGeQP#M}`~!+8AMgTYEMt+hKjTBg*vNNV`m@pKXy-}r z{hjWoIB6*)+S)dL+`Yty#1<=_QW4m7;|%S#l#@VP>+rt*6Ch74cY90QU|KvQh!z|R zDv8|pFNbW_$qUt(71u&)Spr>KkdmDHE(X?+F^A{>vyEzXh4og74c?Gfy;YKDL zRqk>CDK3GaFRw32LwF-ZKwZM?6Wj7QI{?pt-OypGH}weyXG#=H;zL{-5^rFn9Z1NY z?$=JCrK+rIr%tr|AFcm6!gEzLSz9F=^>(voqin%=$D#!|fo!DSy<#+}0b+u^^DUn~ zU7|Jqylx#cy7}W9cTu$f@GY0t)^i2XqEOI9(^RC&CS13F=hy<>3lwQk>c+@jk>4*} zR#VPrnaObiX;!uY>9xhKn*d!1N>5tn*=wzi%WhPi$R7lO#Qza?0=j`c^NxzJ6<_cZ z8R;E08JwSb3!Mg{izb7M?%?mPG0Uoj#jj3il*AR`)+4nj<+85A1K#Z~`w2=v8pho* zgz|@x=&$+Kjf6(Q433o=8R4J{_`;M~m69TB>j@pCy!77syYZpD9f}1c!<=_(IxStR z!;{CTZwAgx+r2*@*{Dl7EctW3@JKsHZCqomzZVj-NVJ~kH#^&vdaJ%eogC}W?H|T< zX~7NM?;6fA4HrSjC`AsG*&MnMg{CgWXfVsdvWByDDsdWvMa7jj(p z7L*)TE7})Hr$1Ig1jv1Z<`p;$^o|Z@Fd{KZfeZKrXlt zerCs;-lEPA%u$A}Cl1Ca+;P15qhUOLs7 z5HvL^SI#iP4H0&6NV`e>#ZNM`ANy87t#*+?ot?EviIMG${Pz`6bmVk>9ZykD@cB-U zqG`Q3C*N}Xc?i+BQ#{ZNX~JoDtZtMzTgNj^{d~Ddw|-{MtJ}u_(o{TR7Z*~|3XB395n1qr_VK4B{olIL*ywz-yo~l2~z6=e?4&A8XeKfJJycsU4Db8|ke6y~3PI z4RH4F#$|?n4egt?o>wFcdixv5952KB_{~R^R&qrUmKqtwG)nH5-7n1(gG>&l+NftE zv#FHaMh`;w>~1Ud&^et7e{K??RX8FCIhXi(PRFpQN2v!p=fxynKysSQaiQg6gEE;r zNHK{kx!>S4k;NT!4O3R%d;Gc0>uvB8u*I4)mW@o2s)G_zCl{iQHpD(tR4!7QA1ISW zXT{$lp2RiG2Gl}d6ctB7Ig7(h&d&JN3hLlP8sM2Q5x3K_+RjCl7A>)bGFdSEA$$VK z{T!&GPwh|N&9P1_`uvESw307_YW+N*09U6(lRb^+btJ7F@k%|>YQ~A5Wz!6X#O=Jw zehyBrMw=qnD@)-lU3OF(8?w81OD6$%qgFl1a82?towk)47}5IvWM){}Ak^L3lXN<@ zU?A1cnrYs_S>3f9bQdeI#=t7zBM$3Sod?Kk=IdFXzKjfhtU=W0@jvaSW{~ek{1=Wt zdOYZ!+)d~%k$o)_NB3@48XKBp`6}OZe(nP}UK0PPCn{(z*dofUeopODlpQI#Jf|{J zO=!9pry4iAEEu0qgK)Jc5FI!phN#iceUOd$x*r9fXHgw0G(+p|jz40TJS%OD)F6Vn$^*2*`md6xwlP^|)H~cq4gQ=9DXDM?{vG}K% z>f9lmJ|<~-+PJqegc+mmT-dx2jB&-G2{{*M{PpWb=PqkgIhsE&hKr`;I2R{yt)#V+ zW_o@6xil}JlKr_6`7$Etb|o+4A#gd?){bLJo&FO&>QE{m1Hku>gZ8g3WmynP21p(D z>Nm+gE6zwyv`GaP7KbozIA1b-rh+gtNE|8h=beTdLYl2#tUwUsb-HUkGDi^KX#(m? zta6)OVP@zf3N5!Xz_tLHHZF~GY2Q{xOf$k>DT6VDc}ctc?PrGG>0_2XEl?#g@>xS@ z@uLwuh`yD4d4aOsU2q@q{=ra(+pKE$=CcASh}nt;Vj_K$74v%J?h0=KBW@l0`MKxR zK@Go&9^r9Y;$8C0GSNF>!8ltZv?Qx)m%$Jma-rQYF%pcu%d&7&r+A5QzZ_V+Lnx&k z14IUuOsTKC>$?(PaJ)!f?vXU|pwf9@W zBx~iQIQn(x8I-_ZY1(t2X}}H<@vPIUTAwQ!mPi~h6}$0I;)g`B4Ug9n0yxLB50}BT z&ClS%6j>XH(U-y&luE=e4sZF_~&*&`g-Hz}wWqK8Lv3j$-9qvV#q!eiPB@?d6a zsd%L|vg$FNOe)O0!hgUuF57~f$y;_Nw>()%&z3jV9co8YuXk_rt!J`RV6SV>f7{rg z4joL!Tj<8$;e!uM05mxUdS&Y}C!e-rc}hC+ne{+VWk`NA)(zi?PZnSLB$Gu>F5fGf zG6MfuHs{bDOw>hJ4*3{}mFr{b*k5g`x_4=;*Z<3KsW~?;4B6aqRBQj}3ba6FYspT;?m|nHvQPIlGFLj1;&DlU0P^ zu9s9Mk}mc$Itp=K+WH5fFJ%DgU0;N4i;k_(?EDFxKUM2!{2YcM#Fl6_zXXb7vEF{N z8(MTx0q1~}&vJv_L;G33Aam`9U;F62UQrn1iHzt~Z;v)E(Uh4};nJBIs`=s&HaI$D ztyb)TB+JM{=L_ADcdYZuWvEC67ZpW{;g(M78`kdDA6iEMQtdSewa=ja`gI3ZYhy@@ zVE)w00S}qa#PPQ<|SaF+eD%4RI^UXr~F%9`)rR= z*O)fW4G5YtoDp}#K-e{E;<~@fWNZ3uXxS5gFF$0BlSx@Z5od;*#pv#lM0Z?X1@%Rz zTWzw1wIhnAI4Bq5(&igd9n`bIg{I|&yo9{(dK2lj(yyepA%8etejOx?eXnbxL9l@Q zslYlKaWRu=D6mrgDr}wW&9*INz#Do}ml5&<$G8O6jRh3szr-IS!gS}`Yt`I5qhmVVxNg$vN%Q(TS6?ewX0ufP z_`Mk`Wa*J^OP=qAel~~9&m51S@`Mef+V14alS18#KOd3opU_3>XabgD4dEZI2u9am zBQF{6dyXiT5#swy;;g7K>8EKxj)L7ULRM7T{b4hCK*J zA+Vp-Y-+un7+))8bRsr7Z5$Q*T}R(~uL5#Zbh}mHvNze+=q(rr@(^*j*J`Je$K=Dj z=TJ_ZDirQ}z^STJTvmTAjN~pR5?8=>RX z@#-CPW|-htx#LbW@63t+mr#LE%XfcV_F;s^lukZDFb(z~!4UcBe%XlroJVh6M+0`x zbQz z5GhFkA7oa!f1!kc!Ba*03bvf#Bh04vg9Kg7Gm`ust17E~?RC!QPXEi9{g1ts^XcbK zU=$~O`n0LFX&P!yYD|x2>9Kn#(lDTtLi!6=Mi2cKfZ3xxqgM9l5cT zcS)%f%GuRqG1152n?RBF(057N?)s05N+k0;smIp|4nNDU#=rt@xPsQlgo7}vM zSL)=7pu%#jY5^zoaTgOMKVkGMl%$siGYp0>x7wmJD-)O->j3G!hRv<5Os@#KSicys zGZAX2o5JKN32>!`rVfxsO~S8%h$FjU+xJXi4WF;s*45|LxR(b!p9=m)EdiJp2)0yf zBr$LE5~*Dk-niRfR;uWIXpw?)sLkb7j9$>0S+haBsho z(9QQ_cpu-fETOBluH32n_sOfKUO)AYFX__m=mTD=bSb`fNAY}mk?G0l*XVPDr1qxO zS5$PRRov<_BT$Fz=F70w^Bx$|lH3|(m6{71T7jhAnu8}$pD0J|DVWW@BrGgkaq9ZZ z<Gq2*B53j{1ulnu8bw?tGkMUam+23Uxf< z!&m3vv{k~)a%ESow@ZiaDlBAmoJa2c>^q7)(j*riqtX5Va&DC*lQ#`z|`bg8S|-`kIJ*moy-b(eKPyHjoe zPFk@2i(KkgDUwttybGq<^QQfci9fSR_M~q@uxz>a!i|4AuH7>|BM(m(+ZFslqh!s4 zT%qA5eHD_rK3Y#$6LU(hF_Lz&ysQK9_l{Tq>t2ODi!BdDh%eZx2HfP(B^EAGU5c2#Kt_m9YNAS92hdhs@j-}X8z<(fS$V3f&)vo&>Wmu`vd8xH1LEs7bh*QKOU zkj_F)>xo=(mS<}#T(XngS=_64_x-BymFuj&h4sF*b z4HHnoVHkIVeyL^`V@HyV%*c4^Lg=}RmQ9{w! zDY0VTCh=e zE#yN@N$C8UkwXyc%tSW&_j^9h$G(qkS*`@r(>ittJpIjG1>wN55%pve0P{G1@Av;m zvh)gHV%^Ci!g3Va<`{wl;YXo-SgoA>uzo>i#Pd`S);bP#|B-ZY^-)qEDrG1nu4C8x z-ipORlT}|*=yUWmjra0!?Y8AS%|2#2+>ooY#Gl4#M0`PCCk6im5U-vo8d_6_5xYl;x?hg{K6Gcdz6=HmilkkCFOl3;3DIx-7> zM5A)Q@1Bs8eAOo&)z5}H;+~L3)_MAODR%$iBzsj$opw4f;PEfotf~`8O3nSkJH+uK zLT}f(7xhdlSSV$ZA~ngzm7F0{>_zFYjV+1{{ai7q)TbWI6_Q4=;R<<93FWaqwPesX zT4-1~9qSG=rYyL{ea@dmIC**mG{5)iok)#GJz%N!7rg1{3>XT#X8I-h#eNkYX|rz1INev15HA|QJxB$v%v(K85_>|D=O`%!{NU6)fx1MG`ft{<`!bQ)|#c9biA6!vm`dOgF!)AQpF83H3SF3k>$l{w`r#d)aw)rcP5s}V&jvmLN3lkmfK zQjqc&^+L5p?Z*Bit!zUqa*+MfWg*S;=`HTBnXspey$j$0ke+Bg63N@~=?Xz626e$e z57J`@%g%PvzLhTQD3PT!`vU0>2>Ru8wNuf$-?}qY+NE?g*0GJEL1~sIRR}Dyo;{_- ze#NW{y`S(PQ%h3(ECeb|hQF}YZ}o3u5G$L>a~mr6T{;mnK6cE*?6eD2i{6ORh!8aq z%lH#&UXTj$lEWTchzSU!VX=|u-7Jy_?^@6 z`=QRd)cO=er0Q zVp_TYv-&flUc_W!N3@}lPF8yd$-Gfc0GNvH1Z-Btefx%Hv`koOX=d6>l_OnX; z$o+Kt*;=o!f>9$$8L2tZ9CD$xH##)E6onwrn|$pQnQSaEed6HW8>|gJjcLO3kwoSn zih>Y6v`-`1A_8Y}n)>rf+_-aZ!DN^>Kt7oN;L!s!f`e(*S|{x+nf}r^Nz)7xVMlr) zE@mbBRSQoQ-bADaSD!4JoRU@4Fh~bF|HzeA&qwZ-m-jdumVYnwcVCKYjx93MX`mR6 zZ%8fY*;pQpMyc9*q0XLry!E}oN`i5ibW}%lC3-A00uB*M6?0IX3_%{eT^!kX;krQY z8EJ#`Y!{@zqyODI^H1N9(@KjRRI|)FV@ey=XZpAf&-<4l@4QY|yPr5l`Z^QppsM41 z*|(HIN-KF!(a~h~bFEL>#Xlny+f&X{dZfUr<_taiZ(Mhr|kBFESJoEf67aV)Vh8UO#kHs zg#^Z9tWBHeLjRURo(akqt*)*lQ}{3iIsF9V`ju&&f#vr5mUPUft% zn}O@2^Rl-6o>}j2j+^??H35iP1=ew-?$G*Q1T2mY@*?9miRXOFAu@sLoUwcxHQ^opzmECDOSmoeWh- zz!9AWv*UqQ&%3{FYKBXW*EQ%U@a$YE1MWd@5i_j8p<%5gT>#J8cZ{p8=g^n^HPcj?(($vRFmc9pE{2FBH({`n$l}YClWw+=LeaM@lx_KUzyN`c5UqKk; zNW5_W$6!ZJ{Y!3A{9u$ndX()HCu*vSh}H^}f!hWL>pnX3;XAOSG*W%O0f294*vHr(TfJ zc%L9A;CR_NE?*FzT3c^XV%si@gS15Cr#Vnju>}|l1>;*i$typnN>S-fSkKge*!1?o zkr?ku$&(!2tu`87WM@i|)Cw%8(9K1vQ(mKDzA%2;!fRG)<6WLjzdoE@flhg7l6Bj2PQHQEF;x)FUcNT+@G~AZdg=<@{rH z54}mTYlU%adADqY@|*cLhYbctr`>(!BJo4@hmDd0tg{tYfQ*{i>l|CL*YJP%URC7> zTOp?##qS$%Pnxgid7^RYnAl~^1yR^QAHgMktbUo9EX@^S6PG<$bcdqK71FCObCN)$ z6jhJQo}IQ|v^E-aR&^0TP5nTn0SP?Wofg`E(_YBxPHOYAdIm~EDut+NvcaP(#!BO< z+4d5UI2xC(dB=+ThY<@+LVAMLomMro^E;yThBVVptQweT4iYsFe91~2#AWPp?#Ah9 z=*c1V%2qLYq`uzg>WxHFPbOyEnJ^OFtKU0YNQ#}hn5O)`V;t3U8neqn`pK@s9{`GPHOWNeW<$s%qDXG8^tWZg13`23kw*dc+8%F7QFN9{B%mqJs$ordopywE0` zd!ui8RKd!ka*4ZD=a;rh6F<3xF{pvzMPVoIKqj`KTmG9Cy}mieKC;JN%aT(akw@_F z?SFD4R})N53Bu8%7Gmfl8B3FeICK9w4$QAww>Q@hnTfifU->JXUcd*Xq@+*{7Ll(@ zF&Ds0t*8KGIOO_6x5QkBcGT6Z|JUAoe>L50+rx@Hhy@EODo9b9AVpf}1XMsuKnX}M zQlti?8%iQ}kfMZ|&>n?A=+Zl)6zQEnK%#~oij>fkd_VD=JKl4Tj&si+@cI*q$=GG> zwda~^uC>ANwJG7L=A^@N$T6ox*jpOD;}P9#K=}5q>Dq49#BOE^&-$B+i{9ex7l@*; zG||n)xQ0X>+N(M>!JS66tqEVSH_-P=QV-ru8Om1kUpH;s9nKz)0`O1h^7ble%Tf^w z6k4em8Z_OdKGmDqBVXSXP5neJWtZI`X0HCIfQ<*}?Ig!G-q;P*9QbV1aMPeoQT9Uq zb}w+%@rb7sp62L{+cQk4NRQvw7O210HC!eDOE}_<5wwNL% zxO?*Fr$nF4`w!4B6~wAxq2|a+V{2gtSUjG0#46a6rNke&28>>Ez%Ku8k{8(fz$X%6 zx0(-}Tgm}qD@2p>C1Y~#1#<8IM-a4vw@Ub?E*F=k8j7hA2$s4!v za}X2xeMpI6cW*->8zMJXXQ}9i@Zc=Ibi{-jd7+Kqo~r{y9}r&zK$N5hgXcnf9b?mj zP4 zM7&HEij*r-wExBPJ)}`4Ha;f_w;Xx7elf}4?VJoZw^+Ivl9D>C^WZ3z3 zY+Sb?nxyr>#ZmA6uYpAB&Zzklj>7#<=No>$G@|<@2S2i0XTKoD`t0q-!6O4zI!v52 zm(h<-j*A7(Jt;eSM>;MH>DsmXW>Sf|jl|dpXZNhA0^D>~x36Ot^{GKP{S}OEOrS+f z)EIbmKFYJ0S77&Bd+ZY0;L_kb3=vV;{`S<614H|?AZSJsjHR<(Ycqagl_u(Yd|Z#X z@)qP(8gMtetX?N%^Wep;?pXZC7!7DCw9|fEG8_iZlE2maI%^r!?aozSglmXPEu-?d zDyJBZ3l<8xZi(XPg7ver13pqcp|Mn=WBzz~_{g5#Av?!qdM=E@P7F;S3cYP_fzVzE ze%et=R}|ApJs^sY_uytyF~~U~PPp;E;T8~=^IhL|dhelrJF?>kLT77yM)TJDCQ;5W zKgg7!bI4mYv@7zHThmHAL#^g+G6rptusqM}2EqIa1l+o?9IP69-rU~?stGV57M=y^ zqHel7W3u8YRe436)OZVFoRYONM1G%IS5sXut$-I8NlVACwc_&7L$ogvS?wFG+C6cU zN;R~>pdo;8DX2>8_10#5AOr{xgHzGz&3mqwfV*&QGv>1)=ml?LNG+>N@#BUKKMtN* zX%KOv6*R!Y`--A?{+M01>E(k*Lyz3r&M3-5Cn9>|@eYgeNA2F)y+Vp;91Cnu`aGyB zO|s>ysDlt1mwvpKq}AwS?w2!bt7sFDTB0lm#XfUg=7&v6hH(?wa}R4KlPy~+4jEkl znh6ok`k%kOYb(GvlMo9!Q!Tius>5V91a<;7XnE{u^sl?Qf?4fEq-F{R3=D7R*qGYD zr!_y3t$lofXVyLZD%P&(AhcV9UZzPv2YNiMMYBI|v^1odyfI+BwKq83?eTA6Hs67; zo-OV#2pwOgp?`oI8|KUyz{My((aX6cM^d3)*DKd1~%D??KqCOye$g8R@*W_V|71Feyw7PM@SuZi)}aH9-4|8j)})N@4;P9 zy|NB^@-l7m@9f5XVK_G`vC-z?Bz%?{S#*0^@WK_AxH?;=dy`@m1<3{-Q6YwfnfVxOW$k;T78LI#S5XyCfY|-nbfh-EnX{J{O*~uL3U{ z^xE|{g3r_QjF6fOJpO~1ftp7@1({>Nfpj}h)-Us-yW}F1L>BDa%;(&Wcc%;;`WJq_ z+(GpFy?GW)yO$M`+Fdol^0&d1d{66#(<$Tnk(dfM=vfPG)TT89iSAzjJusEa+Arga z>0jlPBMO4EWQSLwLxoODM8D7AXiFeFHXKLhJ;XoM>RO-f1H!QZ^yIwCsu9NC+H!yB zna+@-6Bs)(OaVxxk=|EXd4+gLsR=$GZ!@+(*{4&Wn#xbxo0}CRBAD2iST@OlO%poE zCDZ8V)_fm#7oQ~d5Uw_z$TP5i3ssBO{}OaDNav8oi^MMK%rVKjhj}RzYlqf?QtLcp z@lNdDe%}179S^gadtNt0Hn{?<}$qAz&%-`B&+DOJyN(hQ3Up) zNzVTB$<7NwC0&gusY$;1yeW!dCJnji(C*;9A*Bp$Y>u`?wQ*Ws;cSF1omEb|qBZya z(TLT+X8gbmd784cR-fxxYNRGw|}A`AM90+PST-6_(GLr5}yBfXQn zqKBaiZcyca6nU}wkG+fB5lwbaWHyG;3HXN1MrSS`hkRyGl)o&?(|-gG{~DUwEgzYi z7cHsg>&-(<-HN~AXL!hbDzS2J3m01WU!J&K?YxA^B#&imte>OeJyNDY z5YkKyOrqCqaOi*7T64Kn#YeMf2(fTmBwJ^(sBE*VQZ1DNT$v)nMEBVDjr9|9DP^#U zpA}M=23~6OU$x4$IpkgEm(I^&jAl_Ny^x#t|*cI&WIJ1nAxO46@#l#0Rzx zPfg}ZIYn=^?+0hu*gn-~r!dWDM3>`TW&XSdYMERbNU zi;8HCTKLLB#9PT4p>ADY@Qp~j43Gi*_VL|v^0Nm zM}lKUZEo(M2v~2ImRc?{;B>@s=j4wfnIA=#OKHrbb+^~R4%7NUW;68~ttv^?h7@L? z)rGW1)p54p2?y+Z z`HZnRM2A;4?G~A$yw|7@s5e0$Xn0vF(vSR#Kxbm(s2_UB<#l6c$FU>PQsoS0uUCf* zrIr_zjpuDGl}Lrs?Ylk{sbU7RL5%>*iaqN*T(+NPS(o|zM_Kftv{KM*N_u&^5omaI zQ1rXz-#{8hUmyCxTP{w#N$>E9$(`l~le1*_;!1WL#D70-!3*6L0DVs z3z2^p{&grZYHl(o0N^*moG<}S-yeq%2aS4jrRTo|XN9~R8h1J@8Pa87>Hoo(PME#<%9;JChwSaQe}3xK@gd-M>X>cz zbD6a!#44yy-_gc)zhKS?8>q~U@6@U6Y8h(kM2oD4nw7C(I;&!MIRyBpuZ5V;Akd=^ z6N^ggn2{4#Bvq?h3Vj89TaTg)G~063tuTfjyU(UyZ!~mc#J z$fRVbjx@Q3yhTjil`CL*_vOWB2|C$4H2==&4C$f5wwCl@&_^e;%E?I2*cM1g0cv#@ z(PdBafE2%~(4iO(w?GeR!CH5A^g!#Py^&caL7q9QzZM?3c00fVk>Tu7FLgx}TUd?Iykbag%jvyVIL+->>=+p-q z`J2d1@8?qu(^a2m&BFA$<8x7Jp+v8tz4l7+Qk=o58<$E&&k%dp+)z_{_eRR}&_4Q_ zTDIlJ)kX+|;d!&cpHLGBglhaes{BI~)!*aR_^He#q3f&{`c~3t&5Z$=Is|!2dA*Rb zg`Jf>GDjzVsaf=T=C24T1SK-F?+13ei%u8qtR<~+PObWJ>CJ5G9kF?J@O;2{cBP)i zQ)`w)6;G{05H*vt4r{gS-(uN5zEyN+Yd*A#Q;!5P-Gv5aM1H%if%gl9GLZRS zLrA!9(>!V4NBPFzkuU8>9&gN-n z%sS?}dR6yTzzr$qBya6~7d&-8PcnudIiHoN)*r(!9KP>t+cQ1A5qDv(L;_SHquE7FxcL+f66j)TymbCcK^G_4AYUas->xRlx@Ob=cG_}WSs}l70tA>pry|< zSM$T(ZO54MHpSGE3^>)&8j*Tkc;JQ1@A8xdWh}Tn|=)hjgn|)BLOvd}k6l&4sgcYYe zBDmUXG?E65nL!&H4K@rI95Y(B=P|#CBzs=(v4FY(v?s;9!DCQ%Q}Vv_X>itz;c)z4 zw(Yz*TbO&0*KML~c{Mq%E_IGWw6mzeO0`aTuC!05$D>Kqvu#~t#xufR9^Eweku=`U zwHfDY?z#Txo~GGJp5gCBihZ(isP)0(u+AYX<@y)yf!zem^q!8?+ zpDr%ekohj#%%*$az9iURYlQx?pWcWYd|5$Zy;G<%NkWTH6Y>S+w!Dh-fqwloWokpPX`Ojs40h#s1=qhMG(pX z6M54@M;gx$nbJ?y8U1Cl4q|tCC>vQK`b}Wsns`TGBrl4kvPugq5EBVcWERUm8u|9X zn<>&mt44x(zjtmxcAo~5IpQXh`H9!{;A@p>+@6-JSK24U!W86RPsXGG+%_?EuJ1CN zhxrO6M~Pc=HgTcM*3c5}*XIN<5$1;Y(b+mPjcQc}Pa7jy%={Zqd;L7~#_Ek^Ff*C- zJm}c9V|#x5SN8v|l0UK9!eTqvUFC=zlbs;L@=KeN#vjID3YD-u-+z&io8}Gq$n&xn zXTMB!H7Re2O{_wNd;w-h!o8yamE#&e_YSBF>xoiaE>BzsV!)jC-C&pvH2&j@Lf175 zXo^o58$q&x*N7VLDq5_eXtHFBH+(OfwV&^Q7scB)TbqwtEHyLWRN_+##+rmmA_({Z zaJ;Kq&E}7Jz19cD9j&yhH%c4RccPPI^&2G@*q;0q`zXibwmSaZ3GYq1&ONPYEctD@ z0{fr+65 z(rM)XP8)sA{$)+=;zsBi{|j#RoHeTtR2C7iw#|6~q1xpk9CRfXr57Y@kn-rdccbpG zL!K=90arh_VL4oV1_`z1a!lD)$f>#6hj;=kd@`Taixb6M+!JbX@C&jD zWb0{H&*G%wm$IcHxE+U7y!(z6OrTdEC5_`WyN?dF7-&2LP$hHD1(t{y`rki4Jz)X8 zT0ZmvHr&g~4Xxh!$SOVy5b?SSTShRb|3%eX=TFdRP8THdD zFMp)c&)7D+JJ))@d6bbE1jPXrhyR`#eEeA0uJMHgNaDu>9j>m@J#TTYzc@}mtPy!d(Xv+Ifu(G6C_1<2 zLQZBtTF{(tCe;v9(wjMzu) z1R5DSTx7|(UHppH=v0sq3BiEswRYhtbn~N6BeYc)?rIyPsVLS79~8&49TU8GaRsjS z(P4e2TU!rZv^Vnnyv_C&4|Ps^rR1PM&g`L=CwXZl@q()2!et@GMxpfvkEoHEdsA{% z%{}gds42lsFxDkOuR$=-;RR)(GUiL?ekM*!DfO-CCcA{`t4U$YLq}dz-(2gwCj>hm zrv0gd;B;47e1+teqwy-M)7%`~01&Ag zkV`uzyu5%hfSmVww3G&DUJ6Qc+JOW$z+ogalKQ2a#=gTJSzzRy_9b2jok}k`C8M{!<1FoEpu}Mf;duO zDOv6#I*hV*j7+NyTm+sy`YdG?O3G(0wh{!@ciQ;)bZx~QukmU6>wKNW1h3oMwqv2g z#4~L8?UG(wpLW6X9n_NQkagEtFiAL$@>9juU`kHthj{B9D7w% z6%m?rPT+0Bx_YJ>e{e;+8_J!JF_GT+%*WKI*uW$7`JkW-1X9fw>=iRJs{tM6dzNF} ziQBr#wQpuFmZJ2|);CKl6B5qqCRQ z?Q!i4gJSjLRu{F44;>KJewtZ4{qfe05Fh|dfpDs(1G_!$TBx9zZbsAr96LQT9Q#-%C32T4- z)$2sXV)Z7$y_e1Z^E+l~5gFAFEroQyAI;&13)Bf}jOJ{;!e+mFt|UFCnx+gS-w2q) z^jr?|Ss`JBP~hx4?iD)?x{&AD3iuT)Az`5iMw-coS?tpMW#_FpM^Ej!ho)R}LUk7{ zEuqVWO55F~$L$GDkk;ym^mm@(mh>+oEw{#7;YKOt)&TcgK(K|qhWR?Q@-Yu)I&5DX zZgfZIZUg5d1Vc5}gqnbpC#wRm^_#6Yku8Kp&BE??ekT3Ep57p^v`#&5eq&TNu09?6 zHx8FRG?@b>R}pSzu$~?D$}Ws7>3Z^)>>WGyCMN#|+vpPikuAZWwQGUX=HrBf9m-ig z%h&Ip0Pn!KJgiSWFvP(&de=bx2?s*mLgkbNb^IkSqDu1MQON|9LgCv%Xb*lbC?aA= z)sV>y0gc#Q!-uX_Wti?9?biF+h;meD=-L{1N5}RB+@0Ayhl2($Ds3jUsmyHUUtcyh zh=7~5?)%CwSB9&xm4sJ_f0@5xs1Ullu~b;Vk~`rv{X^>3%L1|ak?HAa@Q^mgIe)!R zD{lRWyg09iOBV6sOqj|0TH&pEE43>PTl4c$_e?YO-c7d&yqVn!t#_fZ*$NTWnA|x6 z_w<{u{C4*oV4ewo>}mCd7rhr$wk6;<4O;*4Ajz|MejgztS=nUfEK@kMA6nj2XiqZ% z&DZBT5ERswY5>7x&!sxw-cQ_lNWQUi;AvUNU~h@mZn%HRayi}@wJE9Ga+9`Q*EWuU zg9CL@c$7sg=O|yyskQD!X7tEzI~QiaI3J~p08+*cj$J1DHJjCeyCcrrr=nB`&6&$D zC1oSij}lEcQP-s|Wr_=AG%>MDD-TEm4osW+(5U0KaUSPK_jA#@f9=pCOo(Ufg^&55 zZyhj6dA;VydI@08GMHmjOrM-nn{$5i?U}RgPO?!^*wtfesdo?E^~ruYSAQPpCpD#q zINt4^%KW0vqID1awI1VC$K`K;0}D|SJIRVh(Iom%Hkdk(DkP4UO4AL4mW*hBeOP*K zw1sHrjXgryi1$L_2dPs&^b<$6x8D?@HY;_gy|p#S0NTzhn2!{ywIG2q9hyZ~2w%(o zzW_|7_M0g%|CU68F~05Gh!(UT@j5^Iw`fV)I9{Z>u z)O3vK*Q>e}!iP6~qKeRa!t=u+Od?i^EpA&1u4-uGy}uGKMfsq7rrx+#KO_77Hd(}e zyfgC$ev0A-F94EYYoVXWhXt+A`ENfHR{5ToMYPORvoU-dA-8M)z9LTW=iIBgDx1aX z-m%P0B3}dI`NSTgy@>I-r605S>P<&WU7$~4f)Y#Vzuh4K0N@H!|JrseUaG&lYvO^~ zVE59f?b0h>ldpDV5K}NVI&s0)1hr{s?S#SplHsELcv6WmxM?Bu5Z0I;CzR^`z1cbx zjKs^0X)SX?*z;xj$TSow?wI{ghlT4nnhD;98|)+(&IX8kQ7T>N^xE9^q~m|-w92-N zh`EENu4Ds3Eqw+I<4C9ks8H8JRSa>le`tRSHn1zSBD_>&x>4W58|nqx*#SgeASV#cjW_?<;^uoY_mx-Ms;-)%3 zKV@sls);vU0ZSe{1*D62KE9Is>PTyR+kw1VQRm+c!M)MmOIsJe(Y;`wcOALG_GC^Tf{kCDt$`K1#1Vyf=rAcI?kxX-I8BzH2zPL*Fr}A==3l3YIHxU;OE6 z4b~$f-qCOE$5}YzYD#)9C>{+hs+2!B%D1)G(49{m8tNs0)wCY)02wjVR`yK1&ew8M zTaqy{`Tfdla$LF*9kdMx2&TuOUcdvGln`x9+_2kgm4%#*57H=fus&&RF3bOkn8E&h zQK|A*rQ7OH&QD%`KwpjKR+d1iM|i#AJIvLwcHS$ z;?DFHpkdvbjB}5Qjt%iDiOPSGzPh#>83z`bHsl5)>SP~lwJKabBXKXy*dy-1>$^*) zjuYThbXVR9UB%8)M5URmrkgw?S|(qOSHLY3qjU z_Y6_A-P`XzzgzVkuswvhC%~%pvYlX}ogKLo=;SbeE8xg$y3o#I-H5@U{lrTBkBDm$ ziRBN=jpxHFIUxURfdr22g>kWnU!$O{UN+!+lfgp z$e+*YWhH`!ZOLVjbC`~O<8mYv9F7YuPt3DsUJSOZ%~x`dO8azo%R3I%-y|XiTV)RL zd-bt|8F%SiFon|KO$hz@t?8vxU8AT?WsL74)*&2b`&Me%MJaj{4>cF)SLY4&-&G3P z{;7IJ-F9W6>{KN0DYGYs{vAv+A|aP!VMCfkV*0x@B- z^Q*H0N^vTNKfZtuIC6ckK`rQ8`-7~@kyD8z6;B|YJN@K>5!#4Qj!cxBPRc9`X!(cm zIWB-3N+#`HZWH(=phK36^u%+OF6@cOVF|pm^FY1_1tfEcSr*h`e}J~n6nmGh%DZ;z z%jv5IFXm^LE6Z)3dX227Ru%*mANvP;)b|V^`yg}u=YJ!U4upL;)7O3Y$b+}M{e_Do zWy{P?T+Rrw@t-kVO#~s&l=!ImJ^YayUto7eQi<-Ooq;CM5yRdp+<6KxT64&ioL|Va zH;9po4hCm}_D#GZpbKU3R^wJkm=|6aAiO+c#Y2Ejb%coYOrA8R8(T;Cb^wJ{ zir_ppZ;8^*>=7c~A7C)T@$cd5q2~e7(2(Dfrcm7FJ1^A_8_LdC&VSJNHv?+_f3cO< zj{v}1|B0x>@60h@U(&}QM3hBE@{If;|3R6``Gs-ZDYo2dyoh{=ALZt1l$fVOA28Q?B5_(28g zxmpWECvH$dqHrE*oapmfuST5iy`>N_p# zI-ZiA01liz$lYd(t3dF;FeNx~bX|dyen@SKQ15dUMzPGwo1hY%_zIZIU{eMLWcQin z*8xp(V3Z1d@O}GxsnF)ykN3Np7;QRLV(J*w6Ntffs|PtAin1Ob^SuydI{odo!*5tn?6*MiXy~hPr3bb zK5VqD+Q*>+0_@Ate9qB6L10N_{R%s;3XAVzkYu}y#6+?6FLe{K~-=%JlS0zG!p$WT5h6h^V(c=B>P}v2e}IFB-?;5P(2p0GN?6nl05nDYaP|A$E@x666T1exi?JAQ|6OgMOK4S%!@c3N zv$hP4_JYTqUAUjv*Rn_l152nIzpPsX$U?Dl-fc@9_8wbO&PA2&JI#|fLa?!1cGA>tTkk7rp|sQ<2Yor<5E2bR*@nYd zh7gz67tNqOyp-Xr`7hNi51F&tna9Y_ANq9{a6Jdy1Xs)8-0yWAT~c9H8kq5oNDREf zbzWYk6QoK~@t3~<)Rwt>Je8^_$kl1HsHXRY!3W~jP|h>!q*2+Z#qM528Wy4J3J^7Q z;t55AjZx?>><~dNDgj`fw*eFfsM4JQWgwXm8c~Yg>5ShJ!qF_UVNiGBVX%p*u$chf z7FL`Wot{uwgGi*5mXXouZCBv6+Z2G2H_@yllrK|59o5V z))j(&hd90l>he85S`%T|7B&OT)8qHpI6koH{z9!lEUu!6F{0+V4eT5?RY>KRe{g)k z^}SH3$lBoG1O37*pwb#`K?m)$1I$tGZaaB>#~7Jboc zBfS`)sL3dd&3X;03r#K0MnszyHdefLzfvBM-^g3p!;pS#A=WlsCPRo$5L^b|@IB4J zCn`daaUTu46lMbZmy)WI5{x!$)L?1kU%xhI{Wf4+@C{M)9}xMg$21E|WO+esCy13g zv#kJn;>(rk{nP+vG`?@*JXn@11a9b^-OUDF7{>yoMAug8ozn5u3_phl9JIj@!_Q6a zFQSi^pv;1u5V`Mg2J{^5PHVjJ=Ec*Lk?D5B_o!7`*JGTTgF0cF`2t3ZxU#8cKFrp|5Av=o>?`h4#F#e~un@ zMg5c>rY=nUxAfCN)_lxk&(`?|X}ljnq#v!ep8N`%m23^>o@gzM4;ql)XD9jT0PF_a zMp{)Kz;&Ap$E~AS$Vo`?;R^34%nC;OgQ9GAK|L1jLwzsg^?G_3n@f!frwsdz0M-|m zZg2NAt><*I8NJX&cZ`<|)H+s<8TKP6I=O{C&hU-Uj$VuG5F>YCIb^N@%#A<{$(*kO zR9M;J1^M~;M;2Fo8HJEXTuy~EqZUUpVkyygOVjgbTS8MBj8L52ziiI#Ch*Xu)6H7m z)!(KUw!@bZlW?)^fz?D8RLPqpS$UsF{wkC!k|F2w+;4$<3`!H?7K%u%*YLLEEr8A* z2wawB<>syW4NBW(RyTKm05%@REP1;)8t>}P5NrdiQ06=#Z$Y?JWLMS+wKL}4G0i9% zYfmy>MJ@FH1=^-5J&0&8+c(w*JbpiZJp;GUXn40aVTpQ*n!h!&hv0mfp6h1OOR%)D zS;nbJuWb1u-X+<+N&pVpD&&v4x5Z2}fGd{J*t;o|(?(bgE4P;i4sagT zxus!j3U#sz^=ZIxpcG58(M%_Fb=6yzi5)G*bh~U7+x_g{;o8bNE?qG_X=iY@PWoE5V zQ*t!B7{k|JS7Y)Ab18;9g)Ck?@&{0L@$~J~JKvDyg%9Ri%~ix(Kws>~Cv;doG{QJ{ zfz2pH?hn_E8J9PDB@Sj%7{#oo#%DYho&fBTv3p%4uE^^>gJA%iOeurwh5|*O(hQ&J zT9+<_6LCW3%haohult4&vnNlMjyQtgmcD4?O0R4uauK|?CR1lA2EB3jF1E#J@GSlo zkU2`#Zd>!6R&ZyafkFbYau8n7IJ1=dUdJ%G1d(|viREB$*PGOy9|kV-o%_2HrnaIi zF-JuWtjven&dwi^R8hM6?%nZ!+3Vka9JOQKNZb<|11;z0<3`42l84)(nM%*$hZY7ZEHGTWJSR0z!=Ik zxUIWd;a3Saqptxt>RnXY^>cnJ!<((my9vWBQrh0p4$-y9bKt+aKlcU%-Vgb`zz#DPBLaCzAa4fzq`_nVvE; zw1sf>j&p%XHnGeFz3!hVhbxo@Gd*VbU(;N94$HAQs^$a4h_*qtNCEtotpvof@PRy9@)9EwY;c@7yR1yS2;EgAhO5N zmbJN;cY#Lss=w@Wi(qo|-XOrey?*_APha>?nw)>z4<#LSWD5@)9#WF)Uvb%Gsa&O# zrWBY0c7`sPLI{<|&~br7=J%Y@BPq@7i0X8%!QGh7UL`|M3C!mKt6C+HrY+eaegUXA z6*Ixu(_P)AEyW;XQ--*oE0kRQ@f@A%*+o;1cfP~(mn=Y9=zzP>GI05(QG*0Y)~)Mq zityLQi1dz-0xEyDgo|{cA~8#Lel{7=YHn-9SaNHDQ;m7^fg=(NA0IIOP7?U&Oj2-_ z=J*ZZ9$-qne8+=nS?(zr(25cWW(lnj`2w%cEF=&-E}FRgl_t3*c<36VZvr7hgplR5 zl3l`=Fj^^&hzQVxIFY+{qjyQRp8CB#Gs)%Y5Y!lSM!2y-&I=Z5oA;Vz5TOHTwGD?0 z5`9;bXNcp${wL~da_93Y)9ILgJOuCAA&je`?BXJX+#Otp;@16pdPVl2{-r~i7UZs? zP4X-kroKY=wmkbm^SwsV^h1pjXnB~x*Y#Zgp}=zZaFhgNa!$jBkX;j}Sr64McH0R}Do(p_sJ)|E$(mUWiIusW^We=e%?D;jpq1v2$| z$MmRJDpjST`uuFiSmyd3O$73JkGCEM0etf23d8erFmjxKPhJ7U`2Ytx1e-3Qjc5(( zbplBL%$3fm-vKiKr>%g} zgzkF#7=b>v)*+$$g*_kvHltM9hob1e*PC~h9PZ8AZsq~+UqIX=bjm_4&>bE|%9OV{ zo1IP&%3EEAeZhv$F6kDaQi>di6+ksY{D5Nxx>uYMT3-Yhu55t}w(u}`xooek+MkvJ z!3`=JkrnY^{Jq%?(?K9JX7WK~mh8Fl$gdY+g=Lc;NLG2FmcrD3NL2L_F^o@`JomKR~o*Ydf*` z^tEKeHra~mn?(x*9&_o{-L;*j=nZU&))wK0>Tla1`Cx@_@r`fx$EW;@>Xzg1d716s z4$s8WAHe7LH|MG>G2BXmC= zh(rvfhP{2L)Jr206$Q!q_8e6c$6sV~qLBv=-# zi}Kn3n(ICnK&F z(F__ztJ%MELlOPE|2cQAdm@zqby#U|8!h+#j&C4O-w;qPuUpTnk8&-O z-Qr250I>p)HyQ;2MNPZLZ9{88_~{?UapPb=zp&r=$lfP zeG_qsxkq`Wj&9OgtzpA!YhgjSOgtiKV`GD;Q|b720vFtoEaOaj+EWT&p9ypm#os%^ z18*H$9HV#e7dF`X4mkge)TK&@_*dQ@J|un5ur(wyGbUlp25!x_L^CshD;loRf@(J4 zq?;#I{ldzK0`iM-@tCrHpe%TggWBN6-JGVa^YvJP{?UH`xTM2Ov=ymbZ5nK`_o;*| zgVOwW)j*g(Ko3e&v$nqBC)ZdxLyt%C>dTWnP<{B>1&{R}f?KVXC2*G%T3;$HgzR4B zrgsVrCt*50J{XV7C4)DIy)n_|S&g*IUGDej39bWD6epM%WVl-wnha^Zg)hZ(<4dFa z;d%{&Z z+&8aK8iE}k$N?OLgnNX3M6U;ZryTzXXnK*tyy2~}sd$CR*6=w-oDx$STp}ddA8bTR zeYbqRpkT5zU~~Jj2;KbdmflE@5f0yWwBR;hSCoY?TseHYH$Mss6sIm#w_Mn*U-Ay< zU0~O^KJu*WCUbK-t<)g2zVhabgD8XX`Ecgy9&+o0Ve8}HRt{L=3_WJQQrkn^izfSF z@RexZ>d>E84^cly%!hMDS`ZzdmUz-9Sa4&`xUng^S>Zu(#l3>`tPx@d+Q4nFtPm>- z3SYkeGkT{8Xf@IxgP6qU5`72ub7&)Kv(A_{LX<-n7UYU7vLt@CI1gJw3-yjF1VdJ1 z>ML5y3%s03kxxsboLsqkAhYt2yb$qpXFvusSmtWQQ2YBjEbNWhERZLHdMNS;5Iz&o zl)V!wx3*o-4z+r-m7M?$3w!`brCT-B4UIo+#qI~Ma>vp~ewJt-3(e~>ofzCA{(O5w zy0tOBWaR6;(K6$>^5wSab8Y))>Lsa&!V7~>%xp|Hc!lf-oy!X{4J&lT$*=i1_y?AZ zLv&8*Ra(Ddlv^hqIZI?aOdmN%XIbWzYx$W}a+mGT7wru2I{azPm?x&HK5ZaXFERp3FV@t zPD>rX#=q$Oei4O~ESCtLm)WlRow)&4;l-6Z9$1QeoJ;dRSwkUxbwf9E8?$9@#B^4$ zu*$j5H~DnbnOJzlJXQGSkD;P^?jg#m0xgW#WZ_ZFi4#>$oe7e%du>q6;IoQ$MZ4``UnU1H~#(ONga0?6))e!4(59HHH}6R< zO^R`O6un)04T2Fi0xcw`>3XqF-MoPIU}eg5iu0^**bveLp-RiP@JdW6T(f2`UMq{p zE;o}OP!!B)S}U=cG3G8|ES0QqM)pP&r<1Gkq2*u9-fxPge;s1R60=0rwqZ~bzQp9fc1=w!x^yPORXDvU$Vb`#j#Mfe939+`F> zv9t?7TZKP(&VPxK!wlHBH?qN@Mo&y^B)A@|upexcXgUP6NYPijypw%9HSN>8L~I_D zL1s4+C?3aSVmAegoldd7cdE}^&d%z7JxvL@xPAgRfX8=|&#etWZ6^hUD?*Ic$kl3B zQVUV|@u|$Dai`_+5gFEhFzg4IVEo0vvqxzu_dv3GH14I7fLzePUL@?Z`2RW9hl z8*{aSIa696%yBLNSMGRCC|9vDObSWOif3yj)k!t{i(+t+7C9 zPDqbWWGSw*F}M$}E|2$XnMRqz<38~#2OT?B$q|*t?~61oITW9I<;^4G)-;17AI%se zak#=q*DBoIN3Dg4-aHwjk~fe9`jaR9-|Oq5d~k=d_1>{`QN(^0q_cwh_#~#C|9Hbl z-?DGO;^>Ft*i{}zx)o6TDy+7=aF77njGT*!x6K6`@QC=OM{d~-uFipjnSAVf zbM1u8C2R9kZa8cvI2>f`@apL|gZ-PGjZLatz~eu_QyE&3XMevN|7C~&`J?(xCidnM z(-%L5Pc5RLkISMN`L~ZR`0l9ax-_1@mi{#U2lP?On`|vc82=xwnK9vykB)3VPFRiZ za$)>ui`(i37s&9^skiV@TZzsy%|==;ga3UL_4QA&@I!&x-L$IJ;GRDG@~ zW_zwqSVL?tH}~?ZEKw1{@4A{lxT_p3KF`rYLSF%hxq$I&znG659UZy7%Bs(&zr5wA z=IR2^AL~X0{8!@=J@rjC;piI|rh}|q+>9YTF$=OWONE-1@oI>r@%*31>er%^E`M|t zBYppJ72rj*S!IE5abj;CIPL5p^oQ8{--p%5&6LtQz-@aUARziuK29gvHdM}wh`F^W zc0=wn|6fPtADjM)nP+dbO+weYcX+F8xzm>BLmYm~;0XI%nOYp4d9GiXlDa_!TG_Oo z!G)k4)A$kuI7I&xX#HjnviuoMqRb_-4!6AkEb*0D_OTNj4o8;xxVp|`B}3=+wC$-m zC0aD^|7z)e3IsA@PO*R96o|CknjJT?zn=StUH^lr z+UYY--Fosh{O`@PTpzZ_FN&keZe;C65=M(%A)y)}Q2JLNa_M$H-4y+X35CfXq z&BUv(?hKy^#kC#AEw%!0aj|NM>4yC?B@P(xfAHEL<5)e$D`51F4qZt(?j|u2(IH@l zrC07;exwQE(mU|_#H~}WjlTWfpYjjid0msqDZ3=;B4^)~CW*rd2?@I?efGw;FCPfg zIdhfgV&>qKfwk0`R*pLRL+s7=0>Cf+ZdQi_bXZ!ZQ*$6j`8r*XhhyjxzSU?9J@qcm6YU`KOumNi%Wwy%Q2=EQ)G@ zW@o_Zsb6ayc9(f-{e2N^%9r%mtG29c2n;lx`Ic5B{D%Paw-fq@-TrSj@BjbUe_TmB cG&Ak6i3w$v`J^@R0Psgg)8JO&4ZA1*4--|`+5i9m literal 0 HcmV?d00001 diff --git a/core/mqueue/assets/direct-exchange.png b/core/mqueue/assets/direct-exchange.png new file mode 100644 index 0000000000000000000000000000000000000000..f519dce78ce0b46567f997c5269436a4a94a9ea2 GIT binary patch literal 42545 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

;2iY&&*OHj2Y*uv8YP>TwVNP!F|O8q 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 S@KgZ}cqz+k$d$=j2K*mBg?*y{ literal 0 HcmV?d00001 diff --git a/core/mqueue/assets/fanout-exchange.png b/core/mqueue/assets/fanout-exchange.png new file mode 100644 index 0000000000000000000000000000000000000000..823a250264b00b16ed712b510c3019df29037343 GIT binary patch literal 26191 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 literal 0 HcmV?d00001 diff --git a/core/mqueue/assets/simple-view.png b/core/mqueue/assets/simple-view.png new file mode 100644 index 0000000000000000000000000000000000000000..b24e6b8b30222b1817ebf75e8fc8be7a7e7a4bcd GIT binary patch literal 36356 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 Fe*jrDWa0n- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1ba0f821e5f05f48a09b5989fec38d80eee05aaa GIT binary patch literal 54748 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 literal 0 HcmV?d00001 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, + ); + }); + }); +}