From ac82e93b9db68669bb767057ec84306d4cc7529c Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Fri, 4 Apr 2025 17:08:06 +0200 Subject: [PATCH] [UNTESTED] Fixes notifiers and adds some additional methods. Adds tests. Changes: // ArcaneEnvironment breaking: context.read() -> ArcaneEnvironment.of(context) breaking: context.read().state -> ArcaneEnvironment.of(context).environment; // Feature flag service added: reset() // Logging service added: registerInterface() added: unregisterInterfaces() added: unregisterAllInterfaces() // ArcaneReactiveTheme fixed: currentMode, dark, light now actually emit new values when changed added: getters for lightTheme, darkTheme, and systemTheme TODO: test systemTheme Signed-off-by: Hans Kokx --- lib/src/arcane.dart | 9 +- lib/src/providers/environment_provider.dart | 127 +++++--- lib/src/providers/service_provider.dart | 10 +- .../authentication_service.dart | 38 ++- .../feature_flags/feature_flags_service.dart | 12 + lib/src/services/logging/logging_service.dart | 64 +++- .../reactive_theme_service.dart | 35 ++- pubspec.yaml | 8 +- test/arcane_test.dart | 22 ++ test/providers/service_provider_test.dart | 97 ++++++ .../authentication_service_test.dart | 144 +++++++++ .../authentication_service_test.mocks.dart | 275 ++++++++++++++++++ .../feature_flags_service_test.dart | 69 +++++ .../logging/logging_service_test.dart | 203 +++++++++++++ .../logging/logging_service_test.mocks.dart | 69 +++++ .../reactive_theme_service_test.dart | 140 +++++++++ 16 files changed, 1233 insertions(+), 89 deletions(-) create mode 100644 test/arcane_test.dart create mode 100644 test/providers/service_provider_test.dart create mode 100644 test/services/authentication/authentication_service_test.dart create mode 100644 test/services/authentication/authentication_service_test.mocks.dart create mode 100644 test/services/feature_flags/feature_flags_service_test.dart create mode 100644 test/services/logging/logging_service_test.dart create mode 100644 test/services/logging/logging_service_test.mocks.dart create mode 100644 test/services/reactive_theme/reactive_theme_service_test.dart diff --git a/lib/src/arcane.dart b/lib/src/arcane.dart index 5ebe7ca..557a2bd 100644 --- a/lib/src/arcane.dart +++ b/lib/src/arcane.dart @@ -6,14 +6,7 @@ import "package:arcane_framework/arcane_framework.dart"; /// `Arcane` provides access to important services like logging, feature flags, /// authentication, theming, secure storage, and ID management. It also offers a /// convenient method for logging messages using the integrated logger. -class Arcane { - Arcane._internal(); - - /// Creates a singleton instance of `Arcane`. - /// - /// This factory constructor always returns the same instance of `Arcane`. - factory Arcane() => Arcane._internal(); - +abstract class Arcane { /// Provides access to the singleton instance of the logger service. /// /// The `ArcaneLogger` is used for logging messages throughout the app. diff --git a/lib/src/providers/environment_provider.dart b/lib/src/providers/environment_provider.dart index 6a5fc35..8f4a9ca 100644 --- a/lib/src/providers/environment_provider.dart +++ b/lib/src/providers/environment_provider.dart @@ -1,54 +1,109 @@ import "package:arcane_framework/arcane_framework.dart"; import "package:flutter/widgets.dart"; -import "package:flutter_bloc/flutter_bloc.dart"; -/// A `Cubit` that manages the application environment state. +/// An `InheritedWidget` that provides access to the application environment. /// -/// The `ArcaneEnvironment` cubit holds the current environment (`debug` or `normal`) -/// and provides a method to enable debug mode. -class ArcaneEnvironment extends Cubit { - /// Initializes the cubit with the `normal` environment as the default state. - ArcaneEnvironment() : super(Environment.normal); +/// The `ArcaneEnvironment` widget holds the current environment (`debug` or `normal`) +/// and allows descendant widgets to access it. +class ArcaneEnvironment extends InheritedWidget { + /// The current application environment. + final Environment environment; + + final ValueChanged onEnvironmentChanged; + + /// Creates an `ArcaneEnvironment` widget. + const ArcaneEnvironment({ + required this.environment, + required Widget child, + required this.onEnvironmentChanged, + Key? key, + }) : super(key: key, child: child); + + /// Retrieves the `ArcaneEnvironment` instance from the nearest ancestor. + /// + /// Returns `null` if no `ArcaneEnvironment` ancestor is found. + static ArcaneEnvironment? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + /// Retrieves the `ArcaneEnvironment` instance from the nearest ancestor. + /// + /// Throws a `StateError` if no `ArcaneEnvironment` ancestor is found. + static ArcaneEnvironment of(BuildContext context) { + final ArcaneEnvironment? result = maybeOf(context); + if (result == null) { + throw StateError("No ArcaneEnvironment found in context"); + } + return result; + } + + @override + bool updateShouldNotify(ArcaneEnvironment oldWidget) { + return environment != oldWidget.environment; + } + + void enableDebugMode() => onEnvironmentChanged(Environment.debug); + void disableDebugMode() => onEnvironmentChanged(Environment.normal); +} + +/// A `StatefulWidget` that manages and provides the `ArcaneEnvironment`. +/// +/// This widget holds the internal state of the environment and rebuilds +/// its descendants when the environment changes. +class ArcaneEnvironmentProvider extends StatefulWidget { + /// The child widget that will have access to the `ArcaneEnvironment`. + final Widget child; + + /// The initial environment state. Defaults to `Environment.normal`. + final Environment environment; + + /// Creates an `ArcaneEnvironmentProvider`. + const ArcaneEnvironmentProvider({ + required this.child, + Key? key, + this.environment = Environment.normal, + }) : super(key: key); + + @override + State createState() => + _ArcaneEnvironmentProviderState(); +} + +class _ArcaneEnvironmentProviderState extends State { + late Environment _environment; + + @override + void initState() { + super.initState(); + _environment = widget.environment; + } /// Enables debug mode by setting the environment to `Environment.debug`. void enableDebugMode() { - if (state == Environment.debug) return; - - emit(Environment.debug); + if (_environment == Environment.debug) return; + setState(() { + _environment = Environment.debug; + }); } /// Disables debug mode by setting the environment to `Environment.normal`. void disableDebugMode() { - if (state == Environment.normal) return; - - emit(Environment.normal); + if (_environment == Environment.normal) return; + setState(() { + _environment = Environment.normal; + }); } -} - -/// A widget that provides `ArcaneEnvironment` to the widget tree using `BlocProvider`. -/// -/// This widget wraps around a child widget and makes `ArcaneEnvironment` available -/// to the rest of the widget tree. It should be used in combination with `BlocProvider` -/// from the `flutter_bloc` package. -/// -/// Example: -/// ```dart -/// ArcaneEnvironmentProvider( -/// child: MyApp(), -/// ); -/// ``` -class ArcaneEnvironmentProvider extends StatelessWidget { - /// The widget that will be provided with access to the `ArcaneEnvironment`. - final Widget child; - - /// Constructs an `ArcaneEnvironmentProvider` with the given [child]. - const ArcaneEnvironmentProvider({required this.child, super.key}); @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ArcaneEnvironment(), - child: child, + return ArcaneEnvironment( + environment: _environment, + onEnvironmentChanged: (Environment environment) { + setState(() { + _environment = environment; + }); + }, + child: widget.child, ); } } diff --git a/lib/src/providers/service_provider.dart b/lib/src/providers/service_provider.dart index 46793f8..0ca42f7 100644 --- a/lib/src/providers/service_provider.dart +++ b/lib/src/providers/service_provider.dart @@ -53,14 +53,12 @@ class ArcaneServiceProvider extends InheritedNotifier { /// final provider = ArcaneServiceProvider.of(context); /// ``` static ArcaneServiceProvider of(BuildContext context) { - final ArcaneServiceProvider? result = - context.dependOnInheritedWidgetOfExactType(); - - if (result == null) { + try { + return context + .dependOnInheritedWidgetOfExactType()!; + } catch (e) { throw Exception("ArcaneServiceProvider not found in context"); } - - return result; } } diff --git a/lib/src/services/authentication/authentication_service.dart b/lib/src/services/authentication/authentication_service.dart index fb22e97..31974bc 100644 --- a/lib/src/services/authentication/authentication_service.dart +++ b/lib/src/services/authentication/authentication_service.dart @@ -2,7 +2,6 @@ import "dart:async"; import "package:arcane_framework/arcane_framework.dart"; import "package:flutter/widgets.dart"; -import "package:flutter_bloc/flutter_bloc.dart"; part "authentication_enums.dart"; part "authentication_interface.dart"; @@ -59,12 +58,15 @@ class ArcaneAuthenticationService extends ArcaneService { Future get refreshToken => authInterface?.refreshToken ?? Future.value(""); + AuthenticationStatus? _previousModeWhenSettingDebug; + /// Removes any registered `ArcaneAuthInterface` and resets all values to /// default. Future reset() async { _authInterface = null; _notifier.value = AuthenticationStatus.unauthenticated; _isSignedIn.value = isAuthenticated; + _previousModeWhenSettingDebug = null; notifyListeners(); } @@ -85,17 +87,18 @@ class ArcaneAuthenticationService extends ArcaneService { BuildContext context, { Future Function()? onDebugModeSet, }) async { - ArcaneEnvironment? environment; - try { - environment = context.read(); - final Environment previousEnvironment = environment.state; + final ArcaneEnvironment arcaneEnvironment = ArcaneEnvironment.of(context); + + final Environment previousEnvironment = arcaneEnvironment.environment; if (previousEnvironment == Environment.debug) return; - environment.enableDebugMode(); + _previousModeWhenSettingDebug = status; - final Environment currentEnvironment = environment.state; + arcaneEnvironment.enableDebugMode(); + + final Environment currentEnvironment = arcaneEnvironment.environment; if (previousEnvironment == currentEnvironment) { throw Exception("Unable to switch to debug mode."); @@ -103,8 +106,8 @@ class ArcaneAuthenticationService extends ArcaneService { _setStatus(AuthenticationStatus.debug); if (onDebugModeSet != null) await onDebugModeSet(); - } catch (_) { - throw Exception("No ArcaneEnvironment found in BuildContext"); + } catch (e) { + rethrow; } } @@ -115,23 +118,24 @@ class ArcaneAuthenticationService extends ArcaneService { BuildContext context, { Future Function()? onDebugModeUnset, }) async { - ArcaneEnvironment? environment; - try { - environment = context.read(); - final Environment previousEnvironment = environment.state; + final ArcaneEnvironment arcaneEnvironment = ArcaneEnvironment.of(context); + + final Environment previousEnvironment = arcaneEnvironment.environment; if (previousEnvironment == Environment.normal) return; - environment.disableDebugMode(); + arcaneEnvironment.disableDebugMode(); - final Environment currentEnvironment = environment.state; + final Environment currentEnvironment = arcaneEnvironment.environment; if (previousEnvironment == currentEnvironment) { throw Exception("Unable to switch to normal mode."); } - _setStatus(AuthenticationStatus.debug); + _setStatus( + _previousModeWhenSettingDebug ?? AuthenticationStatus.unauthenticated, + ); if (onDebugModeUnset != null) await onDebugModeUnset(); } catch (_) { throw Exception("No ArcaneEnvironment found in BuildContext"); @@ -174,6 +178,8 @@ class ArcaneAuthenticationService extends ArcaneService { if (onLoggedOut != null) await onLoggedOut(); } + _previousModeWhenSettingDebug = null; + return loggedOut; } diff --git a/lib/src/services/feature_flags/feature_flags_service.dart b/lib/src/services/feature_flags/feature_flags_service.dart index fa06c3d..99a1512 100644 --- a/lib/src/services/feature_flags/feature_flags_service.dart +++ b/lib/src/services/feature_flags/feature_flags_service.dart @@ -129,4 +129,16 @@ class ArcaneFeatureFlags extends ArcaneService { I._initialized = true; notifyListeners(); } + + /// Resets the feature flags to their initial state. + /// + /// This method clears all enabled features, resets notification values, + /// marks the flags as uninitialized, and notifies listeners of the changes. + void reset() { + _enabledFeatures.clear(); + _notifier.value.clear(); + + I._initialized = false; + notifyListeners(); + } } diff --git a/lib/src/services/logging/logging_service.dart b/lib/src/services/logging/logging_service.dart index 846dd46..1d82c11 100644 --- a/lib/src/services/logging/logging_service.dart +++ b/lib/src/services/logging/logging_service.dart @@ -1,6 +1,7 @@ import "dart:async"; import "package:arcane_helper_utils/arcane_helper_utils.dart"; +import "package:flutter/foundation.dart"; part "logging_enums.dart"; part "logging_interface.dart"; @@ -174,9 +175,27 @@ class ArcaneLogger { } } - /// Registers a [LoggingInterface] with the [ArcaneLogger]. Due to iOS app - /// tracking permissions, permission to track must first be checked for - /// and (optionally) granted before the interface is automatically initialized. + /// Registers a [LoggingInterface] with the [ArcaneLogger]. + /// Due to iOS app tracking permissions, permission to track must first be + /// checked for and (optionally) granted before the interface is automatically + /// initialized. + /// + /// Once your [LoggingInterface] has been registered and initialized, logs + /// will automatically be sent to the interface. + Future registerInterface( + LoggingInterface loggingInterface, + ) async { + if (!initialized) await _init(); + + I._interfaces.add(loggingInterface); + + return I; + } + + /// Registers a `List` of [LoggingInterface] with the [ArcaneLogger]. + /// Due to iOS app tracking permissions, permission to track must first be + /// checked for and (optionally) granted before the interface is automatically + /// initialized. /// /// Once your [LoggingInterface] has been registered and initialized, logs /// will automatically be sent to the interface. @@ -192,15 +211,37 @@ class ArcaneLogger { return I; } + /// Unregisters a `List` of [LoggingInterface] from the [ArcaneLogger], if + /// they were previously registered. + Future unregisterInterfaces( + List interfaces, + ) async { + if (!initialized) await _init(); + + for (final LoggingInterface i in interfaces) { + I._interfaces.remove(i); + } + + return I; + } + + /// Unregisters all previously registered [LoggingInterface] from the + /// [ArcaneLogger], if any were previously registered. + Future unregisterAllInterfaces() async { + if (!initialized) await _init(); + I._interfaces.clear(); + return I; + } + /// Initializes all registered [LoggingInterface]s by calling their /// [LoggingInterface.init] methods. Future initializeInterfaces() async { - assert( - I._interfaces.isNotEmpty, - "No logging interfaces have been registered.", - ); + if (!initialized) await _init(); + + if (I._interfaces.isEmptyOrNull) { + throw Exception("No logging interfaces have been registered."); + } - if (!I._initialized) await _init(); for (final LoggingInterface i in I._interfaces) { if (!i.initialized) await i.init(); } @@ -245,4 +286,11 @@ class ArcaneLogger { /// Clears all persistent metadata. void clearPersistentMetadata() => _additionalMetadata.clear(); + + @visibleForTesting + void reset() { + I._interfaces.clear(); + I._initialized = false; + I._additionalMetadata.clear(); + } } diff --git a/lib/src/services/reactive_theme/reactive_theme_service.dart b/lib/src/services/reactive_theme/reactive_theme_service.dart index cd9553c..5a0122d 100644 --- a/lib/src/services/reactive_theme/reactive_theme_service.dart +++ b/lib/src/services/reactive_theme/reactive_theme_service.dart @@ -18,29 +18,30 @@ class ArcaneReactiveTheme extends ArcaneService { ArcaneReactiveTheme._internal(); - /// Whether the current theme is dark. - bool _isDark = false; + final ValueNotifier _systemThemeNotifier = + ValueNotifier(ThemeMode.light); /// Returns the current theme mode based on `_isDark`. /// /// If `_isDark` is true, it returns `ThemeMode.dark`, otherwise it returns `ThemeMode.light`. - ThemeMode get currentMode => _isDark ? ThemeMode.dark : ThemeMode.light; + ThemeMode get currentMode => I._systemThemeNotifier.value; /// The `ThemeData` for the dark theme. - ThemeData _darkTheme = ThemeData.dark(); + final ValueNotifier _darkTheme = ValueNotifier(ThemeData.dark()); /// The `ThemeData` for the light theme. - ThemeData _lightTheme = ThemeData.light(); + final ValueNotifier _lightTheme = ValueNotifier(ThemeData.light()); /// Returns the current dark theme `ThemeData`. - ThemeData get dark => _darkTheme; + ThemeData get dark => _darkTheme.value; + ValueNotifier get darkTheme => I._darkTheme; /// Returns the current light theme `ThemeData`. - ThemeData get light => _lightTheme; + ThemeData get light => _lightTheme.value; + ValueNotifier get lightTheme => I._lightTheme; /// A listenable that notifies listeners when the syste theme mode changes. - ValueListenable get systemTheme => - ValueNotifier(_isDark ? ThemeMode.dark : ThemeMode.light); + ValueListenable get systemTheme => I._systemThemeNotifier; /// Switches the current theme between light and dark modes. /// @@ -52,7 +53,9 @@ class ArcaneReactiveTheme extends ArcaneService { /// ArcaneReactiveTheme.I.switchTheme(); /// ``` ArcaneReactiveTheme switchTheme() { - _isDark = !_isDark; + _systemThemeNotifier.value = _systemThemeNotifier.value == ThemeMode.light + ? ThemeMode.dark + : ThemeMode.light; notifyListeners(); return I; @@ -87,7 +90,7 @@ class ArcaneReactiveTheme extends ArcaneService { /// ArcaneReactiveTheme.I.setDarkTheme(customDarkTheme); /// ``` ArcaneReactiveTheme setDarkTheme(ThemeData theme) { - _darkTheme = theme; + _darkTheme.value = theme; notifyListeners(); return I; } @@ -102,8 +105,16 @@ class ArcaneReactiveTheme extends ArcaneService { /// ArcaneReactiveTheme.I.setLightTheme(customLightTheme); /// ``` ArcaneReactiveTheme setLightTheme(ThemeData theme) { - _lightTheme = theme; + _lightTheme.value = theme; notifyListeners(); return I; } + + @visibleForTesting + void reset() { + _darkTheme.value = ThemeData.dark(); + _lightTheme.value = ThemeData.light(); + _systemThemeNotifier.value = ThemeMode.light; + notifyListeners(); + } } diff --git a/pubspec.yaml b/pubspec.yaml index bf01760..fc05500 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,14 +12,16 @@ environment: flutter: ">=1.17.0" dependencies: - arcane_helper_utils: ^1.2.6 - collection: ^1.18.0 + arcane_helper_utils: ^1.3.2 + collection: ^1.19.0 flutter: sdk: flutter - flutter_bloc: ^9.0.0 + flutter_bloc: ^9.1.0 result_monad: ^2.3.2 dev_dependencies: arcane_analysis: ^1.0.3 + build_runner: ^2.4.1 flutter_test: sdk: flutter + mockito: ^5.4.5 diff --git a/test/arcane_test.dart b/test/arcane_test.dart new file mode 100644 index 0000000..a1c30ac --- /dev/null +++ b/test/arcane_test.dart @@ -0,0 +1,22 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mockito/mockito.dart"; + +class MockLoggingInterface extends Mock implements LoggingInterface {} + +void main() { + setUpAll(() { + ArcaneFeatureFlags.I.reset(); + ArcaneAuthenticationService.I.reset(); + ArcaneReactiveTheme.I.reset(); + }); + + group("Arcane", () { + test("services getter returns all core services", () { + final services = Arcane.services; + expect(services, contains(isA())); + expect(services, contains(isA())); + expect(services, contains(isA())); + }); + }); +} diff --git a/test/providers/service_provider_test.dart b/test/providers/service_provider_test.dart new file mode 100644 index 0000000..44a5b05 --- /dev/null +++ b/test/providers/service_provider_test.dart @@ -0,0 +1,97 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("ArcaneServiceProvider", () { + late List testServices; + + setUp(() { + testServices = [ + MockArcaneService(), + AnotherMockService(), + ]; + }); + + testWidgets("provides services to widget tree", (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + final provider = ArcaneServiceProvider.of(context); + expect(provider.serviceInstances, equals(testServices)); + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets("serviceOfType extension returns correct service", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + final service = context.serviceOfType(); + expect(service, isNotNull); + expect(service, isA()); + return const SizedBox(); + }, + ), + ), + ); + }); + + testWidgets("serviceOfType returns null for unregistered service", + (tester) async { + await tester.pumpWidget( + ArcaneApp( + services: testServices, + child: Builder( + builder: (context) { + final service = context.serviceOfType(); + expect(service, isNull); + return const SizedBox(); + }, + ), + ), + ); + }); + + test("of() throws when no provider is found", () { + final context = MockBuildContext(); + expect( + () => ArcaneServiceProvider.of(context), + throwsException, + ); + }); + + testWidgets("updateShouldNotify always returns true", (tester) async { + final provider = ArcaneServiceProvider( + serviceInstances: testServices, + child: const SizedBox(), + ); + + expect( + provider.updateShouldNotify( + ArcaneServiceProvider( + serviceInstances: testServices, + child: const SizedBox(), + ), + ), + true, + ); + }); + }); +} + +class MockArcaneService extends ArcaneService {} + +class AnotherMockService extends ArcaneService {} + +class UnregisteredService extends ArcaneService {} + +class MockBuildContext extends Fake implements BuildContext {} diff --git a/test/services/authentication/authentication_service_test.dart b/test/services/authentication/authentication_service_test.dart new file mode 100644 index 0000000..52eef3b --- /dev/null +++ b/test/services/authentication/authentication_service_test.dart @@ -0,0 +1,144 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mockito/annotations.dart"; +import "package:mockito/mockito.dart"; + +import "authentication_service_test.mocks.dart"; + +@GenerateMocks([ + ArcaneAuthInterface, + ArcaneEnvironmentProvider, +]) +void main() { + late ArcaneAuthInterface mockInterface; + + group("ArcaneAuthenticationService", () { + setUp(() async { + // Initialize mocks + mockInterface = MockArcaneAuthInterface(); + + // Initialize the service + await ArcaneAuthenticationService.I.reset(); + + // Set up default mock behaviors + when(mockInterface.login(input: anyNamed("input"))).thenAnswer( + (_) async => Result.ok(null), + ); + when(mockInterface.logout()).thenAnswer( + (_) async => Result.ok(null), + ); + when(mockInterface.init()).thenAnswer( + (_) async {}, + ); + + await ArcaneAuthenticationService.I.registerInterface(mockInterface); + }); + + testWidgets("login with success", (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: ArcaneEnvironmentProvider( + environment: Environment.normal, + child: Builder( + builder: (context) { + return Container(); + }, + ), + ), + ), + ); + final result = await ArcaneAuthenticationService.I.login( + input: {"username": "test"}, + ); + expect(result.isSuccess, true); + expect( + ArcaneAuthenticationService.I.status, + equals(AuthenticationStatus.authenticated), + ); + }); + + testWidgets("login with failure", (WidgetTester tester) async { + // Reset the mock behavior for this specific test + when(mockInterface.login(input: anyNamed("input"))) + .thenAnswer((_) async => Result.error("error")); + + final result = await ArcaneAuthenticationService.I + .login(input: {"username": "test"}); + expect(result.isFailure, true); + expect( + ArcaneAuthenticationService.I.status, + equals(AuthenticationStatus.unauthenticated), + ); + }); + + testWidgets("logout with success", (WidgetTester tester) async { + ArcaneAuthenticationService.I.setAuthenticated(); + final result = await ArcaneAuthenticationService.I.logOut(); + expect(result.isSuccess, true); + expect( + ArcaneAuthenticationService.I.status, + equals(AuthenticationStatus.unauthenticated), + ); + }); + + testWidgets("setDebug enables debug mode", (WidgetTester tester) async { + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: ArcaneEnvironmentProvider( + child: Builder( + builder: (context) { + capturedContext = context; + return Container(); + }, + ), + ), + ), + ); + + await tester.pump(); + ArcaneEnvironment.of(capturedContext).enableDebugMode(); + await tester.pump(); + expect( + ArcaneEnvironment.of(capturedContext).environment, + equals(Environment.debug), + ); + }); + + testWidgets("setNormal disables debug mode", (WidgetTester tester) async { + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: ArcaneEnvironmentProvider( + child: Builder( + builder: (context) { + capturedContext = context; + return Container(); + }, + ), + ), + ), + ); + + await tester.pump(); + ArcaneEnvironment.of(capturedContext).enableDebugMode(); + await tester.pump(); + + expect( + ArcaneEnvironment.of(capturedContext).environment, + equals(Environment.debug), + ); + + ArcaneEnvironment.of(capturedContext).disableDebugMode(); + await tester.pump(); + + expect( + ArcaneEnvironment.of(capturedContext).environment, + equals(Environment.normal), + ); + }); + }); +} diff --git a/test/services/authentication/authentication_service_test.mocks.dart b/test/services/authentication/authentication_service_test.mocks.dart new file mode 100644 index 0000000..f225281 --- /dev/null +++ b/test/services/authentication/authentication_service_test.mocks.dart @@ -0,0 +1,275 @@ +// Mocks generated by Mockito 5.4.5 from annotations +// in arcane_framework/test/services/authentication/authentication_service_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; + +import 'package:arcane_framework/arcane_framework.dart' as _i2; +import 'package:flutter/foundation.dart' as _i4; +import 'package:flutter/widgets.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i6; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeResult_0 extends _i1.SmartFake implements _i2.Result { + _FakeResult_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeWidget_1 extends _i1.SmartFake implements _i3.Widget { + _FakeWidget_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); + + @override + String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => + super.toString(); +} + +class _FakeState_2 extends _i1.SmartFake + implements _i3.State { + _FakeState_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); + + @override + String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => + super.toString(); +} + +class _FakeStatefulElement_3 extends _i1.SmartFake + implements _i3.StatefulElement { + _FakeStatefulElement_3(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); + + @override + String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => + super.toString(); +} + +class _FakeDiagnosticsNode_4 extends _i1.SmartFake + implements _i3.DiagnosticsNode { + _FakeDiagnosticsNode_4(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); + + @override + String toString({ + _i4.TextTreeConfiguration? parentConfiguration, + _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info, + }) => super.toString(); +} + +/// A class which mocks [ArcaneAuthInterface]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockArcaneAuthInterface extends _i1.Mock + implements _i2.ArcaneAuthInterface { + MockArcaneAuthInterface() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future get isSignedIn => + (super.noSuchMethod( + Invocation.getter(#isSignedIn), + returnValue: _i5.Future.value(false), + ) + as _i5.Future); + + @override + _i5.Future init() => + (super.noSuchMethod( + Invocation.method(#init, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i2.Result> logout() => + (super.noSuchMethod( + Invocation.method(#logout, []), + returnValue: _i5.Future<_i2.Result>.value( + _FakeResult_0(this, Invocation.method(#logout, [])), + ), + ) + as _i5.Future<_i2.Result>); + + @override + _i5.Future<_i2.Result> login({ + T? input, + _i5.Future Function()? onLoggedIn, + }) => + (super.noSuchMethod( + Invocation.method(#login, [], { + #input: input, + #onLoggedIn: onLoggedIn, + }), + returnValue: _i5.Future<_i2.Result>.value( + _FakeResult_0( + this, + Invocation.method(#login, [], { + #input: input, + #onLoggedIn: onLoggedIn, + }), + ), + ), + ) + as _i5.Future<_i2.Result>); +} + +/// A class which mocks [ArcaneEnvironmentProvider]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockArcaneEnvironmentProvider extends _i1.Mock + implements _i2.ArcaneEnvironmentProvider { + MockArcaneEnvironmentProvider() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Widget get child => + (super.noSuchMethod( + Invocation.getter(#child), + returnValue: _FakeWidget_1(this, Invocation.getter(#child)), + ) + as _i3.Widget); + + @override + _i2.Environment get environment => + (super.noSuchMethod( + Invocation.getter(#environment), + returnValue: _i2.Environment.debug, + ) + as _i2.Environment); + + @override + _i3.State<_i2.ArcaneEnvironmentProvider> createState() => + (super.noSuchMethod( + Invocation.method(#createState, []), + returnValue: _FakeState_2<_i2.ArcaneEnvironmentProvider>( + this, + Invocation.method(#createState, []), + ), + ) + as _i3.State<_i2.ArcaneEnvironmentProvider>); + + @override + _i3.StatefulElement createElement() => + (super.noSuchMethod( + Invocation.method(#createElement, []), + returnValue: _FakeStatefulElement_3( + this, + Invocation.method(#createElement, []), + ), + ) + as _i3.StatefulElement); + + @override + String toStringShort() => + (super.noSuchMethod( + Invocation.method(#toStringShort, []), + returnValue: _i6.dummyValue( + this, + Invocation.method(#toStringShort, []), + ), + ) + as String); + + @override + void debugFillProperties(_i4.DiagnosticPropertiesBuilder? properties) => + super.noSuchMethod( + Invocation.method(#debugFillProperties, [properties]), + returnValueForMissingStub: null, + ); + + @override + String toStringShallow({ + String? joiner = ', ', + _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.debug, + }) => + (super.noSuchMethod( + Invocation.method(#toStringShallow, [], { + #joiner: joiner, + #minLevel: minLevel, + }), + returnValue: _i6.dummyValue( + this, + Invocation.method(#toStringShallow, [], { + #joiner: joiner, + #minLevel: minLevel, + }), + ), + ) + as String); + + @override + String toStringDeep({ + String? prefixLineOne = '', + String? prefixOtherLines, + _i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.debug, + int? wrapWidth = 65, + }) => + (super.noSuchMethod( + Invocation.method(#toStringDeep, [], { + #prefixLineOne: prefixLineOne, + #prefixOtherLines: prefixOtherLines, + #minLevel: minLevel, + #wrapWidth: wrapWidth, + }), + returnValue: _i6.dummyValue( + this, + Invocation.method(#toStringDeep, [], { + #prefixLineOne: prefixLineOne, + #prefixOtherLines: prefixOtherLines, + #minLevel: minLevel, + #wrapWidth: wrapWidth, + }), + ), + ) + as String); + + @override + _i3.DiagnosticsNode toDiagnosticsNode({ + String? name, + _i4.DiagnosticsTreeStyle? style, + }) => + (super.noSuchMethod( + Invocation.method(#toDiagnosticsNode, [], { + #name: name, + #style: style, + }), + returnValue: _FakeDiagnosticsNode_4( + this, + Invocation.method(#toDiagnosticsNode, [], { + #name: name, + #style: style, + }), + ), + ) + as _i3.DiagnosticsNode); + + @override + List<_i3.DiagnosticsNode> debugDescribeChildren() => + (super.noSuchMethod( + Invocation.method(#debugDescribeChildren, []), + returnValue: <_i3.DiagnosticsNode>[], + ) + as List<_i3.DiagnosticsNode>); + + @override + String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => + super.toString(); +} diff --git a/test/services/feature_flags/feature_flags_service_test.dart b/test/services/feature_flags/feature_flags_service_test.dart new file mode 100644 index 0000000..ae0b268 --- /dev/null +++ b/test/services/feature_flags/feature_flags_service_test.dart @@ -0,0 +1,69 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("ArcaneFeatureFlags", () { + late ArcaneFeatureFlags featureFlags; + + setUp(() { + featureFlags = ArcaneFeatureFlags.I; + }); + + test("singleton instance is consistent", () { + expect(identical(ArcaneFeatureFlags.I, featureFlags), true); + }); + + group("feature management", () { + setUp(() { + Arcane.features.reset(); + }); + test("enableFeature adds feature to enabled list", () { + featureFlags.enableFeature(MockFeature.test); + expect(featureFlags.enabledFeatures, contains(MockFeature.test)); + expect(featureFlags.isEnabled(MockFeature.test), true); + }); + + test("disableFeature removes feature from enabled list", () { + featureFlags.enableFeature(MockFeature.test); + featureFlags.disableFeature(MockFeature.test); + expect(featureFlags.enabledFeatures, isNot(contains(MockFeature.test))); + expect(featureFlags.isDisabled(MockFeature.test), true); + }); + + test("enabling already enabled feature has no effect", () { + featureFlags.enableFeature(MockFeature.test); + final initialCount = featureFlags.enabledFeatures.length; + featureFlags.enableFeature(MockFeature.test); + expect(featureFlags.enabledFeatures.length, equals(initialCount)); + }); + + test("disabling already disabled feature has no effect", () { + final initialCount = featureFlags.enabledFeatures.length; + featureFlags.disableFeature(MockFeature.test); + expect(featureFlags.enabledFeatures.length, equals(initialCount)); + }); + }); + + group("notifications", () { + test("enableFeature notifies listeners", () { + var notified = false; + featureFlags.addListener(() => notified = true); + featureFlags.enableFeature(MockFeature.test); + expect(notified, true); + }); + + test("disableFeature notifies listeners", () { + featureFlags.enableFeature(MockFeature.test); + var notified = false; + featureFlags.addListener(() => notified = true); + featureFlags.disableFeature(MockFeature.test); + expect(notified, true); + }); + }); + }); +} + +enum MockFeature { + test, + another, +} diff --git a/test/services/logging/logging_service_test.dart b/test/services/logging/logging_service_test.dart new file mode 100644 index 0000000..c9dc943 --- /dev/null +++ b/test/services/logging/logging_service_test.dart @@ -0,0 +1,203 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mockito/annotations.dart"; +import "package:mockito/mockito.dart"; + +import "logging_service_test.mocks.dart"; + +class MyOtherLoggingInterface extends Mock implements MockLoggingInterface {} + +@GenerateNiceMocks([ + MockSpec( + onMissingStub: OnMissingStub.returnDefault, + ), +]) +void main() { + final LoggingInterface myInterface = MockLoggingInterface(); + + setUp(() { + Arcane.logger.reset(); + }); + + group("ArcaneLogger", () { + group("interface management", () { + test("registerInterfaces adds interfaces correctly", () async { + await Arcane.logger.registerInterface(myInterface); + + expect( + Arcane.logger.interfaces, + contains(isA()), + ); + }); + + test("registering an interface doesn't initialize it", () async { + await Arcane.logger.registerInterface(myInterface); + + expect(Arcane.logger.interfaces.first, isA()); + + expect(myInterface.initialized, false); + verifyNever(myInterface.init()); + }); + + test("registering an interface initializes the logger", () async { + expect(Arcane.logger.initialized, false); + + await Arcane.logger.registerInterface(myInterface); + + expect(Arcane.logger.initialized, true); + }); + + test("interfaces can be initialized through the logger", () async { + await Arcane.logger.registerInterface(myInterface); + + expect(Arcane.logger.interfaces.first.initialized, false); + + await Arcane.logger.initializeInterfaces(); + + verify(Arcane.logger.interfaces.first.init()).called(1); + }); + + test("multiple interfaces can be registered", () async { + await Arcane.logger.registerInterfaces([ + MockLoggingInterface(), + MyOtherLoggingInterface(), + ]); + + expect( + Arcane.logger.interfaces, + contains(isA()), + ); + expect( + Arcane.logger.interfaces, + contains(isA()), + ); + }); + }); + + group("persistent metadata", () { + test("addPersistentMetadata adds metadata correctly", () { + Arcane.logger.addPersistentMetadata({"test": "value"}); + expect(Arcane.logger.additionalMetadata["test"], equals("value")); + }); + + test("removePersistentMetadata removes specific key", () { + Arcane.logger.addPersistentMetadata({"test": "value", "keep": "this"}); + Arcane.logger.removePersistentMetadata("test"); + expect(Arcane.logger.additionalMetadata.containsKey("test"), false); + expect(Arcane.logger.additionalMetadata["keep"], equals("this")); + }); + + test("clearPersistentMetadata removes all metadata", () { + Arcane.logger + .addPersistentMetadata({"test": "value", "another": "value"}); + Arcane.logger.clearPersistentMetadata(); + expect(Arcane.logger.additionalMetadata.isEmpty, true); + }); + }); + + group("logging messages", () { + const String logMessage = "Test"; + + setUp(() async { + await Arcane.logger.registerInterface(myInterface); + }); + + test("logging a basic message works", () async { + Arcane.log(logMessage); + + verify( + myInterface.log( + logMessage, + metadata: anyNamed("metadata"), + level: anyNamed("level"), + stackTrace: anyNamed("stackTrace"), + extra: anyNamed("extra"), + ), + ).called(1); + }); + + test("logging at a different level works", () async { + Arcane.log( + logMessage, + level: Level.info, + ); + + verify( + myInterface.log( + logMessage, + metadata: anyNamed("metadata"), + level: Level.info, + stackTrace: anyNamed("stackTrace"), + extra: anyNamed("extra"), + ), + ).called(1); + + Arcane.log( + logMessage, + level: Level.warning, + ); + + verify( + myInterface.log( + logMessage, + metadata: anyNamed("metadata"), + level: Level.warning, + stackTrace: anyNamed("stackTrace"), + extra: anyNamed("extra"), + ), + ).called(1); + }); + + test("logging a stacktrace works", () async { + final stackTrace = StackTrace.current; + Arcane.log(logMessage, stackTrace: stackTrace); + + verify( + myInterface.log( + logMessage, + metadata: anyNamed("metadata"), + level: anyNamed("level"), + stackTrace: stackTrace, + extra: anyNamed("extra"), + ), + ).called(1); + }); + + test("logging an extra object works", () async { + const bool extraObject = true; + Arcane.log( + logMessage, + extra: extraObject, + ); + + verify( + myInterface.log( + logMessage, + metadata: anyNamed("metadata"), + level: anyNamed("level"), + stackTrace: anyNamed("stackTrace"), + extra: extraObject, + ), + ).called(1); + }); + + test("logging metadata works", () async { + final Map metadata = {"test": "value"}; + Arcane.log( + logMessage, + metadata: metadata, + ); + + verify( + myInterface.log( + logMessage, + metadata: metadata, + level: anyNamed("level"), + stackTrace: anyNamed("stackTrace"), + extra: anyNamed("extra"), + ), + ).called(1); + }); + }); + }); +} diff --git a/test/services/logging/logging_service_test.mocks.dart b/test/services/logging/logging_service_test.mocks.dart new file mode 100644 index 0000000..4e52291 --- /dev/null +++ b/test/services/logging/logging_service_test.mocks.dart @@ -0,0 +1,69 @@ +// Mocks generated by Mockito 5.4.5 from annotations +// in arcane_framework/test/services/logging/logging_service_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:arcane_framework/src/services/logging/logging_service.dart' + as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [LoggingInterface]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLoggingInterface extends _i1.Mock implements _i2.LoggingInterface { + @override + bool get initialized => + (super.noSuchMethod( + Invocation.getter(#initialized), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); + + @override + _i3.Future<_i2.LoggingInterface?> init() => + (super.noSuchMethod( + Invocation.method(#init, []), + returnValue: _i3.Future<_i2.LoggingInterface?>.value(), + returnValueForMissingStub: + _i3.Future<_i2.LoggingInterface?>.value(), + ) + as _i3.Future<_i2.LoggingInterface?>); + + @override + void log( + String? message, { + Map? metadata, + _i2.Level? level, + StackTrace? stackTrace, + Object? extra, + }) => super.noSuchMethod( + Invocation.method( + #log, + [message], + { + #metadata: metadata, + #level: level, + #stackTrace: stackTrace, + #extra: extra, + }, + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/services/reactive_theme/reactive_theme_service_test.dart b/test/services/reactive_theme/reactive_theme_service_test.dart new file mode 100644 index 0000000..e752d9f --- /dev/null +++ b/test/services/reactive_theme/reactive_theme_service_test.dart @@ -0,0 +1,140 @@ +import "package:arcane_framework/arcane_framework.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("ArcaneReactiveTheme", () { + late ArcaneReactiveTheme theme; + + setUp(() { + theme = ArcaneReactiveTheme.I; + }); + + test("singleton instance is consistent", () { + expect(identical(ArcaneReactiveTheme.I, theme), true); + }); + + group("theme mode", () { + test("initial mode is light", () { + expect(theme.currentMode, equals(ThemeMode.light)); + }); + + test("switchTheme toggles between light and dark", () { + expect(theme.currentMode, equals(ThemeMode.light)); + theme.switchTheme(); + expect(theme.currentMode, equals(ThemeMode.dark)); + theme.switchTheme(); + expect(theme.currentMode, equals(ThemeMode.light)); + }); + + test("switching theme notifies listeners", () { + var notified = false; + theme.addListener(() => notified = true); + theme.switchTheme(); + expect(notified, true); + }); + }); + + group("theme customization", () { + test("setDarkTheme updates dark theme", () { + final customTheme = ThemeData.dark().copyWith( + primaryColor: Colors.purple, + ); + theme.setDarkTheme(customTheme); + expect(theme.dark.primaryColor, equals(Colors.purple)); + }); + + test("setLightTheme updates light theme", () { + final customTheme = ThemeData.light().copyWith( + primaryColor: Colors.orange, + ); + theme.setLightTheme(customTheme); + expect(theme.light.primaryColor, equals(Colors.orange)); + }); + + test("theme updates notify listeners", () { + bool darkNotified = false; + bool lightNotified = false; + ThemeMode currentTheme = ThemeMode.system; + + theme.darkTheme.addListener(() { + darkNotified = true; + }); + + theme.lightTheme.addListener(() { + lightNotified = true; + }); + + theme.addListener(() { + currentTheme = theme.currentMode; + }); + + expect(currentTheme, ThemeMode.system); + + theme.setDarkTheme(ThemeData.dark()); + theme.setLightTheme(ThemeData.light()); + + expect(darkNotified, true); + expect(lightNotified, true); + + theme.switchTheme(); + expect(currentTheme, ThemeMode.light); + + theme.switchTheme(); + expect(currentTheme, ThemeMode.dark); + }); + }); + + group("system theme following", () { + setUp(() { + Arcane.theme.reset(); + }); + + testWidgets("followSystemTheme updates theme based on context brightness", + (WidgetTester tester) async { + // Create widgets with different brightness contexts + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.light), + child: SizedBox(), + ), + ); + + final BuildContext lightContext = tester.element(find.byType(SizedBox)); + theme.followSystemTheme(lightContext); + + expect(theme.currentMode, equals(ThemeMode.light)); + + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.dark), + child: SizedBox(), + ), + ); + + final BuildContext darkContext = tester.element(find.byType(SizedBox)); + + theme.followSystemTheme(darkContext); + expect(theme.currentMode, equals(ThemeMode.dark)); + }); + + testWidgets("followSystemTheme only switches when needed", + (WidgetTester tester) async { + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(platformBrightness: Brightness.light), + child: SizedBox(), + ), + ); + final BuildContext lightContext = tester.element(find.byType(SizedBox)); + + int switchCount = 0; + theme.addListener(() => switchCount++); + + // Already light, shouldn't switch + theme.followSystemTheme(lightContext); + expect(switchCount, equals(0)); + }); + }); + }); +}