diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a77d1a..0e61ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## 3.0.0 + +### ⚠️ Breaking changes +- Minimum Dart SDK is now `>=3.13.0` +- `equatable` upgraded to `^3.0.0`: `Entity`, `Params`, `Issue` and `Codable` now extend `Equatable` instead of using a mixin, and `Codable.serializer` is now typed as `Codec` + +### 📦 Dependencies +- `meta` upgraded to `^1.19.0` and `test` to `^1.32.0` + +### 🧹 Internal +- Analysis options migrated to `package:coolint/dart.yaml` and updated to `coolint` `3.0.0` +- Constructors migrated to the primary constructor syntax +- `unnecessary_await_in_return` lint disabled: it is deprecated since Dart 3.13 and contradicts `async_return_with_no_await` +- Documentation references fixed and the README examples updated to the current API + ## 2.0.0 ### ⚠️ Breaking changes - Change codable naming and field extend diff --git a/README.md b/README.md index 28e1a2c..152ecba 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,13 @@ A bedrock package providing the blueprints and abstract definitions necessary to ## 🚀 Installation +Requires Dart `>=3.13.0`. + Add the package to your `pubspec.yaml`: ```yaml dependencies: - cool_bedrock: ^1.0.0 + cool_bedrock: ^3.0.0 ``` Then run: @@ -50,18 +52,60 @@ import 'package:cool_bedrock/cool_bedrock.dart'; This example demonstrates how to implement a UseCase, defining its specific Failure and parameter validation: ```dart -// 1. Define the specific Failure for this domain -sealed class FetchUserFailure extends Failure{ - const FetchUserFailure(); +// 1. Define the domain types: params, entity and the raw remote model. +final class FetchUserParams extends Params { + const FetchUserParams({required this.userId}); + + final String userId; + + // Checked automatically before executing the usecase. + @override + bool get isValid => userId.isNotEmpty; + + @override + List get props => [userId]; +} + +final class UserRemote { + const UserRemote({this.name}); + + final String? name; + + UserEntity toEntity() => UserEntity(name: name ?? 'Unknown'); +} + +final class UserEntity extends Entity { + const UserEntity({required this.name}); + + final String name; + + @override + List get props => [name]; } + +// In this example the repository returns a Future>. + +// 2. Define the specific Failure for this domain +sealed class FetchUserFailure extends Failure { + const FetchUserFailure({super.message}); +} + final class InvalidUserFailure extends FetchUserFailure { const InvalidUserFailure() : super(message: 'Invalid User ID provided.'); + + @override + List get props => [message]; } + final class InvalidParamsUserFailure extends FetchUserFailure { - const InvalidParamsUserFailure() : super(message: 'Invalid parameters provided.'); + const InvalidParamsUserFailure() + : super(message: 'Invalid parameters provided.'); + + @override + List get props => [message]; } -// 2. Implement the UseCase contract +// 3. Implement the UseCase contract final class FetchUserUseCase extends UseCase { const FetchUserUseCase(this.repository); @@ -74,11 +118,17 @@ final class FetchUserUseCase @override Future> execute( - FetchUserParams params) async { + FetchUserParams params, + ) async { // Core logic goes here. Mappers and Repositories are typically called here. try { - final user = await repository.fetch(params.userId); - return Right(user); // Success + final result = await repository.fetch(params.userId); + return result.fold( + // Map the repository failure to a domain failure + (failure) => const Left(InvalidUserFailure()), + // Map the remote model to the entity + (remote) => Right(remote.toEntity()), + ); // Success } catch (e) { // Map low-level errors to high-level domain failures return const Left(InvalidUserFailure()); // Failure @@ -86,17 +136,15 @@ final class FetchUserUseCase } } +// 4. Or use the Handler: obtain -> transform -> map errors final class FetchUserUseCaseHandle - extends - UseCaseHandler< - UserEntity, - AuthParams, - FetchUserFailure, - RepositoryValue - > { - const FetchUserUseCaseHandle({ - required LoginRepository repository, - }); + extends UseCaseHandler< + UserEntity, + FetchUserParams, + FetchUserFailure, + UserRemote + > { + const FetchUserUseCaseHandle({required this.repository}); final UserRepository repository; @@ -104,29 +152,23 @@ final class FetchUserUseCaseHandle @override FetchUserFailure onInvalidParams() => const InvalidParamsUserFailure(); - //Obtain repository values. Multiple repository can be call. + // Obtain repository values. Multiple repositories can be called. @override - Future obtainValues( + FutureOr obtainValues( Resolver $, - AuthParams params, + FetchUserParams params, ) async { - final user = await $( - getValue( - () => repository.fetch(params.userId), - ), - ); - return user; + // getValue unwraps a Future> and maps its errors. + return await $(getValue(() => repository.fetch(params.userId))); } @override - UserEntity transformation( - RepositoryValue values, - ) { - if(values.name == null || values.name.isEmpty){ + UserEntity transformation(UserRemote values) { + if (values.name == null || values.name!.isEmpty) { throw const UsecaseException(InvalidUserFailure()); } - //Can throw exception of any kind and it will be control by [wrapError] - return values.toEntity() + // It can throw any exception, it will be handled by [wrapError]. + return values.toEntity(); } @override @@ -134,14 +176,13 @@ final class FetchUserUseCaseHandle return const InvalidUserFailure(); } } - ``` ### 2. Execution and Error Handling ```dart // Execution with valid parameters -final validParams = const FetchUserParams('user_123'); +const validParams = FetchUserParams(userId: 'user_123'); final validResult = await fetchUserUsecase.call(validParams); validResult.fold( diff --git a/analysis_options.yaml b/analysis_options.yaml index d1d7561..4f05e3a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1 @@ -include: package:coolint/coolint.yaml -analyzer: - errors: - document_ignores: ignore +include: package:coolint/dart.yaml diff --git a/example/lib/data/user/user_remote.dart b/example/lib/data/user/user_remote.dart index 6796ed8..c71b012 100644 --- a/example/lib/data/user/user_remote.dart +++ b/example/lib/data/user/user_remote.dart @@ -1,5 +1,5 @@ final class UserRemote { - const UserRemote({ + const new({ this.name, this.surname, this.direction, diff --git a/example/lib/data/user/user_repository.dart b/example/lib/data/user/user_repository.dart index d1f9ef6..865efe4 100644 --- a/example/lib/data/user/user_repository.dart +++ b/example/lib/data/user/user_repository.dart @@ -8,11 +8,11 @@ abstract base class UserRepository { } sealed class UserRepositoryError extends RepositoryError { - const UserRepositoryError(); + const new(); } final class NoUserFindError extends UserRepositoryError { - const NoUserFindError(); + const new(); @override List get props => [message]; } diff --git a/example/lib/domain/entities/user_entity.dart b/example/lib/domain/entities/user_entity.dart index 65f1b1c..df79c2e 100644 --- a/example/lib/domain/entities/user_entity.dart +++ b/example/lib/domain/entities/user_entity.dart @@ -1,7 +1,7 @@ import 'package:cool_bedrock/cool_bedrock.dart'; final class UserEntity extends Entity { - const UserEntity({ + const new({ required this.name, required this.surname, required this.birthday, @@ -12,9 +12,5 @@ final class UserEntity extends Entity { final DateTime birthday; @override - List get props => [ - name, - surname, - birthday, - ]; + List get props => [name, surname, birthday]; } diff --git a/example/lib/domain/usecase/user_information_usecase.dart b/example/lib/domain/usecase/user_information_usecase.dart index 313f94f..f77b83a 100644 --- a/example/lib/domain/usecase/user_information_usecase.dart +++ b/example/lib/domain/usecase/user_information_usecase.dart @@ -20,7 +20,7 @@ export 'package:example_cool_bedrock/domain/entities/user_entity.dart'; abstract base class UserInformationUsecase extends UseCase { /// {@macro example_cool_bedrock.usecase} - const UserInformationUsecase(); + const new(); } /// {@template example_cool_bedrock.usecaseHandler} @@ -45,11 +45,11 @@ abstract base class UserInformationUsecase abstract base class UserInformationUsecaseHandler extends UseCaseHandler { /// {@macro example_cool_bedrock.usecaseHandler} - const UserInformationUsecaseHandler(); + const new(); } final class AuthParams extends Params { - const AuthParams({required this.userId}); + const new({required this.userId}); final String userId; @@ -57,25 +57,22 @@ final class AuthParams extends Params { bool get isValid => userId.isNotEmpty; @override - List get props => [ - userId, - ]; + List get props => [userId]; } sealed class FetchUserFailure extends Failure { - const FetchUserFailure({required super.message}); + const new({required super.message}); } final class InvalidUserFailure extends FetchUserFailure { - const InvalidUserFailure() : super(message: 'Invalid User ID provided.'); + const new() : super(message: 'Invalid User ID provided.'); @override List get props => [message]; } final class InvalidParamsUserFailure extends FetchUserFailure { - const InvalidParamsUserFailure() - : super(message: 'Invalid parameters provided.'); + const new() : super(message: 'Invalid parameters provided.'); @override List get props => [message]; } diff --git a/example/lib/main.dart b/example/lib/main.dart index 5e54daf..4946d9d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,4 @@ -// ignore_for_file: avoid_print +// ignore_for_file: avoid_print Example view import 'package:example_cool_bedrock/data/user/user_repository.dart'; import 'package:example_cool_bedrock/domain/usecase/user_information_usecase.dart'; @@ -20,13 +20,15 @@ Future main(List args) async { print('\n--- 2. UseCase Creation ---'); // A. Standard Approach (Programmer manually handles execution) - final UserInformationUsecase standardUseCase = - FetchUserUseCase(userRepository); + final UserInformationUsecase standardUseCase = FetchUserUseCase( + userRepository, + ); print('✅ Standard UseCase created.'); // B. Programmatically handled - final UserInformationUsecaseHandler handledUseCase = - FetchUserUseCaseHandle(repository: userRepository); + final UserInformationUsecaseHandler handledUseCase = FetchUserUseCaseHandle( + repository: userRepository, + ); print('✅ Handled UseCase created.'); print('\n--- 3. Scenario: Valid User (cool_bedrock) ---'); diff --git a/example/lib/src/user_information_usecase/fetch_user_use_case.dart b/example/lib/src/user_information_usecase/fetch_user_use_case.dart index 72c04fa..8974946 100644 --- a/example/lib/src/user_information_usecase/fetch_user_use_case.dart +++ b/example/lib/src/user_information_usecase/fetch_user_use_case.dart @@ -7,7 +7,7 @@ import 'package:example_cool_bedrock/src/user_information_usecase/user_remote2_e /// {@macro example_cool_bedrock.usecase} final class FetchUserUseCase extends UserInformationUsecase { /// {@macro example_cool_bedrock.usecase} - const FetchUserUseCase(this.repository); + const new(this.repository); final UserRepository repository; diff --git a/example/lib/src/user_information_usecase/user_information_usecase_handle.dart b/example/lib/src/user_information_usecase/user_information_usecase_handle.dart index bea8164..670ae54 100644 --- a/example/lib/src/user_information_usecase/user_information_usecase_handle.dart +++ b/example/lib/src/user_information_usecase/user_information_usecase_handle.dart @@ -9,7 +9,7 @@ import 'package:example_cool_bedrock/src/user_information_usecase/user_remote2_e final class FetchUserUseCaseHandle extends UserInformationUsecaseHandler { /// {@macro example_cool_bedrock.usecaseHandler} - const FetchUserUseCaseHandle({required this.repository}); + const new({required this.repository}); final UserRepository repository; @@ -24,17 +24,13 @@ final class FetchUserUseCaseHandle AuthParams params, ) async { final user = await $( - getValue( - () => repository.call(userId: params.userId), - ), + getValue(() => repository.call(userId: params.userId)), ); return user; } @override - UserEntity transformation( - UserRemote values, - ) { + UserEntity transformation(UserRemote values) { if (values.name == null || (values.name ?? '').isEmpty) { throw const UsecaseException(InvalidUserFailure()); } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index e255f32..cc529f3 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -4,12 +4,12 @@ publish_to: none version: 1.0.0 environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ">=3.13.0 <4.0.0" dependencies: cool_bedrock: path: ../ dev_dependencies: - coolint: 2.1.2 - test: ^1.28.0 + coolint: 3.0.0 + test: ^1.32.0 diff --git a/lib/src/codable.dart b/lib/src/codable.dart index a754fed..b2cb04b 100644 --- a/lib/src/codable.dart +++ b/lib/src/codable.dart @@ -9,7 +9,7 @@ import 'package:meta/meta.dart'; /// from a remote representation to a local Dart object (decoding/deserialization). /// /// This abstract class enforces type safety for mapping operations and integrates -/// with [EquatableMixin] for reliable object comparison. +/// with [Equatable] for reliable object comparison. /// /// **Type Parameters:** /// * **T**: The remote data type (e.g., `Map`, `String`, or `Uint8List`). @@ -18,9 +18,9 @@ import 'package:meta/meta.dart'; /// This is known as the **F-Bounded Polymorphism** pattern. /// {@endtemplate} @immutable -abstract class Codable> with EquatableMixin { +abstract class Codable> with Equatable { /// {@macro cool_bedrock.codable} - const Codable(); + const new(); /// The specific string encoding (e.g., 'utf-8') required for the remote data. /// @@ -32,7 +32,7 @@ abstract class Codable> with EquatableMixin { /// and Dart objects. /// /// This is typically provided by `dart:convert`. - Codec? get serializer; + Codec? get serializer; /// Decodes the remote data representation into an instance of the local Dart model. /// @@ -51,7 +51,7 @@ abstract class Codable> with EquatableMixin { @immutable abstract class JsonBytesCodable> extends Codable { - const JsonBytesCodable(); + const new(); @override Encoding get encoding => const Utf8Codec(allowMalformed: true); @@ -90,7 +90,7 @@ abstract class JsonBytesCodable> abstract class JsonStringCodable> extends Codable { /// {@macro cool_bedrock.json_bytes_codable} - const JsonStringCodable(); + const new(); /// Specifies the required string encoding for decoding the byte array. /// @@ -120,7 +120,7 @@ abstract class JsonStringCodable> Self decode(String remote) => instanceFromMap(deserialize(remote)); /// Performs the actual byte array to Dart Map deserialization using the - /// defined [stringEncoding] and [serializer]. + /// defined [encoding] and [serializer]. /// /// Handles cases where the JSON array might represent a list of items /// (which is wrapped into a 'data' map key). diff --git a/lib/src/domain/entity.dart b/lib/src/domain/entity.dart index def6db6..fcafceb 100644 --- a/lib/src/domain/entity.dart +++ b/lib/src/domain/entity.dart @@ -17,7 +17,7 @@ import 'package:meta/meta.dart'; @Immutable('Entities must be immutable') abstract class Entity extends Equatable { /// {@macro cool_bedrock.entity} - const Entity(); + const new(); @override List get props; diff --git a/lib/src/domain/params.dart b/lib/src/domain/params.dart index 65fa3c6..5848392 100644 --- a/lib/src/domain/params.dart +++ b/lib/src/domain/params.dart @@ -11,9 +11,9 @@ import 'package:meta/meta.dart'; /// All concrete parameter classes must be immutable and define validation logic. /// {@endtemplate} @immutable -abstract class Params with EquatableMixin { +abstract class Params with Equatable { /// {@macro cool_bedrock.params} - const Params(); + const new(); /// Checks if the parameters contained within this object are valid for /// Usecase execution (e.g., non-null, correct format, etc.). @@ -37,7 +37,7 @@ abstract class Params with EquatableMixin { /// {@endtemplate} final class NoParams extends Params { /// {@macro cool_bedrock.no_params} - const NoParams(); + const new(); /// Always returns true, as there are no parameters to validate. @override diff --git a/lib/src/domain/usecase.dart b/lib/src/domain/usecase.dart index 43fccb0..b439b42 100644 --- a/lib/src/domain/usecase.dart +++ b/lib/src/domain/usecase.dart @@ -23,7 +23,7 @@ export 'params.dart'; /// {@endtemplate} sealed class Case { /// {@macro cool_bedrock.case} - const Case(); + const new(); /// Executes the command or query with the provided parameters. /// @@ -51,7 +51,7 @@ sealed class Case { /// than unhandled exceptions, leading to more stable applications. /// - **Clean Architecture Compliant**: Forces a clear separation between the /// caller (UI/Bloc) and the business logic. -/// - **Built-in Validation**: Automatically checks [params.isNotValid] before +/// - **Built-in Validation**: Automatically checks [Params.isNotValid] before /// execution, preventing logic errors from bad input. /// /// ### ❌ Downsides: @@ -61,10 +61,14 @@ sealed class Case { /// need to define Failure types and Params for every action. /// /// {@endtemplate} -abstract class UseCase extends Case { +abstract class UseCase< + TYPE extends Entity, + UsecaseParams extends Params, + LEFT extends Failure +> + extends Case { /// {@macro cool_bedrock.usecase} - const UseCase(); + const new(); /// Defines the specific [Failure] that should be returned when the /// input [UsecaseParams] are invalid. @@ -79,7 +83,7 @@ abstract class UseCase - extends UseCase with UsecaseFlowManager { +abstract class UseCaseHandler< + TYPE extends Entity, + UsecaseParams extends Params, + LEFT extends Failure, + VALUES extends Object +> + extends UseCase + with UsecaseFlowManager { /// {@macro cool_bedrock.usecase_handler} - const UseCaseHandler(); + const new(); /// Retrieves the necessary data or dependencies to perform the use case logic. /// @@ -180,7 +189,7 @@ abstract class UseCaseHandler extends Case { /// {@macro cool_bedrock.one_way_usecase} - const OneWayUseCase(); + const new(); /// Executes the logic and returns an [Option] containing the [TYPE] /// if successful and found, or [None] if the value is absent. @@ -215,10 +224,13 @@ abstract class OneWayUseCase /// **Type Parameter:** /// * [TYPE]: The specific type of [Failure] returned if the condition fails. /// {@endtemplate} -abstract class OneWayFailureUseCase extends Case { +abstract class OneWayFailureUseCase< + TYPE extends Failure, + UsecaseParams extends Params +> + extends Case { /// {@macro cool_way_failure_usecase} - const OneWayFailureUseCase(); + const new(); /// Executes the logic and returns an [Option] containing the [Failure] /// if the condition fails, or [None] if the check passes (success). diff --git a/lib/src/domain/usecase_flow_manager.dart b/lib/src/domain/usecase_flow_manager.dart index cbcd4ec..eb6dc95 100644 --- a/lib/src/domain/usecase_flow_manager.dart +++ b/lib/src/domain/usecase_flow_manager.dart @@ -7,8 +7,12 @@ import 'package:meta/meta.dart'; typedef Resolver = Future Function(TaskEither); -mixin UsecaseFlowManager on UseCase { +mixin UsecaseFlowManager< + TYPE extends Entity, + UsecaseParams extends Params, + LEFT extends Failure +> + on UseCase { /// Handles unexpected errors and maps them to a specific [LEFT] failure. LEFT wrapError(Object error, StackTrace stackTrace); @@ -16,7 +20,7 @@ mixin UsecaseFlowManager handler({ required FutureOr Function(Resolver $) getValues, @@ -26,18 +30,17 @@ mixin UsecaseFlowManager.tryCatch( () async { final data = await TaskEither.Do(($) async { - final data = await getValues($); - return $( - mapper( - () { - return transform(data); - }, - wrapError, - ), - ); + final values = await getValues($); + // ignore: async_return_with_no_await will be wait for TaskEither.Do + return $(mapper(() => transform(values), wrapError)); }).run(); - return data.fold((l) => throw UsecaseException(l), (r) => r); + final result = data.fold( + (l) => throw UsecaseException(l), + (r) => r, + ); + + return result; }, (error, s) { if (error is UsecaseException) { @@ -63,7 +66,9 @@ mixin UsecaseFlowManager.tryCatch( () async { final result = await run(); - return result.fold((l) => throw _DataLayerError(l), (r) => r); + final value = result.fold((l) => throw _DataLayerError(l), (r) => r); + + return value; }, (error, stack) { if (error is _DataLayerError) { @@ -88,7 +93,7 @@ mixin UsecaseFlowManager.tryCatch( () async { - return entitiesMapper(); + return await entitiesMapper(); }, (error, s) { if (error is UsecaseException) { @@ -102,7 +107,7 @@ mixin UsecaseFlowManager implements Exception { - const _DataLayerError(this.issue); + const new(this.issue); final ISSUE issue; } diff --git a/lib/src/errors/issue.dart b/lib/src/errors/issue.dart index b7aaa15..7f29b58 100644 --- a/lib/src/errors/issue.dart +++ b/lib/src/errors/issue.dart @@ -13,7 +13,7 @@ import 'package:equatable/equatable.dart'; /// the same package/library, enabling robust pattern matching. /// {@endtemplate} sealed class Issue extends Equatable { - const Issue({this.message}); + const new({this.message}); /// A human-readable message describing the issue or the cause of the error. final String? message; @@ -31,7 +31,7 @@ sealed class Issue extends Equatable { /// {@endtemplate} abstract base class Failure extends Issue { /// {@macro cool_bedrock.failure} - const Failure({super.message}); + const new({super.message}); } /// {@template cool_bedrock.repository_error} @@ -44,7 +44,7 @@ abstract base class Failure extends Issue { /// {@endtemplate} abstract base class RepositoryError extends Issue { /// {@macro cool_bedrock.repository_error} - const RepositoryError({super.message}); + const new({super.message}); } /// {@template cool_bedrock.data_source_exception} @@ -56,7 +56,7 @@ abstract base class RepositoryError extends Issue { /// {@endtemplate} abstract base class DataSourceException extends Issue implements Exception { /// {@macro cool_bedrock.data_source_exception} - const DataSourceException({ + const new({ this.requestHeaders, this.requestUri, this.requestBody, @@ -87,7 +87,7 @@ abstract base class DataSourceException extends Issue implements Exception { /// that is a subtype of [Failure]. /// {@endtemplate} final class UsecaseException implements Exception { - const UsecaseException(this.failure); + const new(this.failure); final Promotional failure; } diff --git a/lib/src/service/app_service.dart b/lib/src/service/app_service.dart index 6a9fa98..df1c3b3 100644 --- a/lib/src/service/app_service.dart +++ b/lib/src/service/app_service.dart @@ -26,7 +26,7 @@ part 'stream_base/single_replay_subject/single_replay_subject_service.dart'; /// {@endtemplate} abstract interface class AppService { /// {@macro cool_bedrock.app_service} - const AppService(); + const new(); /// Initializes and starts the service, setting up necessary resources /// and starting background tasks or listeners. diff --git a/lib/src/service/stream_base/behavior_subject/behavior_subject_service.dart b/lib/src/service/stream_base/behavior_subject/behavior_subject_service.dart index 7056bdd..1f93dc6 100644 --- a/lib/src/service/stream_base/behavior_subject/behavior_subject_service.dart +++ b/lib/src/service/stream_base/behavior_subject/behavior_subject_service.dart @@ -13,7 +13,7 @@ part of '../../app_service.dart'; /// {@endtemplate} abstract base class BehaviorSubjectService implements AppService { /// {@macro cool_bedrock.behavior_subject_service} - BehaviorSubjectService(); + new(); BehaviorSubject? _behaviorSubject; diff --git a/lib/src/service/stream_base/behavior_subject/timer_and_behavior_service.dart b/lib/src/service/stream_base/behavior_subject/timer_and_behavior_service.dart index 9441294..2d5c9be 100644 --- a/lib/src/service/stream_base/behavior_subject/timer_and_behavior_service.dart +++ b/lib/src/service/stream_base/behavior_subject/timer_and_behavior_service.dart @@ -12,9 +12,7 @@ part of '../../app_service.dart'; /// {@endtemplate} abstract base class TimerAndBehaviorService implements AppService { /// {@macro cool_bedrock.timer_and_behavior_service} - TimerAndBehaviorService({required Duration periodicDuration}) - : _periodicDuration = periodicDuration, - super(); + new({required this._periodicDuration}) : super(); Timer? _timer; BehaviorSubject? _behaviorSubject; diff --git a/lib/src/service/stream_base/publish_subject/publish_subject_service.dart b/lib/src/service/stream_base/publish_subject/publish_subject_service.dart index ab8387d..255be61 100644 --- a/lib/src/service/stream_base/publish_subject/publish_subject_service.dart +++ b/lib/src/service/stream_base/publish_subject/publish_subject_service.dart @@ -12,7 +12,7 @@ part of '../../app_service.dart'; /// {@endtemplate} abstract base class PublishSubjectService implements AppService { /// {@macro cool_bedrock.publish_subject_service} - PublishSubjectService(); + new(); PublishSubject? _publishSubject; diff --git a/lib/src/service/stream_base/publish_subject/timer_and_publish_service.dart b/lib/src/service/stream_base/publish_subject/timer_and_publish_service.dart index d36f9ed..5b77842 100644 --- a/lib/src/service/stream_base/publish_subject/timer_and_publish_service.dart +++ b/lib/src/service/stream_base/publish_subject/timer_and_publish_service.dart @@ -10,8 +10,7 @@ part of '../../app_service.dart'; /// {@endtemplate} abstract base class TimerAndPublishService implements AppService { /// {@macro cool_bedrock.timer_and_publish_service} - TimerAndPublishService({required Duration periodicDuration}) - : _periodicDuration = periodicDuration; + new({required this._periodicDuration}); Timer? _timer; PublishSubject? _publishSubject; diff --git a/lib/src/service/stream_base/single_replay_subject/single_replay_subject_service.dart b/lib/src/service/stream_base/single_replay_subject/single_replay_subject_service.dart index d221d2a..65c271c 100644 --- a/lib/src/service/stream_base/single_replay_subject/single_replay_subject_service.dart +++ b/lib/src/service/stream_base/single_replay_subject/single_replay_subject_service.dart @@ -15,7 +15,7 @@ part of '../../app_service.dart'; /// {@endtemplate} abstract base class SingleReplaySubjectService implements AppService { /// {@macro cool_bedrock.single_replay_subject_service} - SingleReplaySubjectService({this.maxBufferSize}); + new({this.maxBufferSize}); /// Internal controller used for event management and broadcasting. /// It must be a broadcast stream since multiple listeners are possible. @@ -44,13 +44,14 @@ abstract base class SingleReplaySubjectService implements AppService { proxyController.onListen = () { //Subscribe to the live stream IMMEDIATELY to avoid missing events. - final liveSubscription = _singleReplayController!.stream.listen( - proxyController.add, - onError: proxyController.addError, - onDone: proxyController.close, - ) - // PAUSE the live subscription. Ensures that if new events arrive they are held. - ..pause(); + final liveSubscription = + _singleReplayController!.stream.listen( + proxyController.add, + onError: proxyController.addError, + onDone: proxyController.close, + ) + // PAUSE the live subscription. Ensures that if new events arrive they are held. + ..pause(); // Check the flag. If the buffer hasn't been consumed yet, it belongs to this listener. if (!_isFlushed && _noListenValues.isNotEmpty) { diff --git a/lib/src/simple_observer.dart b/lib/src/simple_observer.dart index 5efa1f0..e96c805 100644 --- a/lib/src/simple_observer.dart +++ b/lib/src/simple_observer.dart @@ -9,7 +9,7 @@ import 'package:meta/meta.dart'; /// {@endtemplate} abstract interface class SimpleObserver { /// {@macro cool_bedrock.simple_observer} - const SimpleObserver(); + const new(); /// Called immediately after a component has been successfully created and initialized. /// diff --git a/pubspec.yaml b/pubspec.yaml index dcdb45c..53b7be1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: cool_bedrock description: A bedrock package providing the blueprints and abstract definitions necessary to build a scalable and maintainable Dart/Flutter application. -version: 2.0.0 +version: 3.0.0 homepage: https://github.com/coolosos repository: https://github.com/coolosos/cool_bedrock @@ -14,16 +14,16 @@ topics: - core environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ">=3.13.0 <4.0.0" dependencies: fpdart: ^1.2.0 - equatable: ^2.0.7 - meta: ^1.17.0 + equatable: ^3.0.0 + meta: ^1.19.0 rxdart: ^0.28.0 collection: ^1.19.1 dev_dependencies: - coolint: 2.1.2 - test: ^1.28.0 + coolint: 3.0.0 + test: ^1.32.0 fake_async: ^1.3.3 diff --git a/test/behavior_subject_test.dart b/test/behavior_subject_test.dart index 9e8ac08..1e0ff4c 100644 --- a/test/behavior_subject_test.dart +++ b/test/behavior_subject_test.dart @@ -1,4 +1,4 @@ -// ignore_for_file: cascade_invocations +// ignore_for_file: cascade_invocations Test visibility import 'package:test/test.dart'; @@ -31,26 +31,28 @@ void main() { service.add(10); service.add(20); - await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); expect(results, containsAll([1, 10, 20])); await subscription?.cancel(); }); - test('should allow restarting after stop() without closing subject', - () async { - service.start(); - service.add(100); + test( + 'should allow restarting after stop() without closing subject', + () async { + service.start(); + service.add(100); - service.stop(); - expect(service.stopCalled, isTrue); + service.stop(); + expect(service.stopCalled, isTrue); - final nextValue = service.stream?.skip(1).first; + final nextValue = service.stream?.skip(1).first; - service.add(200); + service.add(200); - expect(await nextValue, equals(200)); - }); + expect(await nextValue, equals(200)); + }, + ); test('should permanently close subject on dispose()', () async { service.start(); diff --git a/test/codable_mock/codable_mock.dart b/test/codable_mock/codable_mock.dart new file mode 100644 index 0000000..3bee1ee --- /dev/null +++ b/test/codable_mock/codable_mock.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:cool_bedrock/cool_bedrock.dart'; + +/// Concrete model to exercise the [JsonBytesCodable] contract. +final class BytesUserMock extends JsonBytesCodable { + const new({this.name, this.age}); + + final String? name; + final int? age; + + @override + BytesUserMock instanceFromMap(Map data) => + BytesUserMock(name: data['name'] as String?, age: data['age'] as int?); + + @override + List get props => [name, age]; +} + +/// Concrete model to exercise the [JsonStringCodable] contract. +final class StringUserMock extends JsonStringCodable { + const new({this.name}); + + final String? name; + + @override + StringUserMock instanceFromMap(Map data) => + StringUserMock(name: data['name'] as String?); + + @override + List get props => [name]; +} + +/// Same as [StringUserMock] but without an [Encoding], so the deserialization +/// has to fall back to the plain JSON serializer. +final class NoEncodingUserMock extends JsonStringCodable { + const new({this.name}); + + final String? name; + + @override + Encoding? get encoding => null; + + @override + NoEncodingUserMock instanceFromMap(Map data) => + NoEncodingUserMock(name: data['name'] as String?); + + @override + List get props => [name]; +} + +/// Encodes [value] as UTF-8 bytes, mimicking a raw HTTP payload. +Uint8List utf8Bytes(String value) => Uint8List.fromList(utf8.encode(value)); diff --git a/test/codable_test.dart b/test/codable_test.dart new file mode 100644 index 0000000..d888c60 --- /dev/null +++ b/test/codable_test.dart @@ -0,0 +1,199 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; + +import 'codable_mock/codable_mock.dart'; + +void main() { + group('JsonBytesCodable', () { + test('should decode a JSON object into the concrete model', () { + const model = BytesUserMock(); + + final user = model.decode(utf8Bytes('{"name":"Coolosos","age":30}')); + + expect(user.name, equals('Coolosos')); + expect(user.age, equals(30)); + }); + + test('should return a JSON object as a map', () { + const model = BytesUserMock(); + + expect( + model.deserialize(utf8Bytes('{"name":"Coolosos","age":30}')), + equals({'name': 'Coolosos', 'age': 30}), + ); + }); + + test('should wrap a JSON array into a data map', () { + const model = BytesUserMock(); + + expect( + model.deserialize(utf8Bytes('[{"id":1},{"id":2}]')), + equals({ + 'data': [ + {'id': 1}, + {'id': 2}, + ], + }), + ); + expect( + model.decode(utf8Bytes('[{"id":1}]')), + isA(), + reason: + 'decode should delegate to instanceFromMap with the wrapped map', + ); + }); + + test('should throw an ArgumentError for a scalar payload', () { + const model = BytesUserMock(); + + expect( + () => model.deserialize(utf8Bytes('42')), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Unsupported type for deserialization'), + ), + ), + ); + expect( + () => model.decode(utf8Bytes('"just a string"')), + throwsArgumentError, + ); + }); + + test('should fail on malformed bytes once they break the JSON', () { + const model = BytesUserMock(); + + // 0xC3 starts a two byte sequence and 0x28 cannot continue it, so the + // lenient decoder produces a replacement char and the JSON is invalid. + expect( + () => model.deserialize(Uint8List.fromList([0xC3, 0x28, 0x7B])), + throwsFormatException, + ); + }); + + test('should expose utf8 encoding and JsonCodec by default', () { + const model = BytesUserMock(); + + expect(model.encoding, isA()); + expect(model.serializer, isA()); + expect(model.stringify, isTrue); + }); + + test('should compare models by props', () { + expect( + const BytesUserMock(name: 'Coolosos'), + equals(const BytesUserMock(name: 'Coolosos')), + ); + expect( + const BytesUserMock(name: 'Coolosos'), + isNot(equals(const BytesUserMock(name: 'Other'))), + ); + expect( + const BytesUserMock(name: 'Coolosos').hashCode, + equals(const BytesUserMock(name: 'Coolosos').hashCode), + ); + }); + }); + + group('JsonStringCodable', () { + test('should decode a JSON object into the concrete model', () { + const model = StringUserMock(); + + final user = model.decode('{"name":"Coolosos"}'); + + expect(user.name, equals('Coolosos')); + }); + + test( + 'should decode non ascii characters from its code units', + // Known bug: JsonStringCodable.deserialize pushes the String code units + // through the UTF-8 decoder, so any char above U+007F is mangled. + skip: 'deserialize corrupts non ascii chars (see codable.dart:133-136)', + () { + const model = StringUserMock(); + + final user = model.decode('{"name":"Cayetano Bañón"}'); + + expect(user.name, equals('Cayetano Bañón')); + }, + ); + + test('should return a JSON object as a map', () { + const model = StringUserMock(); + + expect( + model.deserialize('{"name":"Coolosos"}'), + equals({'name': 'Coolosos'}), + ); + }); + + test('should wrap a JSON array into a data map', () { + const model = StringUserMock(); + + expect( + model.deserialize('[1,2]'), + equals({ + 'data': [1, 2], + }), + ); + expect( + model.decode('[1,2]'), + isA(), + reason: + 'decode should delegate to instanceFromMap with the wrapped map', + ); + }); + + test('should throw an ArgumentError for a scalar payload', () { + const model = StringUserMock(); + + expect(() => model.deserialize('"just a string"'), throwsArgumentError); + expect(() => model.deserialize('null'), throwsArgumentError); + }); + + test('should fall back to the serializer when there is no encoding', () { + const model = NoEncodingUserMock(); + + expect(model.encoding, isNull); + expect( + model.deserialize('{"name":"Coolosos"}'), + equals({'name': 'Coolosos'}), + ); + expect(model.decode('{"name":"Coolosos"}').name, equals('Coolosos')); + }); + + test('should wrap a JSON array when there is no encoding', () { + const model = NoEncodingUserMock(); + + expect( + model.deserialize('[1,2]'), + equals({ + 'data': [1, 2], + }), + ); + }); + + test('should expose utf8 encoding, JsonCodec and stringify by default', () { + const model = StringUserMock(); + + expect(model.encoding, isA()); + expect(model.serializer, isA()); + expect(model.stringify, isTrue); + }); + + test('should compare models by props', () { + expect( + const StringUserMock(name: 'Coolosos'), + equals(const StringUserMock(name: 'Coolosos')), + ); + expect( + const StringUserMock(name: 'Coolosos'), + isNot(equals(const StringUserMock(name: 'Other'))), + ); + }); + }); +} diff --git a/test/domain_test.dart b/test/domain_test.dart index ad45c6c..4eadc1f 100644 --- a/test/domain_test.dart +++ b/test/domain_test.dart @@ -24,14 +24,14 @@ void main() { } final class MockEntity extends Entity { - const MockEntity(); + const new(); @override List get props => []; } final class MockParams extends Params { - const MockParams(); + const new(); @override bool get isValid => true; diff --git a/test/errors_test.dart b/test/errors_test.dart index 25b62b6..faa174c 100644 --- a/test/errors_test.dart +++ b/test/errors_test.dart @@ -28,25 +28,25 @@ void main() { } final class MockFailure extends Failure { - const MockFailure() : super(message: 'message'); + const new() : super(message: 'message'); @override List get props => []; } final class MockError extends RepositoryError { - const MockError() : super(message: 'message'); + const new() : super(message: 'message'); @override List get props => []; } final class MockException extends DataSourceException { - const MockException() - : super( - message: 'Message', - requestBody: null, - requestHeaders: const {}, - requestUri: null, - ); + const new() + : super( + message: 'Message', + requestBody: null, + requestHeaders: const {}, + requestUri: null, + ); } diff --git a/test/publish_subject_test.dart b/test/publish_subject_test.dart index 25873b4..b9023c0 100644 --- a/test/publish_subject_test.dart +++ b/test/publish_subject_test.dart @@ -20,7 +20,7 @@ void main() { service.add('Caught Event'); - await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); expect(results, ['Caught Event']); expect(results, isNot(contains('Lost Event'))); @@ -39,7 +39,7 @@ void main() { service.add('Hello'); - await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); expect(results1, ['Hello']); expect(results2, ['Hello']); @@ -53,7 +53,7 @@ void main() { service.stream?.listen(results.add); service.add('After Stop'); - await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); expect(results, ['After Stop']); }); diff --git a/test/service_lifecycle_test.dart b/test/service_lifecycle_test.dart new file mode 100644 index 0000000..0eda866 --- /dev/null +++ b/test/service_lifecycle_test.dart @@ -0,0 +1,302 @@ +// ignore_for_file: cascade_invocations Test visibility + +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:test/test.dart'; + +import 'service_mock/behavior/behavior_subject_mock.dart'; +import 'service_mock/behavior/timer_and_behavior_subject_mock.dart'; +import 'service_mock/behavior/timer_failing_subject_mock.dart'; +import 'service_mock/publish/publish_subject_mock.dart'; +import 'service_mock/publish/timer_publish_service_mock.dart'; +import 'service_mock/single_replay/single_replay_mock.dart'; + +void main() { + const duration = Duration(seconds: 10); + + group('add before start', () { + test( + 'BehaviorSubjectService should drop events added before start', + () async { + final service = BehaviorServiceMock(); + service.add(999); + + service.start(); + + expect(await service.stream?.first, equals(1)); + }, + ); + + test( + 'PublishSubjectService should drop events added before start', + () async { + final service = MockPublishService(); + service.add('early'); + + await service.start(); + + final events = []; + service.stream?.listen(events.add); + service.add('late'); + + await Future.delayed(Duration.zero); + + expect(events, isNot(contains('early'))); + expect(events, contains('late')); + }, + ); + + test( + 'SingleReplaySubjectService should drop events added before start', + () async { + final service = MockSingleReplayService(); + service.add('early'); + + service.start(); + + final events = []; + service.stream.listen(events.add); + + await Future.delayed(Duration.zero); + + expect(events, isNot(contains('early'))); + expect(events, contains('Init Event 1')); + }, + ); + }); + + group('restart after dispose', () { + test( + 'BehaviorSubjectService should create a new subject on start', + () async { + final service = BehaviorServiceMock(); + service.start(); + expect(await service.stream?.first, equals(1)); + + service.dispose(); + service.start(); + + expect(await service.stream?.first, equals(1)); + }, + ); + + test( + 'PublishSubjectService should create a new subject on start', + () async { + final service = MockPublishService(); + await service.start(); + service.dispose(); + await service.start(); + + final events = []; + service.stream?.listen(events.add); + service.add('after restart'); + + await Future.delayed(Duration.zero); + + expect(events, contains('after restart')); + }, + ); + + test( + 'SingleReplaySubjectService should create a new controller on start', + () async { + final service = MockSingleReplayService(); + service.start(); + service.dispose(); + service.start(); + + final events = []; + service.stream.listen(events.add); + + await Future.delayed(Duration.zero); + + expect(events, containsAll(['Init Event 1', 'Init Event 2'])); + }, + ); + + test('TimerAndBehaviorService should create a new subject and timer', () { + fakeAsync((async) { + final service = MockTimerService(periodicDuration: duration); + final results = []; + + service.start(); + service.stream?.listen(results.add); + async.elapse(duration); + expect(results, [1]); + + service.dispose(); + async.elapse(duration * 3); + expect(results, [1], reason: 'dispose must stop the timer'); + + service.start(); + service.stream?.listen(results.add); + async.elapse(duration); + expect(results, [1, 2], reason: 'the restarted service ticks again'); + + service.dispose(); + }); + }); + + test('TimerAndPublishService should create a new subject and timer', () { + fakeAsync((async) { + final service = MockTimerPublishService(periodicDuration: duration); + final results = []; + + service.start(); + service.stream?.listen(results.add); + async.elapse(duration); + expect(results, ['Tick']); + + service.dispose(); + async.elapse(duration * 3); + expect(results, ['Tick'], reason: 'dispose must stop the timer'); + + service.start(); + service.stream?.listen(results.add); + async.elapse(duration); + expect(results, ['Tick', 'Tick']); + + service.dispose(); + }); + }); + }); + + group('dispose', () { + test( + 'TimerAndBehaviorService should stop ticking and close the subject', + () { + fakeAsync((async) { + final service = MockTimerService(periodicDuration: duration); + final results = []; + + service.start(); + service.stream?.listen(results.add); + async.elapse(duration); + + service.dispose(); + service.add(42); + async.elapse(duration * 3); + + expect(results, [ + 1, + ], reason: 'neither the timer nor the subject alive'); + + service.start(); + service.add(7); + async.elapse(duration); + + expect(results, [1], reason: 'add must be ignored while disposed'); + + service.dispose(); + }); + }, + ); + + test( + 'TimerAndPublishService should stop ticking and close the subject', + () { + fakeAsync((async) { + final service = MockTimerPublishService(periodicDuration: duration); + final results = []; + + service.start(); + service.stream?.listen(results.add); + async.elapse(duration); + + service.dispose(); + service.add('manual'); + async.elapse(duration * 3); + + expect(results, ['Tick']); + + service.dispose(); + }); + }, + ); + + test( + 'SingleReplaySubjectService should ignore events added after dispose', + () async { + final service = MockSingleReplayService(); + service.start(); + service.dispose(); + + expect(() => service.add('after dispose'), returnsNormally); + + final events = []; + service.stream.listen(events.add); + await Future.delayed(Duration.zero); + + expect(events, isEmpty); + }, + ); + }); + + group('failing work', () { + test( + 'TimerAndBehaviorService should let a failing work() reach the zone', + () { + final errors = []; + + runZonedGuarded(() { + fakeAsync((async) { + final service = FailingTimerService(periodicDuration: duration); + final results = []; + + service.start(); + service.stream?.listen(results.add); + + async.elapse(duration); + async.elapse(duration); + + expect( + service.calls, + equals(2), + reason: 'the timer keeps firing after a failing work()', + ); + expect(results, [2], reason: 'only the successful tick emits'); + service.dispose(); + }); + }, (error, stack) => errors.add(error)); + + expect( + errors, + hasLength(1), + reason: 'the periodic work is not wrapped, so the error is unhandled', + ); + expect(errors.single, isA()); + }, + ); + + test( + 'TimerAndPublishService should let a failing work() reach the zone', + () { + final errors = []; + + runZonedGuarded(() { + fakeAsync((async) { + final service = FailingTimerPublishService( + periodicDuration: duration, + ); + final results = []; + + service.start(); + service.stream?.listen(results.add); + + async.elapse(duration); + async.elapse(duration); + + expect(service.calls, equals(2)); + expect(results, ['Tick 2']); + service.dispose(); + }); + }, (error, stack) => errors.add(error)); + + expect(errors, hasLength(1)); + expect(errors.single, isA()); + }, + ); + }); +} diff --git a/test/service_mock/behavior/timer_and_behavior_subject_mock.dart b/test/service_mock/behavior/timer_and_behavior_subject_mock.dart index e645eb6..a8328ca 100644 --- a/test/service_mock/behavior/timer_and_behavior_subject_mock.dart +++ b/test/service_mock/behavior/timer_and_behavior_subject_mock.dart @@ -1,7 +1,7 @@ import 'package:cool_bedrock/cool_bedrock.dart'; final class MockTimerService extends TimerAndBehaviorService { - MockTimerService({required super.periodicDuration}); + new({required super.periodicDuration}); int counter = 0; diff --git a/test/service_mock/behavior/timer_failing_subject_mock.dart b/test/service_mock/behavior/timer_failing_subject_mock.dart new file mode 100644 index 0000000..bb7f0ae --- /dev/null +++ b/test/service_mock/behavior/timer_failing_subject_mock.dart @@ -0,0 +1,33 @@ +import 'package:cool_bedrock/cool_bedrock.dart'; + +/// Fails on the first tick and succeeds afterwards. +final class FailingTimerService extends TimerAndBehaviorService { + new({required super.periodicDuration}); + + int calls = 0; + + @override + Future work() async { + calls++; + if (calls == 1) { + throw StateError('Work failed'); + } + add(calls); + } +} + +/// Same as [FailingTimerService] but for the publish flavour. +final class FailingTimerPublishService extends TimerAndPublishService { + new({required super.periodicDuration}); + + int calls = 0; + + @override + Future work() async { + calls++; + if (calls == 1) { + throw StateError('Work failed'); + } + add('Tick $calls'); + } +} diff --git a/test/service_mock/publish/timer_publish_service_mock.dart b/test/service_mock/publish/timer_publish_service_mock.dart index eef887b..d059541 100644 --- a/test/service_mock/publish/timer_publish_service_mock.dart +++ b/test/service_mock/publish/timer_publish_service_mock.dart @@ -1,7 +1,7 @@ import 'package:cool_bedrock/cool_bedrock.dart'; final class MockTimerPublishService extends TimerAndPublishService { - MockTimerPublishService({required super.periodicDuration}); + new({required super.periodicDuration}); @override Future work() async { diff --git a/test/service_mock/single_replay/single_replay_max_buffer_mock.dart b/test/service_mock/single_replay/single_replay_max_buffer_mock.dart index 908bead..baf1423 100644 --- a/test/service_mock/single_replay/single_replay_max_buffer_mock.dart +++ b/test/service_mock/single_replay/single_replay_max_buffer_mock.dart @@ -2,7 +2,7 @@ import 'package:cool_bedrock/cool_bedrock.dart'; final class MockSingleReplayMaxBuffer extends SingleReplaySubjectService { - MockSingleReplayMaxBuffer({super.maxBufferSize = 2}); + new({super.maxBufferSize = 2}); @override Future work() async {} } diff --git a/test/single_replay_test.dart b/test/single_replay_test.dart index 06706a4..87c1fb4 100644 --- a/test/single_replay_test.dart +++ b/test/single_replay_test.dart @@ -1,4 +1,4 @@ -// ignore_for_file: cascade_invocations +// ignore_for_file: cascade_invocations Test visibility import 'package:test/test.dart'; @@ -23,7 +23,7 @@ void main() { final secondResults = []; - await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); final sub2 = service.stream.listen(secondResults.add); @@ -36,16 +36,18 @@ void main() { await sub2.cancel(); }); - test('should buffer events added manually when no listener is present', - () async { - service.start(); - service.add('Manual Buffer'); + test( + 'should buffer events added manually when no listener is present', + () async { + service.start(); + service.add('Manual Buffer'); - final results = []; - await service.stream.first.then(results.add); + final results = []; + await service.stream.first.then(results.add); - expect(results, contains('Init Event 1')); - }); + expect(results, contains('Init Event 1')); + }, + ); test('should clean up and close controller on dispose', () async { service.start(); @@ -53,11 +55,11 @@ void main() { service.stream.listen((_) {}, onDone: () => isDone = true); - await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); service.dispose(); - await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); expect(isDone, isTrue, reason: 'StreamController should be closed'); }); @@ -70,42 +72,39 @@ void main() { service = MockSingleReplayMaxBuffer(maxBufferSize: 2); service.start(); }); - test('Should add event directly to stream when there is a listener', - () async { - final events = []; - - service.stream.listen(events.add); - - await Future.delayed(Duration.zero); - - service.add('event 1'); - - await Future.delayed(Duration.zero); - - expect(events, contains('event 1')); - expect(events, hasLength(1)); - }); - - test('Should buffer events and respect maxBufferSize when no listener', - () async { - //No subscription -> hasListener == false - service.add('event 1'); - service.add('event 2'); - service.add( - 'event 3', - ); - - final buffer = []; - service.stream.listen( - expectAsync1( - buffer.add, - count: 2, - ), - ); - await Future.delayed(Duration.zero); - expect(buffer.first, equals('event 2')); - expect(buffer.last, equals('event 3')); - }); + test( + 'Should add event directly to stream when there is a listener', + () async { + final events = []; + + service.stream.listen(events.add); + + await Future.delayed(Duration.zero); + + service.add('event 1'); + + await Future.delayed(Duration.zero); + + expect(events, contains('event 1')); + expect(events, hasLength(1)); + }, + ); + + test( + 'Should buffer events and respect maxBufferSize when no listener', + () async { + //No subscription -> hasListener == false + service.add('event 1'); + service.add('event 2'); + service.add('event 3'); + + final buffer = []; + service.stream.listen(expectAsync1(buffer.add, count: 2)); + await Future.delayed(Duration.zero); + expect(buffer.first, equals('event 2')); + expect(buffer.last, equals('event 3')); + }, + ); test('Should reset _isFlushed to false when adding to buffer', () async { // 1. Flush initial diff --git a/test/timer_publish_test.dart b/test/timer_publish_test.dart index 57b92c4..bc40531 100644 --- a/test/timer_publish_test.dart +++ b/test/timer_publish_test.dart @@ -1,4 +1,4 @@ -// ignore_for_file: cascade_invocations +// ignore_for_file: cascade_invocations Test visibility import 'package:fake_async/fake_async.dart'; import 'package:test/test.dart'; @@ -29,24 +29,26 @@ void main() { }); }); - test('should stop emitting when stop() is called but keep subject open', - () { - fakeAsync((async) { - final service = MockTimerPublishService(periodicDuration: duration); - final results = []; + test( + 'should stop emitting when stop() is called but keep subject open', + () { + fakeAsync((async) { + final service = MockTimerPublishService(periodicDuration: duration); + final results = []; - service.start(); - service.stream?.listen(results.add); + service.start(); + service.stream?.listen(results.add); - async.elapse(duration); - service.stop(); + async.elapse(duration); + service.stop(); - service.add('Manual Event'); - async.elapse(duration * 3); + service.add('Manual Event'); + async.elapse(duration * 3); - expect(results, ['Tick', 'Manual Event']); - service.dispose(); - }); - }); + expect(results, ['Tick', 'Manual Event']); + service.dispose(); + }); + }, + ); }); } diff --git a/test/usecase_flow_manager_test.dart b/test/usecase_flow_manager_test.dart new file mode 100644 index 0000000..ccf42fa --- /dev/null +++ b/test/usecase_flow_manager_test.dart @@ -0,0 +1,198 @@ +import 'package:cool_bedrock/cool_bedrock.dart'; +import 'package:test/test.dart'; + +import 'usecase_mock/usecase_composed_handler.dart'; + +void main() { + const validParams = ComposedParams(validate: true); + + ComposedUseCaseHandler handlerWith({ + required Future> Function() first, + required Future> Function() second, + }) => ComposedUseCaseHandler(first: first, second: second); + + group('UsecaseFlowManager', () { + test('should chain two getValue calls through the same resolver', () async { + final usecase = handlerWith( + first: () => Future.value(const Right('first')), + second: () => Future.value(const Right('second')), + ); + + final result = await usecase.call(validParams); + + expect( + result.toNullable(), + equals(const ComposedEntity(value: 'first-second')), + ); + }); + + test( + 'should map a left through the onError callback of getValue', + () async { + final usecase = handlerWith( + first: () => Future.value(const Left(ComposedRepositoryFailure())), + second: () => Future.value(const Right('second')), + ); + + final result = await usecase.call(validParams); + + expect( + result.getLeft().toNullable(), + isA(), + reason: + 'a Left must be mapped by onError before reaching the handler', + ); + }, + ); + + test( + 'should fall back to wrapError when getValue has no onLeft nor onError', + () async { + final usecase = handlerWith( + first: () => Future.value(const Right('first')), + second: () => Future.value(const Left(ComposedRepositoryFailure())), + ); + + final result = await usecase.call(validParams); + + expect(result.getLeft().toNullable(), isA()); + }, + ); + + test( + 'should map a thrown repository error through the onError callback', + () async { + final usecase = handlerWith( + first: () => throw UnimplementedError(), + second: () => Future.value(const Right('second')), + ); + + final result = await usecase.call(validParams); + + expect(result.getLeft().toNullable(), isA()); + }, + ); + + test( + 'should fall back to wrapError when a callback without onError throws', + () async { + final usecase = handlerWith( + first: () => Future.value(const Right('first')), + second: () => throw UnimplementedError(), + ); + + final result = await usecase.call(validParams); + + expect( + result.getLeft().toNullable(), + isA(), + reason: 'getValue without onError must delegate to wrapError', + ); + }, + ); + + test( + 'should short circuit the chain when the first call is invalid', + () async { + var secondCalled = false; + final usecase = handlerWith( + first: () => Future.value(const Right('first')), + second: () { + secondCalled = true; + return Future.value(const Right('second')); + }, + ); + + final result = await usecase.call( + const ComposedParams(validate: false), + ); + + expect( + result.getLeft().toNullable(), + isA(), + ); + expect(secondCalled, isFalse, reason: 'execute must not run'); + }, + ); + + test( + 'should support fully synchronous obtainValues and transformation', + () async { + const usecase = SyncUseCaseHandler(); + + final result = await usecase.call(validParams); + + expect( + result.toNullable(), + equals(const ComposedEntity(value: 'sync')), + ); + }, + ); + + test( + 'should propagate a typed UsecaseException thrown in transformation', + () async { + const usecase = TypedThrowUseCaseHandler(); + + final result = await usecase.call(validParams); + + expect(result.getLeft().toNullable(), isA()); + }, + ); + + test('should keep concurrent calls isolated', () async { + final usecase = handlerWith( + first: () => Future>.delayed( + const Duration(milliseconds: 10), + () => const Right('slow'), + ), + second: () => Future>.delayed( + const Duration(milliseconds: 1), + () => const Right('fast'), + ), + ); + + final results = await Future.wait([ + usecase.call(validParams), + usecase.call(validParams), + ]); + + expect( + results.map((result) => result.toNullable()), + everyElement(equals(const ComposedEntity(value: 'slow-fast'))), + ); + }); + + test('should keep concurrent calls with mixed outcomes isolated', () async { + final usecase = handlerWith( + first: () => Future>.delayed( + const Duration(milliseconds: 10), + () => const Right('ok'), + ), + second: () => Future>.delayed( + const Duration(milliseconds: 1), + () => const Left(ComposedRepositoryFailure()), + ), + ); + + final results = await Future.wait([ + usecase.call(validParams), + usecase.call(validParams), + usecase.call(const ComposedParams(validate: false)), + ]); + + expect( + results[0].getLeft().toNullable(), + isA(), + ); + expect( + results[1].getLeft().toNullable(), + isA(), + ); + expect( + results[2].getLeft().toNullable(), + isA(), + ); + }); + }); +} diff --git a/test/usecase_mock/usecase_composed_handler.dart b/test/usecase_mock/usecase_composed_handler.dart new file mode 100644 index 0000000..9158419 --- /dev/null +++ b/test/usecase_mock/usecase_composed_handler.dart @@ -0,0 +1,129 @@ +import 'dart:async'; + +import 'package:cool_bedrock/cool_bedrock.dart'; + +final class ComposedEntity extends Entity { + const new({required this.value}); + + final String value; + + @override + List get props => [value]; +} + +final class ComposedParams extends Params { + const new({required this.validate}); + + final bool validate; + + @override + bool get isValid => validate; + + @override + List get props => [validate]; +} + +sealed class ComposedFailure extends Failure { + const new(); + + @override + List get props => []; +} + +final class ComposedInvalidParamsFailure extends ComposedFailure { + const new(); +} + +/// Returned by the `onError` callback of the composed handler. +final class ComposedRepositoryFailure extends ComposedFailure { + const new(); +} + +/// Returned by the `wrapError` callback when no other mapping applies. +final class ComposedUnexpectedFailure extends ComposedFailure { + const new(); +} + +/// Chains two fallible data layer calls through the same [Resolver] and +/// transforms the result asynchronously. +final class ComposedUseCaseHandler + extends + UseCaseHandler< + ComposedEntity, + ComposedParams, + ComposedFailure, + String + > { + const new({required this.first, required this.second}); + + final Future> Function() first; + final Future> Function() second; + + @override + ComposedInvalidParamsFailure onInvalidParams() => + const ComposedInvalidParamsFailure(); + + @override + Future obtainValues( + Resolver $, + ComposedParams params, + ) async { + final firstValue = await $( + getValue( + first, + onError: (error, stack) => const ComposedRepositoryFailure(), + ), + ); + final secondValue = await $(getValue(second)); + + return '$firstValue-$secondValue'; + } + + @override + Future transformation(String values) async { + await Future.delayed(Duration.zero); + + return ComposedEntity(value: values); + } + + @override + ComposedFailure wrapError(Object error, StackTrace stackTrace) => + const ComposedUnexpectedFailure(); +} + +/// Never touches a Future: it covers the synchronous [FutureOr] paths of the +/// flow manager. +final class SyncUseCaseHandler + extends + UseCaseHandler< + ComposedEntity, + ComposedParams, + ComposedFailure, + String + > { + const new(); + + @override + ComposedInvalidParamsFailure onInvalidParams() => + const ComposedInvalidParamsFailure(); + + @override + String obtainValues(Resolver $, ComposedParams params) => + params.validate ? 'sync' : 'never'; + + @override + ComposedEntity transformation(String values) => ComposedEntity(value: values); + + @override + ComposedFailure wrapError(Object error, StackTrace stackTrace) => + const ComposedUnexpectedFailure(); +} + +/// Fails inside [transformation] with a typed [UsecaseException]. +final class TypedThrowUseCaseHandler extends SyncUseCaseHandler { + const new(); + + @override + ComposedEntity transformation(String values) => + throw const UsecaseException(ComposedUnexpectedFailure()); +} diff --git a/test/usecase_mock/usecase_handle.dart b/test/usecase_mock/usecase_handle.dart index 7ebb01f..5d47162 100644 --- a/test/usecase_mock/usecase_handle.dart +++ b/test/usecase_mock/usecase_handle.dart @@ -5,10 +5,7 @@ import 'package:cool_bedrock/src/errors/issue.dart'; import 'package:fpdart/src/either.dart'; final class UsecaseTestHandlerEntity extends Entity { - const UsecaseTestHandlerEntity({ - required this.throwError, - required this.throwLeft, - }); + const new({required this.throwError, required this.throwLeft}); final bool throwError; final bool throwLeft; @@ -17,7 +14,7 @@ final class UsecaseTestHandlerEntity extends Entity { } final class UsecaseTestHandlerParams extends Params { - const UsecaseTestHandlerParams({required this.validate}); + const new({required this.validate}); final bool validate; @@ -29,7 +26,7 @@ final class UsecaseTestHandlerParams extends Params { } sealed class UsecaseTestHandlerFailure extends Failure { - const UsecaseTestHandlerFailure(); + const new(); @override List get props => []; @@ -37,24 +34,27 @@ sealed class UsecaseTestHandlerFailure extends Failure { final class InvalidUsecaseTestHandlerParamsFailure extends UsecaseTestHandlerFailure { - const InvalidUsecaseTestHandlerParamsFailure(); + const new(); } final class InvalidUsecaseTestHandlerFailure extends UsecaseTestHandlerFailure { - const InvalidUsecaseTestHandlerFailure(); + const new(); } final class InvalidUsecaseTransformationFailure extends UsecaseTestHandlerFailure { - const InvalidUsecaseTransformationFailure(); + const new(); } -final class UsecaseTestHandler extends UseCaseHandler< - UsecaseTestHandlerEntity, - UsecaseTestHandlerParams, - UsecaseTestHandlerFailure, - UsecaseTestHandlerEntity> { - const UsecaseTestHandler({required this.repository}); +final class UsecaseTestHandler + extends + UseCaseHandler< + UsecaseTestHandlerEntity, + UsecaseTestHandlerParams, + UsecaseTestHandlerFailure, + UsecaseTestHandlerEntity + > { + const new({required this.repository}); final Future> Function() repository; @override diff --git a/test/usecase_mock/usecase_handle_rethrow.dart b/test/usecase_mock/usecase_handle_rethrow.dart index 3b570f1..35d7dc9 100644 --- a/test/usecase_mock/usecase_handle_rethrow.dart +++ b/test/usecase_mock/usecase_handle_rethrow.dart @@ -4,7 +4,7 @@ import 'package:cool_bedrock/src/domain/usecase.dart'; import 'package:fpdart/src/either.dart'; final class UsecaseTestHandlerRethrowEntity extends Entity { - const UsecaseTestHandlerRethrowEntity({required this.throwError}); + const new({required this.throwError}); final bool throwError; @override @@ -12,7 +12,7 @@ final class UsecaseTestHandlerRethrowEntity extends Entity { } final class UsecaseTestHandlerRethrowParams extends Params { - const UsecaseTestHandlerRethrowParams({required this.validate}); + const new({required this.validate}); final bool validate; @@ -24,7 +24,7 @@ final class UsecaseTestHandlerRethrowParams extends Params { } sealed class UsecaseTestHandlerRethrowFailure extends Failure { - const UsecaseTestHandlerRethrowFailure(); + const new(); @override List get props => []; @@ -32,23 +32,26 @@ sealed class UsecaseTestHandlerRethrowFailure extends Failure { final class InvalidUsecaseTestHandlerRethrowParamsFailure extends UsecaseTestHandlerRethrowFailure { - const InvalidUsecaseTestHandlerRethrowParamsFailure(); + const new(); } final class InvalidUsecaseTestHandlerRethrowFailure extends UsecaseTestHandlerRethrowFailure { - const InvalidUsecaseTestHandlerRethrowFailure(); + const new(); } -final class UsecaseTestHandlerRethrow extends UseCaseHandler< - UsecaseTestHandlerRethrowEntity, - UsecaseTestHandlerRethrowParams, - UsecaseTestHandlerRethrowFailure, - UsecaseTestHandlerRethrowEntity> { - const UsecaseTestHandlerRethrow({required this.repository}); +final class UsecaseTestHandlerRethrow + extends + UseCaseHandler< + UsecaseTestHandlerRethrowEntity, + UsecaseTestHandlerRethrowParams, + UsecaseTestHandlerRethrowFailure, + UsecaseTestHandlerRethrowEntity + > { + const new({required this.repository}); final Future> Function() - repository; + repository; @override InvalidUsecaseTestHandlerRethrowParamsFailure onInvalidParams() { return const InvalidUsecaseTestHandlerRethrowParamsFailure(); @@ -91,15 +94,18 @@ final class UsecaseTestHandlerRethrow extends UseCaseHandler< } } -final class UsecaseTestHandlerOnLeft extends UseCaseHandler< - UsecaseTestHandlerRethrowEntity, - UsecaseTestHandlerRethrowParams, - UsecaseTestHandlerRethrowFailure, - UsecaseTestHandlerRethrowEntity> { - const UsecaseTestHandlerOnLeft({required this.repository}); +final class UsecaseTestHandlerOnLeft + extends + UseCaseHandler< + UsecaseTestHandlerRethrowEntity, + UsecaseTestHandlerRethrowParams, + UsecaseTestHandlerRethrowFailure, + UsecaseTestHandlerRethrowEntity + > { + const new({required this.repository}); final Future> Function() - repository; + repository; @override InvalidUsecaseTestHandlerRethrowParamsFailure onInvalidParams() { return const InvalidUsecaseTestHandlerRethrowParamsFailure(); diff --git a/test/usecase_mock/usecase_mock.dart b/test/usecase_mock/usecase_mock.dart index 7b3dc6d..5db1375 100644 --- a/test/usecase_mock/usecase_mock.dart +++ b/test/usecase_mock/usecase_mock.dart @@ -7,7 +7,7 @@ final class UsecaseTestEntity extends Entity { } final class UsecaseTestParams extends Params { - const UsecaseTestParams({required this.validate, this.giveResult = false}); + const new({required this.validate, this.giveResult = false}); final bool validate; @@ -21,18 +21,18 @@ final class UsecaseTestParams extends Params { } sealed class UsecaseTestFailure extends Failure { - const UsecaseTestFailure(); + const new(); @override List get props => []; } final class InvalidUsecaseTestParamsFailure extends UsecaseTestFailure { - const InvalidUsecaseTestParamsFailure(); + const new(); } final class InvalidUsecaseTestFailure extends UsecaseTestFailure { - const InvalidUsecaseTestFailure(); + const new(); } final class UsecaseTest