diff --git a/lib/errors.dart b/lib/errors.dart index 5227f57..f63fc9c 100644 --- a/lib/errors.dart +++ b/lib/errors.dart @@ -1,3 +1,4 @@ +/// Used to notify unexpected errors class StockError extends Error { final String message; diff --git a/lib/fetcher.dart b/lib/fetcher.dart index 2683676..e4fa5d4 100644 --- a/lib/fetcher.dart +++ b/lib/fetcher.dart @@ -1,11 +1,37 @@ import 'package:stock/src/factory_fetcher.dart'; +import 'package:stock/store.dart'; +import 'package:stock/store_response.dart'; +/// Fetcher is used by [Store] to fetch network records of the type [Output] +/// for a given key of the type [Key]. The return type is [Stream] to +/// allow for multiple results per request. +/// +/// Note: Store does not catch exceptions thrown by a [Fetcher]. +/// Use [StoreResponseError] to communicate expected errors. +/// +/// See [ofFuture] for easily translating from a regular `Future` function. +/// See [ofStream], for easily translating to [StoreResponse] (and +/// automatically transforming exceptions into [StoreResponseError]. class Fetcher { + /// "Creates" a [Fetcher] from a [futureFactory] and translates the results into a [StoreResponse]. + /// + /// Emitted values will be wrapped in [StoreResponseData]. If an exception disrupts the stream then + /// it will be wrapped in [StoreResponseError] + /// + /// Use when creating a [Store] that fetches objects in a single response per request + /// network protocol (e.g Http). static Fetcher ofFuture( Future Function(Key key) futureFactory, ) => FutureFetcher(futureFactory); + /// "Creates" a [Fetcher] from a [streamFactory] and translates the results into a [StoreResponse]. + /// + /// Emitted values will be wrapped in [StoreResponseData]. If an exception disrupts the flow then + /// it will be wrapped in [StoreResponseError]. + /// + /// Use when creating a [Store] that fetches objects in a multiple responses per request + /// network protocol (e.g Web Sockets). static Fetcher ofStream( Stream Function(Key key) streamFactory, ) => diff --git a/lib/source_of_truth.dart b/lib/source_of_truth.dart index 7cb0fe9..b1e4e9f 100644 --- a/lib/source_of_truth.dart +++ b/lib/source_of_truth.dart @@ -1,43 +1,86 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; +import 'package:stock/fetcher.dart'; +import 'package:stock/src/key_value.dart'; +import 'package:stock/src/source_of_truth_impl.dart'; +import 'package:stock/store.dart'; +import 'package:stock/store_extensions.dart'; -class SourceOfTruth { - Stream Function(Key key) reader; - Future Function(Key key, Output? output) writer; +/// +/// [SourceOfTruth], as its name implies, is the persistence API which [Store] uses to serve values to +/// the collectors. If provided, [Store] will only return values received from [SourceOfTruth] back +/// to the collectors. +/// +/// In other words, values coming from the [Fetcher] will always be sent to the [SourceOfTruth] +/// and will be read back via [reader] to then be returned to the collector. +/// +/// This round-trip ensures the data is consistent across the application in case the [Fetcher] does +/// not return all fields or returns a different class type than the app uses. It is particularly +/// useful if your application has a local observable database which is directly modified by the app, +/// as Store can observe these changes and update the collectors even before value is synced to the +/// backend. +/// +/// [SourceOfTruth] takes care of making any source (no matter if it has flowing reads or not) into +/// a common flowing API. +/// +/// A source of truth is usually backed by local storage. Its purpose is to eliminate the need +/// for waiting on a network update before local modifications are available (via [Store.stream]). +/// +/// For maximal simplicity, [writer]'s record type ([T]] and [reader]'s record type +/// ([T]) are identical. However, sometimes reading one type of objects from network and +/// transforming them to another type when placing them in local storage is needed. +/// For this case you can use the [mapTo] and [mapToUsingMapper] extensions. +/// +abstract class SourceOfTruth { + /// Creates a source of truth that is accessed via [reader] and [writer]. + /// + /// The [reader] function is used to read records from the source of truth + /// The [writer] function is used to write records to the source of truth + factory SourceOfTruth({ + required Stream Function(Key key) reader, + required Future Function(Key key, T? output) writer, + }) => + SourceOfTruthImpl(reader, writer); - SourceOfTruth({required this.reader, required this.writer}); + /// Used by [Store] to read records from the source of truth for the given [key]. + Stream reader(Key key); + + /// Used by [Store] to write records **coming in from the fetcher (network)** to the source of + /// truth. + /// + /// **Note:** [Store] currently does not support updating the source of truth with local user + /// updates (i.e writing record of type [T]). However, any changes in the local database + /// will still be visible via [Store.stream] APIs as long as you are using a local storage that + /// supports observability (e.g. Floor, Drift, Realm). + Future write(Key key, T? value); } +// A memory cache implementation of a [SourceOfTruth], which stores the latest value and notify new ones. class CachedSourceOfTruth implements SourceOfTruth { - final _streamController = StreamController.broadcast(); + final _streamController = StreamController>.broadcast(); late Map _cachedValues; - @override - late Stream Function(Key key) reader; - @override - late Future Function(Key key, T? output) writer; - CachedSourceOfTruth([Map? cachedValues]) { _cachedValues = {if (cachedValues != null) ...cachedValues}; - reader = generateReader; - writer = generateWriter; } @protected @visibleForTesting - void setCachedValue(Key key, T? t) => _cachedValues[key] = t; + void setCachedValue(Key key, T? value) => _cachedValues[key] = value; - @protected - Stream generateReader(Key key) async* { + @override + Stream reader(Key key) async* { yield _cachedValues[key]; - yield* _streamController.stream; + yield* _streamController.stream + .where((event) => event.key == key) + .map((event) => event.value); } - @protected - Future generateWriter(Key key, T? value) async { + @override + Future write(Key key, T? value) async { setCachedValue(key, value); - _streamController.add(value); + _streamController.add(KeyValue(key, value)); } } diff --git a/lib/src/key_value.dart b/lib/src/key_value.dart new file mode 100644 index 0000000..0b790c3 --- /dev/null +++ b/lib/src/key_value.dart @@ -0,0 +1,6 @@ +class KeyValue { + final Key key; + final Value value; + + KeyValue(this.key, this.value); +} diff --git a/lib/src/source_of_truth_impl.dart b/lib/src/source_of_truth_impl.dart new file mode 100644 index 0000000..5379b41 --- /dev/null +++ b/lib/src/source_of_truth_impl.dart @@ -0,0 +1,26 @@ +import 'package:stock/source_of_truth.dart'; + +class SourceOfTruthImpl implements SourceOfTruth { + final Stream Function(Key key) _reader; + final Future Function(Key key, T? output) _writer; + + SourceOfTruthImpl(this._reader, this._writer); + + @override + Stream reader(Key key) => _reader(key); + + @override + Future write(Key key, T? value) => _writer(key, value); +} + +class WriteWrappedSourceOfTruth extends CachedSourceOfTruth { + final SourceOfTruth? _realSourceOfTruth; + + WriteWrappedSourceOfTruth(this._realSourceOfTruth); + + @override + Future write(Key key, T? value) async { + await _realSourceOfTruth?.write(key, value); + await super.write(key, value); + } +} diff --git a/lib/src/store_impl.dart b/lib/src/store_impl.dart index 08b6a80..1bb5972 100644 --- a/lib/src/store_impl.dart +++ b/lib/src/store_impl.dart @@ -7,7 +7,7 @@ import 'package:stock/source_of_truth.dart'; import 'package:stock/src/extensions/future_stream_extensions.dart'; import 'package:stock/src/extensions/store_response_extensions.dart'; import 'package:stock/src/factory_fetcher.dart'; -import 'package:stock/src/wrapped_source_of_truth.dart'; +import 'package:stock/src/source_of_truth_impl.dart'; import 'package:stock/store.dart'; import 'package:stock/store_request.dart'; import 'package:stock/store_response.dart'; @@ -104,7 +104,7 @@ class StoreImpl implements Store { await _writingLock .protect(() async => _incrementWritingMap(request, 1)); var writerResult = await sourceOfTruth - ?.writer(request.key, response.value) + ?.write(request.key, response.value) .mapToResponse(ResponseOrigin.fetcher); if (writerResult is StoreResponseError) { dataStreamController.add(writerResult.swapType()); diff --git a/lib/src/wrapped_source_of_truth.dart b/lib/src/wrapped_source_of_truth.dart deleted file mode 100644 index c43e2eb..0000000 --- a/lib/src/wrapped_source_of_truth.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:stock/source_of_truth.dart'; - -class WriteWrappedSourceOfTruth extends CachedSourceOfTruth { - final SourceOfTruth? _realSourceOfTruth; - - WriteWrappedSourceOfTruth(this._realSourceOfTruth); - - @override - Future generateWriter(Key key, T? value) async { - await _realSourceOfTruth?.writer(key, value); - await super.generateWriter(key, value); - } -} diff --git a/lib/store.dart b/lib/store.dart index fa6d1df..77bf977 100644 --- a/lib/store.dart +++ b/lib/store.dart @@ -5,16 +5,30 @@ import 'package:stock/source_of_truth.dart'; import 'package:stock/src/store_impl.dart'; import 'package:stock/store_response.dart'; -abstract class Store { +/// A [Store] is responsible for managing a particular data request. +/// +/// When you create an implementation of a [Store], you provide it with a [Fetcher], a function that defines how data will be fetched over network. +/// +/// This [SourceOfTruth] is either a real database or an in memory source of truth. +/// Its purpose is to eliminate the need for waiting on a network update before local modifications are available (via [Store.stream]). +/// +abstract class Store { factory Store({ - required Fetcher fetcher, - required SourceOfTruth? sourceOfTruth, + required Fetcher fetcher, + required SourceOfTruth? sourceOfTruth, }) => - StoreImpl(fetcher: fetcher, sourceOfTruth: sourceOfTruth); + StoreImpl(fetcher: fetcher, sourceOfTruth: sourceOfTruth); - Stream> stream(Key key, {refresh = true}); + /// Returns a [Stream] for the given key. + /// [refresh] is used to ensure a fresh value from the [Fetcher], + /// If it's `false`, [Store] will try to return the [SourceOfTruth] data, + /// and if it doesn't exist, [Store] will request fresh data using the [Fetcher]. + Stream> stream(Key key, {refresh = true}); - Future fresh(Key key); + /// Helper factory that will return fresh data for [key] while updating your cache + Future fresh(Key key); - Future get(Key key); + /// Returns data for [key] if it is cached otherwise will return + /// fresh/network data (updating your cache). + Future get(Key key); } diff --git a/lib/store_extensions.dart b/lib/store_extensions.dart index aaa4592..5a37485 100644 --- a/lib/store_extensions.dart +++ b/lib/store_extensions.dart @@ -1,19 +1,23 @@ -import 'source_of_truth.dart'; +import 'package:stock/type_mapper.dart'; -abstract class StoreConverter { - T1 fromT0(T0 t0); +import 'source_of_truth.dart'; - T0 fromT1(T1 t1); -} +extension SourceOfTruthExtensions on SourceOfTruth { + /// Transforms a [SourceOfTruth] of [Key], [Input] into a [SourceOfTruth] of [Key], [Output]. + SourceOfTruth mapToUsingMapper( + StoreTypeMapper mapper, + ) => + mapTo(mapper.fromInput, mapper.fromOutput); -extension SourceOfTruthExtensions on SourceOfTruth { - SourceOfTruth mapTo( - StoreConverter converter, + /// Transforms a [SourceOfTruth] of [Key], [Input] into a [SourceOfTruth] of [Key], [Output]. + SourceOfTruth mapTo( + Output Function(Input) fromInput, + Input Function(Output) fromOutput, ) => - SourceOfTruth( - reader: (key) => reader(key) - .map((value) => value == null ? null : converter.fromT0(value)), + SourceOfTruth( + reader: (key) => + reader(key).map((value) => value == null ? null : fromInput(value)), writer: (key, value) => - writer(key, value == null ? null : converter.fromT1(value)), + write(key, value == null ? null : fromOutput(value)), ); } diff --git a/lib/store_request.dart b/lib/store_request.dart index f31d8a6..503df18 100644 --- a/lib/store_request.dart +++ b/lib/store_request.dart @@ -1,3 +1,15 @@ +import 'package:stock/fetcher.dart'; +import 'package:stock/source_of_truth.dart'; +import 'package:stock/store.dart'; + +/// Represents a single store request +/// +/// The [key] is a unique identifier for your data +/// +/// If [refresh] is `true`, [Store] will always get fresh value from fetcher. +/// If it's `false`, [Store] will try to return the [SourceOfTruth] data, +/// and if it doesn't exist, [Store] will request fresh data using the [Fetcher]. +/// class StoreRequest { Key key; bool refresh; diff --git a/lib/store_response.dart b/lib/store_response.dart index 2ca3a20..92bd811 100644 --- a/lib/store_response.dart +++ b/lib/store_response.dart @@ -5,14 +5,14 @@ import 'package:stock/store.dart'; /// Holder for responses from Store. /// /// Instead of using regular error channels (a.k.a. throwing exceptions), Store uses this holder -/// class to represent each response. This allows the [Stream] to keep running even if an error happens -/// so that if there is an observable single source of truth, application can keep observing it. +/// class to represent each response. This allows the [Stream] to keep flowing even if an error happens +/// so that if there is an observable single source of truth, the application can keep observing it. class StoreResponse { final ResponseOrigin origin; const StoreResponse._(this.origin); - /// Loading event dispatched by [Store] to signal the [Fetcher] is in progress. + /// Loading event dispatched by [Store] to signal the [Fetcher] is currently running. const factory StoreResponse.loading(ResponseOrigin origin) = StoreResponseLoading; @@ -97,9 +97,6 @@ class StoreResponseError extends StoreResponse { /// Represents the origin for a [StoreResponse]. enum ResponseOrigin { - /// [StoreResponse] is sent from the cache - cache, - /// [StoreResponse] is sent from the [SourceOfTruth] sourceOfTruth, diff --git a/lib/type_mapper.dart b/lib/type_mapper.dart new file mode 100644 index 0000000..5a5f1b1 --- /dev/null +++ b/lib/type_mapper.dart @@ -0,0 +1,7 @@ +/// Converts the [Input] type to the [Output] and vice versa. +/// Used to transform DB entity to network entity and vice versa. +abstract class StoreTypeMapper { + Output fromInput(Input value); + + Input fromOutput(Output value); +} diff --git a/scripts/checks.sh b/scripts/checks.sh index fe71400..4a7082e 100755 --- a/scripts/checks.sh +++ b/scripts/checks.sh @@ -16,8 +16,7 @@ echo "$result" flutter pub run dart_code_metrics:metrics check-unused-code lib --fatal-unused || { echo -e "${RED}Linter error" ; exit 1; } -# TODO: Enable it -# flutter pub run dart_code_metrics:metrics check-unused-files lib --fatal-unused || { echo -e "${RED}Linter error" ; exit 1; } +flutter pub run dart_code_metrics:metrics check-unused-files lib --fatal-unused || { echo -e "${RED}Linter error" ; exit 1; } echo ':: Run tests ::' flutter test || { echo -e "${RED}Test error" ; exit 1; } diff --git a/test/common/source_of_truth/cached_and_mocked_source_of_truth.dart b/test/common/source_of_truth/cached_and_mocked_source_of_truth.dart index 37713c6..a4627e1 100644 --- a/test/common/source_of_truth/cached_and_mocked_source_of_truth.dart +++ b/test/common/source_of_truth/cached_and_mocked_source_of_truth.dart @@ -5,30 +5,37 @@ import 'package:stock/source_of_truth.dart'; import '../../common_mocks.mocks.dart'; import 'cached_source_of_truth_with_default_value.dart'; -SourceOfTruth createMockedSourceOfTruth( - [Value? defaultValue]) { - final cachedSourceOfTruth = - CachedSourceOfTruthWithDefaultValue(defaultValue); +MockSourceOfTruth createMockedSourceOfTruthFromMethods( + Stream Function(Key key) reader, + Future Function(Key key, Value? output) writer, +) { final sourceOfTruth = MockSourceOfTruth(); - when(sourceOfTruth.reader).thenReturn(cachedSourceOfTruth.reader); - when(sourceOfTruth.writer).thenReturn(cachedSourceOfTruth.writer); + when(sourceOfTruth.reader(any)) + .thenAnswer((invocation) => reader(invocation.positionalArguments[0])); + when(sourceOfTruth.write(any, any)).thenAnswer((invocation) => writer( + invocation.positionalArguments[0], invocation.positionalArguments[1])); return sourceOfTruth; } -SourceOfTruth createMockedSourceOfTruthWithDefaultNegativeIntKey() { +MockSourceOfTruth createMockedSourceOfTruth( + [Value? defaultValue]) { + final cachedSot = + CachedSourceOfTruthWithDefaultValue(defaultValue); + return createMockedSourceOfTruthFromMethods( + cachedSot.reader, cachedSot.write); +} + +MockSourceOfTruth + createMockedSourceOfTruthWithDefaultNegativeIntKey() { final sot = _NegativeKeyIntSource(); - final sourceOfTruth = MockSourceOfTruth(); - when(sourceOfTruth.reader).thenReturn(sot.reader); - when(sourceOfTruth.writer).thenReturn(sot.writer); - return sourceOfTruth; + return createMockedSourceOfTruthFromMethods(sot.reader, sot.write); } class _NegativeKeyIntSource extends CachedSourceOfTruth { _NegativeKeyIntSource(); @override - Stream generateReader(int key) => - super.generateReader(key).flatMap((response) async* { + Stream reader(int key) => super.reader(key).flatMap((response) async* { yield response ?? -key; }); } diff --git a/test/common/source_of_truth/cached_source_of_truth_with_default_value.dart b/test/common/source_of_truth/cached_source_of_truth_with_default_value.dart index 6d3bdc1..7103ea7 100644 --- a/test/common/source_of_truth/cached_source_of_truth_with_default_value.dart +++ b/test/common/source_of_truth/cached_source_of_truth_with_default_value.dart @@ -8,8 +8,7 @@ class CachedSourceOfTruthWithDefaultValue CachedSourceOfTruthWithDefaultValue([this._defaultValue]); @override - Stream generateReader(Key key) => - super.generateReader(key).flatMap((response) async* { + Stream reader(Key key) => super.reader(key).flatMap((response) async* { yield response ?? _defaultValue; }); } diff --git a/test/common/source_of_truth/source_of_truth_with_delay.dart b/test/common/source_of_truth/source_of_truth_with_delay.dart index a81e58e..717e5c2 100644 --- a/test/common/source_of_truth/source_of_truth_with_delay.dart +++ b/test/common/source_of_truth/source_of_truth_with_delay.dart @@ -14,15 +14,14 @@ class DelayedSourceOfTruth ]) : super(cachedValue); @override - Stream generateReader(Key key) => - super.generateReader(key).flatMap((response) async* { + Stream reader(Key key) => super.reader(key).flatMap((response) async* { await Future.delayed(readDelayTime); yield response; }); @override - Future generateWriter(Key key, T? value) async { + Future write(Key key, T? value) async { await Future.delayed(readDelayTime); - await super.generateWriter(key, value); + await super.write(key, value); } } diff --git a/test/common/source_of_truth/source_of_truth_with_error.dart b/test/common/source_of_truth/source_of_truth_with_error.dart index 8cbf689..d24ae18 100644 --- a/test/common/source_of_truth/source_of_truth_with_error.dart +++ b/test/common/source_of_truth/source_of_truth_with_error.dart @@ -15,8 +15,7 @@ class SourceOfTruthWithError : super(cachedValue); @override - Stream generateReader(Key key) => - super.generateReader(key).flatMap((response) async* { + Stream reader(Key key) => super.reader(key).flatMap((response) async* { if (throwReadErrorCount > 0) { throwReadErrorCount--; throw readException; @@ -25,11 +24,11 @@ class SourceOfTruthWithError }); @override - Future generateWriter(Key key, T? value) async { + Future write(Key key, T? value) async { if (throwWriteErrorCount > 0) { throwWriteErrorCount--; throw writeException; } - await super.generateWriter(key, value); + await super.write(key, value); } } diff --git a/test/common/store_test_extensions.dart b/test/common/store_test_extensions.dart index e56f140..571ca58 100644 --- a/test/common/store_test_extensions.dart +++ b/test/common/store_test_extensions.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:stock/store.dart'; import 'package:stock/store_response.dart'; @@ -7,21 +9,39 @@ extension StoreExtensions on Store { Future>> getFreshResult( Key key, { refresh = true, - Duration delay = const Duration(milliseconds: 300), + Duration delay = const Duration(milliseconds: 100), }) async { - List> resultList = []; - final subscription = stream(key, refresh: refresh) - .listen((element) => resultList.add(element)); + final resultListener = ResultListener(stream(key, refresh: refresh)); await Future.delayed(delay); - await subscription.cancel(); - return resultList; + return await resultListener.stopAndGetResult(); } Future>> getFreshResultRemovingErrorStackTraces( Key key, { refresh = true, - Duration delay = const Duration(milliseconds: 300), + Duration delay = const Duration(milliseconds: 100), }) => getFreshResult(key, refresh: refresh, delay: delay).then((value) => value.map((items) => items.removeStacktraceIfNeeded()).toList()); } + +class ResultListener { + final Stream _stream; + final List _resultList = []; + StreamSubscription? _subscription; + + ResultListener(this._stream, {bool startListener = true}) { + if (startListener) { + listenChanges(); + } + } + + void listenChanges() { + _subscription = _stream.listen(_resultList.add); + } + + Future> stopAndGetResult() async { + _subscription?.cancel(); + return _resultList; + } +} diff --git a/test/common_mocks.mocks.dart b/test/common_mocks.mocks.dart index 7c3e471..79f9d17 100644 --- a/test/common_mocks.mocks.dart +++ b/test/common_mocks.mocks.dart @@ -63,28 +63,19 @@ class MockStreamFetcher extends _i1.Mock /// A class which mocks [SourceOfTruth]. /// /// See the documentation for Mockito's code generation for more information. -class MockSourceOfTruth extends _i1.Mock - implements _i4.SourceOfTruth { +class MockSourceOfTruth extends _i1.Mock + implements _i4.SourceOfTruth { MockSourceOfTruth() { _i1.throwOnMissingStub(this); } @override - _i3.Stream Function(Key) get reader => - (super.noSuchMethod(Invocation.getter(#reader), - returnValue: (Key key) => _i3.Stream.empty()) - as _i3.Stream Function(Key)); + _i3.Stream reader(Key? key) => + (super.noSuchMethod(Invocation.method(#reader, [key]), + returnValue: _i3.Stream.empty()) as _i3.Stream); @override - set reader(_i3.Stream Function(Key)? _reader) => - super.noSuchMethod(Invocation.setter(#reader, _reader), - returnValueForMissingStub: null); - @override - _i3.Future Function(Key, Output?) get writer => (super.noSuchMethod( - Invocation.getter(#writer), - returnValue: (Key key, Output? output) => _i3.Future.value()) - as _i3.Future Function(Key, Output?)); - @override - set writer(_i3.Future Function(Key, Output?)? _writer) => - super.noSuchMethod(Invocation.setter(#writer, _writer), - returnValueForMissingStub: null); + _i3.Future write(Key? key, T? value) => (super.noSuchMethod( + Invocation.method(#write, [key, value]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value()) as _i3.Future); } diff --git a/test/fresh_and_get_test.dart b/test/fresh_and_get_test.dart index 3a993f4..39e7ddc 100644 --- a/test/fresh_and_get_test.dart +++ b/test/fresh_and_get_test.dart @@ -9,10 +9,10 @@ void main() { group("Fresh tests", () { test('Sot is not called when fresh is invoked', () async { var fetcher = MockFutureFetcher(); - final sourceOfTruth = MockSourceOfTruth(); when(fetcher.factory).thenReturn((key) => Stream.value(1)); - when(sourceOfTruth.reader).thenReturn((key) => Stream.value(-1)); - when(sourceOfTruth.writer).thenReturn((key, value) async {}); + final sourceOfTruth = createMockedSourceOfTruthFromMethods( + (key) => Stream.value(-1), (key, output) => Future.value()); + final store = Store( fetcher: fetcher, sourceOfTruth: sourceOfTruth, @@ -20,18 +20,17 @@ void main() { final result = await store.fresh(1); expect(result, equals(1)); - verifyNever(sourceOfTruth.reader); - verify(sourceOfTruth.writer).called(1); + verifyNever(sourceOfTruth.reader(any)); + verify(sourceOfTruth.write(any, any)).called(1); }); }); group("Get tests", () { test('Fetcher is not called when get is invoked and sot has data', () async { var fetcher = MockFutureFetcher(); - final sourceOfTruth = MockSourceOfTruth(); when(fetcher.factory).thenReturn((key) => Stream.value(1)); - when(sourceOfTruth.reader).thenReturn((key) => Stream.value(-1)); - when(sourceOfTruth.writer).thenReturn((key, value) async {}); + final sourceOfTruth = createMockedSourceOfTruthFromMethods( + (key) => Stream.value(-1), (key, output) => Future.value()); final store = Store( fetcher: fetcher, sourceOfTruth: sourceOfTruth, @@ -40,8 +39,8 @@ void main() { final result = await store.get(1); expect(result, equals(-1)); verifyNever(fetcher.factory); - verify(sourceOfTruth.reader).called(1); - verifyNever(sourceOfTruth.writer); + verify(sourceOfTruth.reader(any)).called(1); + verifyNever(sourceOfTruth.write(any, any)); }); test('Fetcher is called when get is invoked and sot has not data', @@ -57,8 +56,8 @@ void main() { final result = await store.get(1); expect(result, equals(1)); verify(fetcher.factory).called(1); - verify(sourceOfTruth.reader).called(1); - verify(sourceOfTruth.writer).called(1); + verify(sourceOfTruth.reader(any)).called(1); + verify(sourceOfTruth.write(any, any)).called(1); }); }); } diff --git a/test/mapper_test.dart b/test/mapper_test.dart new file mode 100644 index 0000000..12c91d3 --- /dev/null +++ b/test/mapper_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stock/source_of_truth.dart'; +import 'package:stock/store_extensions.dart'; +import 'package:stock/type_mapper.dart'; + +void main() { + group("Mapper test", () { + test('SOT of type is converted to the new type using mapper', () async { + final sot = CachedSourceOfTruth(); + final newSot = sot.mapToUsingMapper(_IntStringMapper()); + + newSot.write(1, '2'); + expect(await sot.reader(1).first, 2); + expect(await newSot.reader(1).first, '2'); + + sot.write(1, 3); + expect(await sot.reader(1).first, 3); + expect(await newSot.reader(1).first, '3'); + }); + + test('SOT of type is converted to the new type using functions', () async { + final sot = CachedSourceOfTruth(); + final newSot = sot.mapTo((value) => value.toString(), int.parse); + + newSot.write(1, '2'); + expect(await sot.reader(1).first, 2); + expect(await newSot.reader(1).first, '2'); + + sot.write(1, 3); + expect(await sot.reader(1).first, 3); + expect(await newSot.reader(1).first, '3'); + }); + }); +} + +class _IntStringMapper implements StoreTypeMapper { + @override + String fromInput(int value) => value.toString(); + + @override + int fromOutput(String value) => int.parse(value); +} diff --git a/test/source_of_truth_test.dart b/test/source_of_truth_test.dart new file mode 100644 index 0000000..7178524 --- /dev/null +++ b/test/source_of_truth_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stock/source_of_truth.dart'; + +import 'common/store_test_extensions.dart'; + +void main() { + group("Cached SOT", () { + test('Multiple requests to the same SOT', () async { + final sot = CachedSourceOfTruth(); + await sot.write(1, 1); + final oneResultListener = ResultListener(sot.reader(1)); + final twoResultListener = ResultListener(sot.reader(2)); + // Wait for initialization time + await Future.delayed(const Duration(milliseconds: 100)); + + await sot.write(1, 2); + await sot.write(2, 3); + await sot.write(2, 4); + + await Future.delayed(const Duration(milliseconds: 100)); + final oneResult = await oneResultListener.stopAndGetResult(); + final twoResult = await twoResultListener.stopAndGetResult(); + + expect(oneResult, equals([1, 2])); + expect(twoResult, equals([null, 3, 4])); + }); + }); +}