diff --git a/packages/neon/neon/lib/src/settings/models/option.dart b/packages/neon/neon/lib/src/settings/models/option.dart index be641aeb..0668d38e 100644 --- a/packages/neon/neon/lib/src/settings/models/option.dart +++ b/packages/neon/neon/lib/src/settings/models/option.dart @@ -84,10 +84,10 @@ abstract class Option extends ChangeNotifier implements ValueListenable { } /// Deserializes the data. - T? deserialize(final Object? data); + T deserialize(final Object data); /// Serializes the [value]. - Object? serialize(); + Object serialize(); BehaviorSubject? _stream; diff --git a/packages/neon/neon/lib/src/settings/models/select_option.dart b/packages/neon/neon/lib/src/settings/models/select_option.dart index ac0fc341..1d3bd47f 100644 --- a/packages/neon/neon/lib/src/settings/models/select_option.dart +++ b/packages/neon/neon/lib/src/settings/models/select_option.dart @@ -41,8 +41,8 @@ class SelectOption extends Option { @override set value(final T value) { - unawaited(storage.setString(key, value.toString())); super.value = value; + unawaited(storage.setString(key, serialize())); } /// A collection of different values this can have. @@ -60,8 +60,8 @@ class SelectOption extends Option { } @override - String? serialize() => value?.toString(); + String serialize() => value.toString(); @override - T? deserialize(final dynamic data) => _fromString(_values, data as String?); + T deserialize(final Object data) => _fromString(_values, data as String)!; } diff --git a/packages/neon/neon/lib/src/settings/models/toggle_option.dart b/packages/neon/neon/lib/src/settings/models/toggle_option.dart index 03a51f8b..227f4b33 100644 --- a/packages/neon/neon/lib/src/settings/models/toggle_option.dart +++ b/packages/neon/neon/lib/src/settings/models/toggle_option.dart @@ -27,13 +27,13 @@ class ToggleOption extends Option { @override set value(final bool value) { - unawaited(storage.setBool(key, value)); super.value = value; + unawaited(storage.setBool(key, serialize())); } @override bool serialize() => value; @override - bool deserialize(final dynamic data) => data as bool; + bool deserialize(final Object data) => data as bool; } diff --git a/packages/neon/neon/lib/src/utils/settings_export_helper.dart b/packages/neon/neon/lib/src/utils/settings_export_helper.dart index 97d2df22..3fe86dfa 100644 --- a/packages/neon/neon/lib/src/utils/settings_export_helper.dart +++ b/packages/neon/neon/lib/src/utils/settings_export_helper.dart @@ -54,7 +54,11 @@ class SettingsExportHelper { for (final optionKey in data.keys) { for (final option in options) { if (option.key == optionKey) { - option.value = await option.deserialize(data[optionKey]); + final Object? value = data[optionKey]; + + if (value != null) { + option.value = await option.deserialize(value); + } } } } diff --git a/packages/neon/neon/pubspec.yaml b/packages/neon/neon/pubspec.yaml index 0479da47..7edd0799 100644 --- a/packages/neon/neon/pubspec.yaml +++ b/packages/neon/neon/pubspec.yaml @@ -60,6 +60,7 @@ dev_dependencies: build_runner: ^2.4.4 go_router_builder: ^2.0.1 json_serializable: ^6.6.2 + mocktail: ^0.3.0 nit_picking: git: url: https://github.com/stack11/dart_nit_picking diff --git a/packages/neon/neon/test/option_test.dart b/packages/neon/neon/test/option_test.dart new file mode 100644 index 00000000..0cadbb3e --- /dev/null +++ b/packages/neon/neon/test/option_test.dart @@ -0,0 +1,251 @@ +// ignore_for_file: discarded_futures + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon/src/settings/models/select_option.dart'; +import 'package:neon/src/settings/models/storage.dart'; +import 'package:neon/src/settings/models/toggle_option.dart'; +import 'package:test/test.dart'; + +class MockStorage extends Mock implements SettingsStorage {} + +class MockCallbackFunction extends Mock { + FutureOr call(); +} + +enum SelectValues { + first, + second, + third, +} + +void main() { + final storage = MockStorage(); + const key = 'storage-key'; + String labelBuilder(final _) => 'label'; + + group('SelectOption', () { + final valuesLabel = { + SelectValues.first: (final _) => 'first', + SelectValues.second: (final _) => 'second', + SelectValues.third: (final _) => 'third', + }; + + late SelectOption option; + + setUp(() { + when(() => storage.setString(key, any())).thenAnswer((final _) async {}); + option = SelectOption( + storage: storage, + key: key, + label: labelBuilder, + defaultValue: SelectValues.first, + values: valuesLabel, + ); + }); + + tearDown(() { + reset(storage); + option.dispose(); + }); + + test('Create', () { + expect(option.value, option.defaultValue, reason: 'Should default to defaultValue.'); + + when(() => storage.getString(key)).thenReturn('SelectValues.second'); + + option = SelectOption( + storage: storage, + key: key, + label: labelBuilder, + defaultValue: SelectValues.first, + values: valuesLabel, + ); + + expect(option.value, SelectValues.second, reason: 'Should load value from storage when available.'); + }); + + test('Depend', () { + final enabled = ValueNotifier(false); + final callback = MockCallbackFunction(); + + option = SelectOption.depend( + storage: storage, + key: key, + label: labelBuilder, + defaultValue: SelectValues.first, + values: valuesLabel, + enabled: enabled, + )..addListener(callback.call); + + expect(option.enabled, enabled.value, reason: 'Should initialize with enabled value.'); + + enabled.value = true; + verify(callback.call).called(1); + expect(option.enabled, enabled.value, reason: 'Should update the enabled state.'); + }); + + test('Update', () { + final callback = MockCallbackFunction(); + option + ..addListener(callback.call) + ..value = SelectValues.third; + + verify(callback.call).called(1); + verify(() => storage.setString(key, 'SelectValues.third')).called(1); + expect(option.value, SelectValues.third, reason: 'Should update the value.'); + + option.value = SelectValues.third; + verifyNever(callback.call); // Don't notify with the same value + expect(option.value, SelectValues.third, reason: 'Should keep the value.'); + }); + + test('Disable', () { + final callback = MockCallbackFunction(); + option.addListener(callback.call); + + expect(option.enabled, true, reason: 'Should default to enabled'); + + option.enabled = false; + verify(callback.call).called(1); + expect(option.enabled, false, reason: 'Should disable option.'); + + option.enabled = false; + verifyNever(callback.call); // Don't notify with the same value + expect(option.enabled, false, reason: 'Should keep the value.'); + }); + + test('Change values', () { + final callback = MockCallbackFunction(); + option.addListener(callback.call); + + expect(option.values, equals(valuesLabel)); + + final newValues = { + SelectValues.second: (final _) => 'second', + SelectValues.third: (final _) => 'third', + }; + + option.values = newValues; + verify(callback.call).called(1); + expect(option.values, equals(newValues), reason: 'Should change values.'); + + option.values = newValues; + verifyNever(callback.call); // Don't notify with the same value + expect(option.values, newValues, reason: 'Should keep the values.'); + }); + + test('Reset', () { + final callback = MockCallbackFunction(); + option + ..value = SelectValues.third + ..addListener(callback.call); + + expect(option.value, SelectValues.third); + + option.reset(); + + verify(callback.call).called(1); + expect(option.value, option.defaultValue, reason: 'Should reset the value.'); + }); + }); + + group('ToggleOption', () { + late ToggleOption option; + + setUp(() { + when(() => storage.setBool(key, any())).thenAnswer((final _) async {}); + option = ToggleOption( + storage: storage, + key: key, + label: labelBuilder, + defaultValue: true, + ); + }); + + tearDown(() { + reset(storage); + option.dispose(); + }); + + test('Create', () { + expect(option.value, option.defaultValue, reason: 'Should default to defaultValue.'); + + when(() => storage.getBool(key)).thenReturn(true); + + option = ToggleOption( + storage: storage, + key: key, + label: labelBuilder, + defaultValue: false, + ); + + expect(option.value, true, reason: 'Should load value from storage when available.'); + }); + + test('Depend', () { + final enabled = ValueNotifier(false); + final callback = MockCallbackFunction(); + + option = ToggleOption.depend( + storage: storage, + key: key, + label: labelBuilder, + defaultValue: true, + enabled: enabled, + )..addListener(callback.call); + + expect(option.enabled, enabled.value, reason: 'Should initialize with enabled value.'); + + enabled.value = true; + verify(callback.call).called(1); + expect(option.enabled, enabled.value, reason: 'Should update the enabled state.'); + }); + + test('Update', () { + final callback = MockCallbackFunction(); + option + ..addListener(callback.call) + ..value = false; + + verify(callback.call).called(1); + verify(() => storage.setBool(key, false)).called(1); + expect(option.value, false, reason: 'Should update the value.'); + + option.value = false; + verifyNever(callback.call); // Don't notify with the same value + expect(option.value, false, reason: 'Should keep the value.'); + }); + + test('Disable', () { + final callback = MockCallbackFunction(); + option.addListener(callback.call); + + expect(option.enabled, true, reason: 'Should default to enabled'); + + option.enabled = false; + verify(callback.call).called(1); + expect(option.enabled, false, reason: 'Should disable option.'); + + option.enabled = false; + verifyNever(callback.call); // Don't notify with the same value + expect(option.enabled, false, reason: 'Should keep the value.'); + }); + + test('Reset', () { + final callback = MockCallbackFunction(); + option + ..value = false + ..addListener(callback.call); + + expect(option.value, false); + + option.reset(); + + verify(callback.call).called(1); + expect(option.value, option.defaultValue, reason: 'Should reset the value.'); + }); + }); +}