mirror of
https://github.com/hanskokx/arcane_framework.git
synced 2026-08-12 06:10:55 +02:00
v2.0.0: Overhaul core, services, and app integration (#6)
* [UNTESTED] Fixes notifiers and adds some additional methods. Adds tests. Changes: // ArcaneEnvironment breaking: context.read<ArcaneEnvironment>() -> ArcaneEnvironment.of(context) breaking: context.read<ArcaneEnvironment>().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 <hans.d.kokx@gmail.com> * ArcaneReactiveTheme now optionally takes a ThemeMode parameter when calling switchTheme Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Added `of(context)` extension to `ArcaneService` * Updated changelog * Logging service: - added: reset() - added: `skipAutodetection` option for module, method, and metadata - added: unregisterInterface() - Created FileAndLineNumber mixin Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Fix module/method/fileAndLineNumber calculations in logging service Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Minor fix to fileAndLineParts calculation Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Remove debug logging Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Minor change Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Fixed theme - fixed: currentMode, dark, light now actually emit new values when changed - added: getters for lightTheme, darkTheme, and systemTheme - breaking: currentMode -> systemTheme - added: currentTheme - breaking: currentMode => currentTheme - change: Arcane.theme.followSystemTheme(context) required to follow system theme - change: After following the system theme, calling Arcane.theme.switchTheme() cancels following the system theme - added: ArcaneReactiveTheme is now registered in ArcaneServiceProvider when using ArcaneApp Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Removed arcane theme from service provider Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Fixed late initialized value in logging service * Fixes the reactive theme service to properly follow the system brightness Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Fixes tests and updates reactive theme Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Fixes broken tests Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Fixes bug with following/unfollowing system theme Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Re-add getters on ArcaneTheme Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Update example Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Added logStream to logger. Updated example code. Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Setting a theme style now automatically switches to that theme Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Updated theme example to set color of theme Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Added examples for feature flags Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Added environment example Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Center text Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Added environment logging Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Rename onEnvironmentChanged to switchEnvironment Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Made ArcaneServiceProvider.of(context) nullable Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Remove invalid test Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Extra log messages will not longer be added to the log stream if more than one LoggingInterface is registered Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Only send log messages to the LoggingInterfaces if the logging service has been initialized Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Check for empty interfaces before initializing them in the logger Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Moved platform brightness checking from ArcaneApp to ArcaneThemeSwitcher, where it is more appropriate Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Correctly export the theme switcher widget Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Remove unused import Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Made the switchEnvironment field in ArcaneEnvironment private (_switchEnvironment) Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Testing GH actions Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Set correct .puro.json filename in github action config Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Check different path for .puro.json Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Test different GH action workflow Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Attempt to correctly address "stable" version in .puro.json Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Another GH action attempt Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Add environment variables Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Debug Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Add puro config to git :) Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Remove debug Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * feat(framework): Overhauls framework core, services, and app integration for 2.0.0 (#5) * Made ArcaneTheme private (_ArcaneTheme) Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Updated changelog Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Fix updateShouldNotify on ArcaneServiceProvider Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Update service provider Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Put service locators in an extension Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Try using a mixin instead Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Remove 'of' and 'requiredOf' locators Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Re-add service locators. They should be called as: ArcaneService.of<MyService>(context) Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Renamed getters on ArcaneService Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Renamed serviceInstances => registeredServices and added a removeService method Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Broke up arcane service into separate files Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Added requiredServiceOfType getter on ArcaneServiceProvider Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Updated services section of the readme Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Updated example to add a services example Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Removed unused variable Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Update example documentation Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Update example Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Breaking up example into smaller widgets Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Remove bloc dependency Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Fixes the notifier for feature flags and updates the example Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Fixed a bug in the example Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Update ServiceProvider to make service instances optional Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Remove unnecessary notifyListeners calls Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Organize theme service Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Fix authentication interface and service methods for logout and login. * Fix DebugAuthInterface logout method Added environment key to ArcaneEnvironment constructor Renamed switchEnvironment parameter in ArcaneEnvironment constructor Bumped arcane_helper_utils dependency version * Enhance theme management with StreamBuilder for dynamic updates Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Add configuration files and update authentication service error handling - Introduced .vscode/settings.json and .vscode/launch.json for IDE configuration. - Updated DebugAuthInterface and ArcaneAuthenticationService to return const Result.ok() for consistency. - Added ArcaneTheme class for theme management. - Updated pubspec.yaml to change result_monad dependency version. - Modified authentication_service_test to return const Result.ok() in mock setups. Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Update dependency versions to use 'any' for arcane_helper_utils and arcane_analysis Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Remove unnecessary 'const' keyword from Result.ok calls in DebugAuthInterface methods Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Add 'const' keyword to Result.ok calls in DebugAuthInterface methods Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Enhance logging service with new lifecycle capabilities and interceptor support - Updated `LoggingInterface` to remove singleton-style initialization. - Introduced `LoggingInitializable` and `LoggingInitializationMixin` for optional lifecycle management. - Added `LogEvent` and `LogInterceptor` classes for improved logging event handling and interception. - Updated `ArcaneLogger` to support multiple logging interfaces and global interceptors. - Modified `DebugPrint` and `DebugAuthInterface` to align with new logging structure. - Enhanced tests to cover new logging features and interceptor functionality. Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Add stream-based updates for authentication, feature flags, logging, and theming - Added `statusChanges` and `signedInChanges` streams to `ArcaneAuth` for real-time authentication updates. - Introduced `enabledFeaturesChanges` stream in `ArcaneFeatureFlags` for observing feature updates. - Improved `ArcaneLogger` to maintain `logStream` usability after listener cancellation. - Updated `ArcaneTheme` to provide streams for theme mode and theme data changes. - Enhanced documentation and examples to reflect new stream APIs and usage patterns. - Added regression tests for stream listener cancellation and re-subscription across services. Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Refactor ArcaneEnvironment and AuthenticationService for improved environment management and status handling Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Add application environment management and tests for authentication status coherence Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * feat: Enhance service management with ArcaneApp provider composition This commit significantly refactors Arcane's service architecture. ArcaneApp now acts as a StatefulWidget, composing InheritedWidget- based providers for core services (features, environment, theme, auth). Service lookups (e.g., Arcane.features, context.service()) now prioritize provider-registered instances, falling back to singletons. Key changes include: - Renamed ArcaneFeatureFlags to ArcaneFeatureFlagService and ArcaneReactiveTheme to ArcaneThemeService (with typedefs). - Introduced ArcaneEnvironmentService and ArcaneEnvironmentProvider for centralized environment management. - Added BuildContext extensions (e.g., context.featureFlags) for reactive, convenient service access. - Removed .puro.json and its references, streamlining SDK setup. - Updated CHANGELOG.md to reflect a 2.0.0 release, detailing all breaking changes and new features. * chore(example): Add ignore rules for example builds * feat(logging): Introduce log interceptors and origin control Introduces the LogInterceptor class, enabling pre-processing, modification, or suppression of log events before they reach registered interfaces. The LogInterceptorContext provides contextual information such as the originating LoggingInterface. Adds a `skipAutodetection` parameter to Arcane.log, allowing control over the automatic detection of log origin (module, method, file/line). Also makes the LogEvent class extendable to support custom event types and updates existing interceptor callback signatures for consistency. * fix(theme): Update theme mode check to use currentModeOf method Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * fix(theme): Improve theme mode detection and toggling `context.isDarkMode` now reflects the app's effective theme brightness, not the raw platform brightness. This ensures `isDarkMode` accurately reports the currently rendered theme. `switchTheme()` now intelligently toggles from the *effective* theme when in `ThemeMode.system` by considering the current app brightness. `followSystemTheme()` is updated to always read the platform brightness directly, decoupling its behavior from the app's overridden theme settings. * feat(example): Improve theme and service integration demo Updates the example application to better demonstrate service and theme integration. The `FavoriteColorService` now actively sets the app's color scheme and can initialize its state from the current theme. Environment display is now reactive, and the UI responds dynamically to service registration and removal. Initial themes are now explicitly seeded. * feat(example): Add interactive persistent metadata toggle Previously, persistent metadata was added unconditionally. This change allows users to dynamically enable or disable the demo persistent metadata via a UI switch, better showcasing the API functionality. * feat(theme): Add assignment-style theme setters Introduces convenience setters for dark and light themes, allowing `Arcane.theme.dark = ...` and `Arcane.theme.light = ...` as an alternative to `setDarkTheme` and `setLightTheme`. This improves developer ergonomics and conciseness. * docs(README): Add Application Environments section to features list Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * Adds `ArcaneApp.builder` to provide a provider-aware `BuildContext` for the application root. This deprecates the `ArcaneApp.child` parameter, improving developer experience for accessing Arcane's providers at build time. Updates the `ArcaneThemeSwitcher` to default to `followSystemTheme` when mounted under `ArcaneApp`, ensuring system theme behavior is enabled out of the box. Fixes `setDarkTheme` and `setLightTheme` to only update the rendered theme if their respective modes are currently active. This prevents unintended UI changes when updating the definition of an inactive theme. Updates README and examples to reflect the new `builder` API and clarified theme logic. Adds new tests for these changes. * docs(arcane_app): Clarify ArcaneApp API and provider access documentation Updates the documentation for the `ArcaneApp` constructor to explicitly detail the preferred `builder` API and the deprecated `child` parameter, including migration guidance. Also updates the list of provided services to include `ArcaneFeatureFlagsProvider`. * feat: Overhaul Arcane framework for 2.0.0 release notes Migrate Arcane to a static utility surface, removing the instantiable singleton constructor for simpler and more direct access to services. Introduce new reactive streams for environment, authentication, and theme services, enabling real-time observation of state changes. Enhance `ArcaneApp` integration with the new `builder` callback and `BuildContext` convenience accessors for services and feature flags. Update `ArcaneAuthInterface.logout` signature and revise `src` import paths, requiring consumers to update their implementations and imports. Deprecate `ArcaneApp.child` and `BuildContext.serviceOfType<T>()` in favor of new, more idiomatic APIs. Remove direct `flutter_bloc` dependency and upgrade `result_monad` to `^4.0.0`. * refactor(environment): Rename environment access getter to current This change unifies how the current application environment is accessed across Arcane's services and providers. The `state` and `environment` getters have been replaced with a consistent `current` getter, simplifying API usage and aligning with new patterns. Includes corresponding updates in example and internal code, and documents the migration in the CHANGELOG. * chore: Relax result_monad dependency constraint The `result_monad` dependency was recently upgraded to `^4.0.0`. This update relaxes the constraint to `any` to provide greater flexibility and mitigate potential dependency conflicts for consumers of Arcane, allowing them to use other compatible versions. * refactor(logging): Replace LoggingInitializationMixin with LoggingInitialization and add LoggingFeature annotation support Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * docs(changelog): Add migration steps for ArcaneThemeService and update themeMode configuration examples Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * refactor(workflow): Simplify analyze-and-test workflow by removing unused environment variables and steps Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * docs(changelog): Update Arcane.log metadata type and add migration steps for structured values Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * test: Add unit tests for authentication and feature flags functionality Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * feat(theme): track user-defined theme overrides in ArcaneThemeService Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * refactor(service): update service access methods in ArcaneServiceProvider Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * refactor(tests): format login and logout method signatures in MockAuth for improved readability Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * feat(tests): add unit tests for ArcaneThemeService functionality Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> --------- Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * fix(dependencies): update result_monad and arcane_helper_utils constraints Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * fix(version): update version from 2.0.0-dev to 2.0.0 Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * feat(auth): add callbacks for login/logout events in DebugAuthInterface fix(logging): ensure proper cancellation of log stream subscriber refactor(arcane): make built-in services list unmodifiable fix(app): update service notifier on widget updates fix(service): improve removeService method to return boolean fix(auth): update BuildContext parameter to unused in setDebug/setNormal fix(logging): correct log level reference in LogInterceptorContext documentation fix(theme): update documentation to reflect ThemeMode instead of ThemeData fix(theme): ensure unawaited cancellation of theme subscriptions chore(dependencies): update arcane_analysis version to ^1.0.4 test: make ArcaneAuthenticationService reset asynchronous in tests Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * feat(theme): enhance theme synchronization and documentation for ArcaneApp Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> * fix(gitignore): add example/.metadata to .gitignore Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> --------- Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com> Co-authored-by: Hans Kokx <hans.kokx@airahome.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import "package:arcane_framework/arcane_framework.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
ArcaneFeatureFlagService.I.reset();
|
||||
await ArcaneAuthenticationService.I.reset();
|
||||
ArcaneReactiveTheme.I.reset();
|
||||
ArcaneEnvironmentService.I.reset();
|
||||
});
|
||||
|
||||
group("Arcane", () {
|
||||
test("services getter returns all core services", () {
|
||||
final services = Arcane.services;
|
||||
expect(services, contains(isA<ArcaneFeatureFlagService>()));
|
||||
expect(services, contains(isA<ArcaneAuthenticationService>()));
|
||||
expect(services, contains(isA<ArcaneReactiveTheme>()));
|
||||
expect(services, contains(isA<ArcaneEnvironmentService>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
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<ArcaneService> 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.registeredServices, containsAll(testServices));
|
||||
expect(
|
||||
provider.registeredServices
|
||||
.whereType<ArcaneFeatureFlagService>(),
|
||||
isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
provider.registeredServices
|
||||
.whereType<ArcaneAuthenticationService>(),
|
||||
isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
provider.registeredServices.whereType<ArcaneThemeService>(),
|
||||
isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
provider.registeredServices
|
||||
.whereType<ArcaneEnvironmentService>(),
|
||||
isNotEmpty,
|
||||
);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("does not duplicate built-ins when explicitly provided",
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: [Arcane.environment],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final provider = ArcaneServiceProvider.of(context);
|
||||
final environmentServices = provider.registeredServices
|
||||
.whereType<ArcaneEnvironmentService>()
|
||||
.toList();
|
||||
|
||||
expect(environmentServices.length, 1);
|
||||
expect(environmentServices.single, same(Arcane.environment));
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("ArcaneApp builder receives provider-aware context",
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: testServices,
|
||||
builder: (context, child) {
|
||||
final provider = ArcaneServiceProvider.of(context);
|
||||
expect(provider.registeredServices, containsAll(testServices));
|
||||
return child ?? const SizedBox();
|
||||
},
|
||||
child: const SizedBox(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("ArcaneApp supports builder-only usage", (tester) async {
|
||||
var builderCalled = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: testServices,
|
||||
builder: (context, _) {
|
||||
builderCalled = true;
|
||||
final provider = ArcaneServiceProvider.of(context);
|
||||
expect(provider.registeredServices, containsAll(testServices));
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(builderCalled, isTrue);
|
||||
});
|
||||
|
||||
test("ArcaneApp asserts when both child and builder are missing", () {
|
||||
expect(
|
||||
() => ArcaneApp(),
|
||||
throwsA(isA<AssertionError>()),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("static serviceOfType<T> method returns correct service",
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: testServices,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final service =
|
||||
ArcaneServiceProvider.serviceOfType<MockArcaneService>(
|
||||
context,
|
||||
);
|
||||
expect(service, isNotNull);
|
||||
expect(service, isA<MockArcaneService>());
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
"static serviceOfType<T> method returns correct service and returns null when not found",
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: testServices,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
// Should find this service
|
||||
final service =
|
||||
ArcaneServiceProvider.serviceOfType<MockArcaneService>(
|
||||
context,
|
||||
);
|
||||
|
||||
expect(service, isA<MockArcaneService>());
|
||||
|
||||
// Returns null for unregistered services
|
||||
expect(
|
||||
ArcaneServiceProvider.serviceOfType<UnregisteredService>(
|
||||
context,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("service<T> extension returns correct service", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: testServices,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final service = context.service<MockArcaneService>();
|
||||
expect(service, isNotNull);
|
||||
expect(service, isA<MockArcaneService>());
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("service<T> prefers provider services over built-in fallbacks",
|
||||
(tester) async {
|
||||
final providerService = MockArcaneService();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: [providerService],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final service = context.service<ArcaneService>();
|
||||
expect(service, same(providerService));
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
"requiredService<T> extension returns correct service and throws when not found",
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: testServices,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
// Should find this service
|
||||
final service = context.requiredService<MockArcaneService>();
|
||||
expect(service, isA<MockArcaneService>());
|
||||
|
||||
// Should throw for missing service
|
||||
expect(
|
||||
() => context.requiredService<UnregisteredService>(),
|
||||
throwsA(isA<AssertionError>()),
|
||||
);
|
||||
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("service<T> returns null for unregistered service",
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: testServices,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final service = context.service<UnregisteredService>();
|
||||
expect(service, isNull);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("legacy serviceOfType method still works but is deprecated",
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: testServices,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
final service = context.serviceOfType<MockArcaneService>();
|
||||
expect(service, isNotNull);
|
||||
expect(service, isA<MockArcaneService>());
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("service updates trigger rebuilds", (tester) async {
|
||||
late ArcaneServiceProvider provider;
|
||||
int buildCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ArcaneServiceProvider(
|
||||
serviceInstances: testServices,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
provider = ArcaneServiceProvider.of(context);
|
||||
buildCount++;
|
||||
// Access a service to create dependency
|
||||
context.service<MockArcaneService>();
|
||||
return const Text("Test");
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(buildCount, 1);
|
||||
|
||||
// Update services and verify rebuild
|
||||
provider.setServices([MockArcaneService(), AnotherMockService()]);
|
||||
await tester.pump();
|
||||
expect(buildCount, 2);
|
||||
|
||||
// Add a service and verify rebuild
|
||||
provider.addService(UnregisteredService());
|
||||
await tester.pump();
|
||||
expect(buildCount, 3);
|
||||
});
|
||||
|
||||
testWidgets("ArcaneService.of<T> static helper works", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: testServices,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final service = ArcaneService.ofType<MockArcaneService>(context);
|
||||
expect(service, isNotNull);
|
||||
expect(service, isA<MockArcaneService>());
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("ArcaneService.requiredOf<T> static helper works",
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ArcaneApp(
|
||||
services: testServices,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final service =
|
||||
ArcaneService.requiredOfType<MockArcaneService>(context);
|
||||
expect(service, isA<MockArcaneService>());
|
||||
|
||||
expect(
|
||||
() =>
|
||||
ArcaneService.requiredOfType<UnregisteredService>(context),
|
||||
throwsA(isA<AssertionError>()),
|
||||
);
|
||||
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Mock classes for testing
|
||||
class MockArcaneService extends ArcaneService {}
|
||||
|
||||
class AnotherMockService extends ArcaneService {}
|
||||
|
||||
class UnregisteredService extends ArcaneService {}
|
||||
|
||||
class MockBuildContext extends Fake implements BuildContext {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import "package:arcane_framework/src/services/authentication/authentication_service.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
group("SignUpStep", () {
|
||||
test("values are correct", () {
|
||||
expect(SignUpStep.confirmSignUp.index, 0);
|
||||
expect(SignUpStep.done.index, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group("AuthenticationStatus", () {
|
||||
test("isAuthenticated returns true only for authenticated", () {
|
||||
expect(AuthenticationStatus.authenticated.isAuthenticated, isTrue);
|
||||
expect(AuthenticationStatus.unauthenticated.isAuthenticated, isFalse);
|
||||
});
|
||||
test("isUnauthenticated returns true only for unauthenticated", () {
|
||||
expect(AuthenticationStatus.authenticated.isUnauthenticated, isFalse);
|
||||
expect(AuthenticationStatus.unauthenticated.isUnauthenticated, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import "package:arcane_framework/src/services/authentication/authentication_service.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:result_monad/result_monad.dart";
|
||||
|
||||
class MockAuth implements ArcaneAuthInterface {
|
||||
@override
|
||||
Future<Result<void, String>> login<T>({
|
||||
T? input,
|
||||
Future<void> Function()? onLoggedIn,
|
||||
}) async {
|
||||
if (onLoggedIn != null) await onLoggedIn();
|
||||
return const Result.ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> get isSignedIn => Future.value(true);
|
||||
|
||||
@override
|
||||
Future<String?>? get accessToken => Future.value("token");
|
||||
|
||||
@override
|
||||
Future<String?>? get refreshToken => Future.value("refresh");
|
||||
|
||||
@override
|
||||
Future<void> init() async {}
|
||||
|
||||
@override
|
||||
Future<Result<void, String>> logout({
|
||||
Future<void> Function()? onLoggedOut,
|
||||
}) async {
|
||||
if (onLoggedOut != null) await onLoggedOut();
|
||||
return const Result.ok(null);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test("MockAuth fulfills ArcaneAuthInterface contract", () async {
|
||||
final auth = MockAuth();
|
||||
expect(await auth.isSignedIn, isTrue);
|
||||
expect(await auth.accessToken, "token");
|
||||
expect(await auth.refreshToken, "refresh");
|
||||
var called = false;
|
||||
await auth.logout(
|
||||
onLoggedOut: () async {
|
||||
called = true;
|
||||
},
|
||||
);
|
||||
expect(called, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import "package:arcane_framework/arcane_framework.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:mocktail/mocktail.dart";
|
||||
|
||||
class MockArcaneAuthInterface extends Mock implements ArcaneAuthInterface {}
|
||||
|
||||
class MockAccountRegistration extends Mock
|
||||
implements ArcaneAuthInterface, ArcaneAuthAccountRegistration {}
|
||||
|
||||
class MockPasswordManagement extends Mock
|
||||
implements ArcaneAuthInterface, ArcaneAuthPasswordManagement {}
|
||||
|
||||
void main() {
|
||||
group("ArcaneAuthenticationService error and edge cases", () {
|
||||
setUp(() async {
|
||||
await ArcaneAuthenticationService.I.reset();
|
||||
Arcane.environment.reset();
|
||||
});
|
||||
|
||||
test("reset clears interface and notifiers", () async {
|
||||
final auth = MockArcaneAuthInterface();
|
||||
when(() => auth.init()).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
await ArcaneAuthenticationService.I.registerInterface(auth);
|
||||
await ArcaneAuthenticationService.I.reset();
|
||||
expect(ArcaneAuthenticationService.I.authInterface, isNull);
|
||||
expect(
|
||||
ArcaneAuthenticationService.I.status,
|
||||
AuthenticationStatus.unauthenticated,
|
||||
);
|
||||
expect(ArcaneAuthenticationService.I.isSignedIn.value, false);
|
||||
});
|
||||
|
||||
test("registerInterface throws if already registered", () async {
|
||||
final auth = MockArcaneAuthInterface();
|
||||
when(() => auth.init()).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
await ArcaneAuthenticationService.I.registerInterface(auth);
|
||||
expect(
|
||||
() async => ArcaneAuthenticationService.I.registerInterface(auth),
|
||||
throwsException,
|
||||
);
|
||||
});
|
||||
|
||||
test("login returns error if no interface registered", () async {
|
||||
final result = await ArcaneAuthenticationService.I.login(input: {});
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("No ArcaneAuthInterface"));
|
||||
});
|
||||
|
||||
test("logOut returns error if no interface registered", () async {
|
||||
final result = await ArcaneAuthenticationService.I.logOut();
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("No ArcaneAuthInterface"));
|
||||
});
|
||||
|
||||
test("logOut returns error if not authenticated", () async {
|
||||
final auth = MockArcaneAuthInterface();
|
||||
when(() => auth.init()).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
await ArcaneAuthenticationService.I.registerInterface(auth);
|
||||
final result = await ArcaneAuthenticationService.I.logOut();
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("not authenticated"));
|
||||
});
|
||||
|
||||
test("register returns error if no interface registered", () async {
|
||||
final result = await ArcaneAuthenticationService.I.register(input: {});
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("No ArcaneAuthInterface"));
|
||||
});
|
||||
|
||||
test("register returns error if interface does not support registration",
|
||||
() async {
|
||||
final auth = MockArcaneAuthInterface();
|
||||
when(() => auth.init()).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
await ArcaneAuthenticationService.I.registerInterface(auth);
|
||||
final result = await ArcaneAuthenticationService.I.register(input: {});
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("does not support account registration"));
|
||||
});
|
||||
|
||||
test("register returns error if registration returns null", () async {
|
||||
final auth = MockAccountRegistration();
|
||||
when(() => auth.init()).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
when(() => auth.register(input: any(named: "input")))
|
||||
.thenAnswer((_) async => const Result.error("returned a null value"));
|
||||
await ArcaneAuthenticationService.I.registerInterface(auth);
|
||||
final result = await ArcaneAuthenticationService.I.register(input: {});
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("returned a null value"));
|
||||
});
|
||||
|
||||
test("confirmSignup returns error if no interface registered", () async {
|
||||
final result = await ArcaneAuthenticationService.I
|
||||
.confirmSignup(email: "a", confirmationCode: "b");
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("No ArcaneAuthInterface"));
|
||||
});
|
||||
|
||||
test(
|
||||
"confirmSignup returns error if interface does not support registration",
|
||||
() async {
|
||||
final auth = MockArcaneAuthInterface();
|
||||
when(() => auth.init()).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
await ArcaneAuthenticationService.I.registerInterface(auth);
|
||||
final result = await ArcaneAuthenticationService.I
|
||||
.confirmSignup(email: "a", confirmationCode: "b");
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("does not support account registration"));
|
||||
});
|
||||
|
||||
test("confirmSignup returns error if confirmSignup returns null", () async {
|
||||
final auth = MockAccountRegistration();
|
||||
when(() => auth.init()).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
when(
|
||||
() => auth.confirmSignup(
|
||||
username: any(named: "username"),
|
||||
confirmationCode: any(named: "confirmationCode"),
|
||||
),
|
||||
).thenAnswer((_) async => const Result.error("returned a null value"));
|
||||
await ArcaneAuthenticationService.I.registerInterface(auth);
|
||||
final result = await ArcaneAuthenticationService.I
|
||||
.confirmSignup(email: "a", confirmationCode: "b");
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("returned a null value"));
|
||||
});
|
||||
|
||||
test("resendVerificationCode returns error if no interface registered",
|
||||
() async {
|
||||
final result =
|
||||
await ArcaneAuthenticationService.I.resendVerificationCode("a");
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("No ArcaneAuthInterface"));
|
||||
});
|
||||
|
||||
test(
|
||||
"resendVerificationCode returns error if interface does not support registration",
|
||||
() async {
|
||||
final auth = MockArcaneAuthInterface();
|
||||
when(() => auth.init()).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
await ArcaneAuthenticationService.I.registerInterface(auth);
|
||||
final result =
|
||||
await ArcaneAuthenticationService.I.resendVerificationCode("a");
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("does not support account registration"));
|
||||
});
|
||||
|
||||
test(
|
||||
"resendVerificationCode returns error if resendVerificationCode returns null",
|
||||
() async {
|
||||
final auth = MockAccountRegistration();
|
||||
when(() => auth.init()).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
when(() => auth.resendVerificationCode(input: any(named: "input")))
|
||||
.thenReturn(null);
|
||||
await ArcaneAuthenticationService.I.registerInterface(auth);
|
||||
final result =
|
||||
await ArcaneAuthenticationService.I.resendVerificationCode("a");
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("returned a null value"));
|
||||
});
|
||||
|
||||
test("resetPassword returns error if no interface registered", () async {
|
||||
final result =
|
||||
await ArcaneAuthenticationService.I.resetPassword(email: "a");
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("No ArcaneAuthInterface"));
|
||||
});
|
||||
|
||||
test(
|
||||
"resetPassword returns error if interface does not support password management",
|
||||
() async {
|
||||
final auth = MockArcaneAuthInterface();
|
||||
when(() => auth.init()).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
await ArcaneAuthenticationService.I.registerInterface(auth);
|
||||
final result =
|
||||
await ArcaneAuthenticationService.I.resetPassword(email: "a");
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("does not support password management"));
|
||||
});
|
||||
|
||||
test("resetPassword returns error if resetPassword returns null", () async {
|
||||
final auth = MockPasswordManagement();
|
||||
when(() => auth.init()).thenAnswer((_) async {
|
||||
return null;
|
||||
});
|
||||
when(
|
||||
() => auth.resetPassword(
|
||||
email: any(named: "email"),
|
||||
newPassword: any(named: "newPassword"),
|
||||
code: any(named: "code"),
|
||||
),
|
||||
).thenAnswer((_) async => const Result.error("returned a null value"));
|
||||
await ArcaneAuthenticationService.I.registerInterface(auth);
|
||||
final result =
|
||||
await ArcaneAuthenticationService.I.resetPassword(email: "a");
|
||||
expect(result.isFailure, true);
|
||||
expect(result.error, contains("returned a null value"));
|
||||
});
|
||||
|
||||
test("dispose closes stream controllers and calls super", () async {
|
||||
// Just call dispose to ensure no exceptions are thrown
|
||||
ArcaneAuthenticationService.I.dispose();
|
||||
// No assertion needed; just ensure no crash
|
||||
});
|
||||
});
|
||||
|
||||
late ArcaneAuthInterface authInterface;
|
||||
|
||||
group("ArcaneAuthenticationService", () {
|
||||
setUp(() async {
|
||||
authInterface = MockArcaneAuthInterface();
|
||||
|
||||
// Initialize the service
|
||||
await ArcaneAuthenticationService.I.reset();
|
||||
Arcane.environment.reset();
|
||||
|
||||
when(() => authInterface.init()).thenAnswer((_) async {});
|
||||
|
||||
when(
|
||||
() => authInterface.login<Map<String, String>>(
|
||||
input: any(named: "input"),
|
||||
onLoggedIn: any(named: "onLoggedIn"),
|
||||
),
|
||||
).thenAnswer((_) async => const Result.ok(null));
|
||||
|
||||
when(
|
||||
() => authInterface.logout(
|
||||
onLoggedOut: any(named: "onLoggedOut"),
|
||||
),
|
||||
).thenAnswer((_) async => const Result.ok(null));
|
||||
|
||||
await ArcaneAuthenticationService.I.registerInterface(authInterface);
|
||||
});
|
||||
|
||||
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 {
|
||||
when(
|
||||
() => authInterface.login<Map<String, String>>(
|
||||
input: any(named: "input"),
|
||||
onLoggedIn: any(named: "onLoggedIn"),
|
||||
),
|
||||
).thenAnswer((_) async => const 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(
|
||||
"setDebug and setNormal use Arcane.environment without provider ancestry",
|
||||
(WidgetTester tester) async {
|
||||
late BuildContext capturedContext;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
capturedContext = context;
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await ArcaneAuthenticationService.I.setDebug(capturedContext);
|
||||
expect(Arcane.environment.current, Environment.debug);
|
||||
|
||||
await ArcaneAuthenticationService.I.setNormal(capturedContext);
|
||||
expect(Arcane.environment.current, Environment.normal);
|
||||
},
|
||||
);
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
"setDebug and setNormal do not mutate authentication status",
|
||||
(WidgetTester tester) async {
|
||||
late BuildContext capturedContext;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ArcaneEnvironmentProvider(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
capturedContext = context;
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await ArcaneAuthenticationService.I.login(
|
||||
input: {"username": "test"},
|
||||
);
|
||||
|
||||
expect(ArcaneAuthenticationService.I.isAuthenticated, true);
|
||||
|
||||
await ArcaneAuthenticationService.I.setDebug(capturedContext);
|
||||
expect(
|
||||
ArcaneAuthenticationService.I.status,
|
||||
AuthenticationStatus.authenticated,
|
||||
);
|
||||
|
||||
await ArcaneAuthenticationService.I.setNormal(capturedContext);
|
||||
expect(
|
||||
ArcaneAuthenticationService.I.status,
|
||||
AuthenticationStatus.authenticated,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets("supports custom environment values",
|
||||
(WidgetTester tester) async {
|
||||
late BuildContext capturedContext;
|
||||
|
||||
const Environment staging = Environment("staging");
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ArcaneEnvironmentProvider(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
capturedContext = context;
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
ArcaneEnvironment.of(capturedContext).setEnvironment(staging);
|
||||
await tester.pump();
|
||||
|
||||
expect(ArcaneEnvironment.of(capturedContext).environment, staging);
|
||||
expect(ArcaneEnvironment.of(capturedContext).environment.name, "staging");
|
||||
});
|
||||
|
||||
test("statusChanges emits authentication updates", () async {
|
||||
final statusEvent = expectLater(
|
||||
ArcaneAuthenticationService.I.statusChanges,
|
||||
emits(AuthenticationStatus.authenticated),
|
||||
);
|
||||
|
||||
ArcaneAuthenticationService.I.setAuthenticated();
|
||||
await statusEvent;
|
||||
});
|
||||
|
||||
test("signedInChanges emits signed-in updates", () async {
|
||||
final signedInEvent = expectLater(
|
||||
ArcaneAuthenticationService.I.signedInChanges,
|
||||
emits(true),
|
||||
);
|
||||
|
||||
ArcaneAuthenticationService.I.setAuthenticated();
|
||||
await signedInEvent;
|
||||
});
|
||||
|
||||
test("statusChanges works after listener cancellation", () async {
|
||||
final firstSubscription =
|
||||
ArcaneAuthenticationService.I.statusChanges.listen((_) {});
|
||||
await firstSubscription.cancel();
|
||||
|
||||
// Ensure a deterministic baseline before asserting the next stream event.
|
||||
ArcaneAuthenticationService.I.setUnauthenticated();
|
||||
|
||||
final secondEvent = expectLater(
|
||||
ArcaneAuthenticationService.I.statusChanges,
|
||||
emits(AuthenticationStatus.authenticated),
|
||||
);
|
||||
|
||||
ArcaneAuthenticationService.I.setAuthenticated();
|
||||
await secondEvent;
|
||||
});
|
||||
|
||||
test("statusChanges and signedInChanges stay coherent", () async {
|
||||
ArcaneAuthenticationService.I.setUnauthenticated();
|
||||
|
||||
final statusEvents = expectLater(
|
||||
ArcaneAuthenticationService.I.statusChanges,
|
||||
emitsInOrder(
|
||||
<AuthenticationStatus>[
|
||||
AuthenticationStatus.authenticated,
|
||||
AuthenticationStatus.unauthenticated,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final signedInEvents = expectLater(
|
||||
ArcaneAuthenticationService.I.signedInChanges,
|
||||
emitsInOrder(<bool>[true, false]),
|
||||
);
|
||||
|
||||
ArcaneAuthenticationService.I.setAuthenticated();
|
||||
ArcaneAuthenticationService.I.setUnauthenticated();
|
||||
|
||||
await statusEvents;
|
||||
await signedInEvents;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import "package:arcane_framework/arcane_framework.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
enum DummyFeature { foo, bar }
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
Arcane.features.disableFeature(DummyFeature.foo);
|
||||
Arcane.features.disableFeature(DummyFeature.bar);
|
||||
});
|
||||
|
||||
test("enabled/disabled reflect Arcane.features state", () {
|
||||
expect(DummyFeature.foo.enabled, isFalse);
|
||||
Arcane.features.enableFeature(DummyFeature.foo);
|
||||
expect(DummyFeature.foo.enabled, isTrue);
|
||||
expect(DummyFeature.foo.disabled, isFalse);
|
||||
Arcane.features.disableFeature(DummyFeature.foo);
|
||||
expect(DummyFeature.foo.disabled, isTrue);
|
||||
});
|
||||
|
||||
test("enable/disable call Arcane.features", () {
|
||||
DummyFeature.bar.enable();
|
||||
expect(DummyFeature.bar.enabled, isTrue);
|
||||
DummyFeature.bar.disable();
|
||||
expect(DummyFeature.bar.enabled, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import "package:arcane_framework/arcane_framework.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
enum TestFeature {
|
||||
alpha,
|
||||
beta,
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
Arcane.features.reset();
|
||||
});
|
||||
|
||||
testWidgets("ArcaneApp provides ArcaneFeatureFlagProvider", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ArcaneApp(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
expect(ArcaneFeatureFlagProvider.maybeOf(context), isNotNull);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("feature flag updates trigger rebuilds for dependent widgets",
|
||||
(tester) async {
|
||||
int buildCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ArcaneApp(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final scope = context.featureFlags;
|
||||
buildCount++;
|
||||
final bool enabled = scope.isEnabled(TestFeature.alpha);
|
||||
return Text(enabled ? "enabled" : "disabled");
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text("disabled"), findsOneWidget);
|
||||
expect(buildCount, 1);
|
||||
|
||||
Arcane.features.enableFeature(TestFeature.alpha);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text("enabled"), findsOneWidget);
|
||||
expect(buildCount, 2);
|
||||
|
||||
Arcane.features.disableFeature(TestFeature.alpha);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text("disabled"), findsOneWidget);
|
||||
expect(buildCount, 3);
|
||||
});
|
||||
|
||||
testWidgets("scope helper methods can enable and disable features",
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ArcaneApp(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final scope = context.featureFlags;
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
scope.isEnabled(TestFeature.beta) ? "on" : "off",
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => scope.enableFeature(TestFeature.beta),
|
||||
child: const Text("enable"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => scope.disableFeature(TestFeature.beta),
|
||||
child: const Text("disable"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text("off"), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text("enable"));
|
||||
await tester.pump();
|
||||
expect(find.text("on"), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text("disable"));
|
||||
await tester.pump();
|
||||
expect(find.text("off"), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("scope exposes notifier and stream for reactive consumers",
|
||||
(tester) async {
|
||||
late ArcaneFeatureFlagProvider scope;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ArcaneApp(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
scope = context.featureFlags;
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(identical(scope.notifier, Arcane.features.notifier), true);
|
||||
expect(scope.enabledFeaturesChanges, isA<Stream<List<Enum>>>());
|
||||
});
|
||||
|
||||
testWidgets("context fallback helpers work without ArcaneFeatureFlagProvider",
|
||||
(tester) async {
|
||||
Arcane.features.enableFeature(TestFeature.alpha);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
expect(context.maybeFeatureFlags, isNull);
|
||||
expect(context.isFeatureEnabled(TestFeature.alpha), isTrue);
|
||||
expect(context.isFeatureDisabled(TestFeature.beta), isTrue);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import "package:arcane_framework/arcane_framework.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
group("ArcaneFeatureFlagService", () {
|
||||
late ArcaneFeatureFlagService featureFlags;
|
||||
|
||||
setUp(() {
|
||||
featureFlags = ArcaneFeatureFlagService.I;
|
||||
Arcane.features.reset();
|
||||
});
|
||||
|
||||
test("singleton instance is consistent", () {
|
||||
expect(identical(ArcaneFeatureFlagService.I, featureFlags), true);
|
||||
});
|
||||
|
||||
group("feature management", () {
|
||||
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.notifier.addListener(() => notified = true);
|
||||
featureFlags.enableFeature(MockFeature.test);
|
||||
expect(notified, true);
|
||||
});
|
||||
|
||||
test("disableFeature notifies listeners", () {
|
||||
featureFlags.enableFeature(MockFeature.test);
|
||||
var notified = false;
|
||||
featureFlags.notifier.addListener(() => notified = true);
|
||||
featureFlags.disableFeature(MockFeature.test);
|
||||
expect(notified, true);
|
||||
});
|
||||
|
||||
test("enabledFeaturesChanges emits updates", () async {
|
||||
List<Enum>? emitted;
|
||||
final subscription =
|
||||
featureFlags.enabledFeaturesChanges.listen((features) {
|
||||
emitted = features;
|
||||
});
|
||||
|
||||
featureFlags.enableFeature(MockFeature.test);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(emitted, contains(MockFeature.test));
|
||||
|
||||
await subscription.cancel();
|
||||
});
|
||||
|
||||
test("enabledFeaturesChanges works after listener cancellation",
|
||||
() async {
|
||||
List<Enum>? firstEmission;
|
||||
final firstSubscription =
|
||||
featureFlags.enabledFeaturesChanges.listen((features) {
|
||||
firstEmission = features;
|
||||
});
|
||||
|
||||
featureFlags.enableFeature(MockFeature.test);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(firstEmission, contains(MockFeature.test));
|
||||
|
||||
await firstSubscription.cancel();
|
||||
|
||||
List<Enum>? secondEmission;
|
||||
final secondSubscription =
|
||||
featureFlags.enabledFeaturesChanges.listen((features) {
|
||||
secondEmission = features;
|
||||
});
|
||||
|
||||
featureFlags.disableFeature(MockFeature.test);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(secondEmission, isNot(contains(MockFeature.test)));
|
||||
|
||||
await secondSubscription.cancel();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
enum MockFeature {
|
||||
test,
|
||||
another,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import "package:arcane_framework/src/services/logging/logging_service.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
class DummyLogEvent extends LogEvent {
|
||||
DummyLogEvent({required super.level, required super.message});
|
||||
}
|
||||
|
||||
class DummyLoggingInterface extends LoggingInterface {
|
||||
const DummyLoggingInterface() : super();
|
||||
|
||||
@override
|
||||
void log(
|
||||
String message, {
|
||||
Map<String, Object?>? metadata,
|
||||
Level? level,
|
||||
StackTrace? stackTrace,
|
||||
Object? extra,
|
||||
}) {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group("LogInterceptor", () {
|
||||
test("calls the callback and returns the event unchanged", () {
|
||||
final interceptor = LogInterceptor((event, context) => event);
|
||||
final event = DummyLogEvent(level: Level.info, message: "test");
|
||||
const context = LogInterceptorContext();
|
||||
final result = interceptor(event, context: context);
|
||||
expect(result, equals(event));
|
||||
});
|
||||
|
||||
test("can modify the event", () {
|
||||
final interceptor = LogInterceptor((event, context) {
|
||||
return DummyLogEvent(level: Level.warning, message: event.message);
|
||||
});
|
||||
final event = DummyLogEvent(level: Level.info, message: "test");
|
||||
const context = LogInterceptorContext();
|
||||
final result = interceptor(event, context: context);
|
||||
expect(result, isA<DummyLogEvent>());
|
||||
expect(result!.level, Level.warning);
|
||||
expect(result.message, "test");
|
||||
});
|
||||
|
||||
test("can suppress the event by returning null", () {
|
||||
final interceptor = LogInterceptor((event, context) => null);
|
||||
final event = DummyLogEvent(level: Level.info, message: "test");
|
||||
const context = LogInterceptorContext();
|
||||
final result = interceptor(event, context: context);
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test("receives the correct context", () {
|
||||
const dummyInterface = DummyLoggingInterface();
|
||||
LogInterceptorContext? receivedContext;
|
||||
final interceptor = LogInterceptor((event, context) {
|
||||
receivedContext = context;
|
||||
return event;
|
||||
});
|
||||
final event = DummyLogEvent(level: Level.info, message: "test");
|
||||
const context = LogInterceptorContext(interface: dummyInterface);
|
||||
interceptor(event, context: context);
|
||||
expect(receivedContext, isNotNull);
|
||||
expect(receivedContext!.interface, dummyInterface);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import "package:arcane_framework/arcane_framework.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
class TestLoggingInterface extends LoggingInterface with LoggingInitialization {
|
||||
TestLoggingInterface(this.name);
|
||||
|
||||
final String name;
|
||||
int initCallCount = 0;
|
||||
final List<LogEvent> events = [];
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
await super.init();
|
||||
initCallCount += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
void log(
|
||||
String message, {
|
||||
Map<String, Object?>? metadata,
|
||||
Level? level,
|
||||
StackTrace? stackTrace,
|
||||
Object? extra,
|
||||
}) {
|
||||
events.add(
|
||||
LogEvent(
|
||||
message: message,
|
||||
metadata: metadata == null ? null : Map<String, Object?>.from(metadata),
|
||||
level: level,
|
||||
stackTrace: stackTrace,
|
||||
extra: extra,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TestPassiveLoggingInterface extends LoggingInterface {
|
||||
TestPassiveLoggingInterface(this.name);
|
||||
|
||||
final String name;
|
||||
final List<LogEvent> events = [];
|
||||
|
||||
@override
|
||||
void log(
|
||||
String message, {
|
||||
Map<String, Object?>? metadata,
|
||||
Level? level,
|
||||
StackTrace? stackTrace,
|
||||
Object? extra,
|
||||
}) {
|
||||
events.add(
|
||||
LogEvent(
|
||||
message: message,
|
||||
metadata: metadata == null ? null : Map<String, Object?>.from(metadata),
|
||||
level: level,
|
||||
stackTrace: stackTrace,
|
||||
extra: extra,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RedactingLogInterceptor implements LogInterceptor {
|
||||
const RedactingLogInterceptor();
|
||||
|
||||
@override
|
||||
LogEvent? call(
|
||||
LogEvent event, {
|
||||
required LogInterceptorContext context,
|
||||
}) {
|
||||
final Object? token = event.metadata?["token"];
|
||||
if (token == null) return event;
|
||||
|
||||
return event.copyWith(
|
||||
metadata: {
|
||||
...?event.metadata,
|
||||
"token": "[redacted]",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late TestLoggingInterface myInterface;
|
||||
late LogInterceptor prefixInterceptor;
|
||||
|
||||
setUp(() {
|
||||
Arcane.logger.reset();
|
||||
myInterface = TestLoggingInterface("primary");
|
||||
prefixInterceptor = LogInterceptor(
|
||||
(event, context) {
|
||||
return event.copyWith(message: "[global] ${event.message}");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group("ArcaneLogger", () {
|
||||
group("stream lifecycle", () {
|
||||
test("logStream remains usable after listener cancellation", () async {
|
||||
String? firstMessage;
|
||||
final firstSubscription = Arcane.logger.logStream.listen((message) {
|
||||
firstMessage = message;
|
||||
});
|
||||
|
||||
Arcane.log("first");
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(firstMessage, contains("first"));
|
||||
|
||||
await firstSubscription.cancel();
|
||||
|
||||
String? secondMessage;
|
||||
final secondSubscription = Arcane.logger.logStream.listen((message) {
|
||||
secondMessage = message;
|
||||
});
|
||||
|
||||
Arcane.log("second");
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(secondMessage, contains("second"));
|
||||
|
||||
await secondSubscription.cancel();
|
||||
});
|
||||
});
|
||||
|
||||
group("interface management", () {
|
||||
test("registerInterfaces adds interfaces correctly", () async {
|
||||
await Arcane.logger.registerInterface(myInterface);
|
||||
|
||||
expect(
|
||||
Arcane.logger.interfaces,
|
||||
contains(isA<LoggingInterface>()),
|
||||
);
|
||||
});
|
||||
|
||||
test("registering an interface doesn't initialize it", () async {
|
||||
await Arcane.logger.registerInterface(myInterface);
|
||||
|
||||
expect(Arcane.logger.interfaces.first, isA<LoggingInterface>());
|
||||
|
||||
expect(myInterface.initialized, false);
|
||||
expect(myInterface.initCallCount, 0);
|
||||
});
|
||||
|
||||
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(myInterface.initialized, false);
|
||||
|
||||
await Arcane.logger.initializeInterfaces();
|
||||
|
||||
expect(myInterface.initCallCount, 1);
|
||||
});
|
||||
|
||||
test("non-initializable interfaces are skipped by initializeInterfaces",
|
||||
() async {
|
||||
final TestPassiveLoggingInterface passiveInterface =
|
||||
TestPassiveLoggingInterface("passive");
|
||||
|
||||
await Arcane.logger.registerInterfaces([
|
||||
myInterface,
|
||||
passiveInterface,
|
||||
]);
|
||||
|
||||
await Arcane.logger.initializeInterfaces();
|
||||
|
||||
expect(myInterface.initCallCount, 1);
|
||||
|
||||
Arcane.log("hello");
|
||||
|
||||
expect(myInterface.events.single.message, "hello");
|
||||
expect(passiveInterface.events.single.message, "hello");
|
||||
});
|
||||
|
||||
test("multiple interfaces can be registered", () async {
|
||||
await Arcane.logger.registerInterfaces([
|
||||
TestLoggingInterface("first"),
|
||||
TestLoggingInterface("second"),
|
||||
]);
|
||||
|
||||
expect(
|
||||
Arcane.logger.interfaces,
|
||||
contains(isA<TestLoggingInterface>()),
|
||||
);
|
||||
expect(
|
||||
Arcane.logger.interfaces.length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("global interceptors can be registered at runtime", () async {
|
||||
await Arcane.logger.registerInterface(myInterface);
|
||||
|
||||
Arcane.log("before");
|
||||
Arcane.logger.registerInterceptor(prefixInterceptor);
|
||||
Arcane.log("after");
|
||||
|
||||
expect(myInterface.events[0].message, "before");
|
||||
expect(myInterface.events[1].message, "[global] after");
|
||||
});
|
||||
|
||||
test("interface interceptors can be registered at runtime", () async {
|
||||
final LogInterceptor dropForPrimary = LogInterceptor(
|
||||
(event, context) {
|
||||
if ((context.interface as TestLoggingInterface).name == "primary") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return event;
|
||||
},
|
||||
);
|
||||
|
||||
await Arcane.logger.registerInterface(myInterface);
|
||||
|
||||
Arcane.log("before");
|
||||
Arcane.logger.registerInterceptor(dropForPrimary);
|
||||
Arcane.log("blocked");
|
||||
Arcane.logger.unregisterInterceptor(dropForPrimary);
|
||||
Arcane.log("after");
|
||||
|
||||
expect(
|
||||
myInterface.events.map((LogEvent event) => event.message),
|
||||
["before", "after"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(myInterface.events.single.message, logMessage);
|
||||
});
|
||||
|
||||
test("logging at a different level works", () async {
|
||||
Arcane.log(
|
||||
logMessage,
|
||||
level: Level.info,
|
||||
);
|
||||
|
||||
expect(myInterface.events.last.level, Level.info);
|
||||
|
||||
Arcane.log(
|
||||
logMessage,
|
||||
level: Level.warning,
|
||||
);
|
||||
|
||||
expect(myInterface.events.last.level, Level.warning);
|
||||
});
|
||||
|
||||
test("logging a stacktrace works", () async {
|
||||
final stackTrace = StackTrace.current;
|
||||
Arcane.log(logMessage, stackTrace: stackTrace);
|
||||
|
||||
expect(myInterface.events.single.stackTrace, stackTrace);
|
||||
});
|
||||
|
||||
test("logging an extra object works", () async {
|
||||
const bool extraObject = true;
|
||||
Arcane.log(
|
||||
logMessage,
|
||||
extra: extraObject,
|
||||
);
|
||||
|
||||
expect(myInterface.events.single.extra, extraObject);
|
||||
});
|
||||
|
||||
test("logging metadata works", () async {
|
||||
final Map<String, String> metadata = {"test": "value"};
|
||||
Arcane.log(
|
||||
logMessage,
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
expect(myInterface.events.single.metadata?["test"], "value");
|
||||
expect(
|
||||
myInterface.events.single.metadata?.containsKey("timestamp"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("global interceptors run in registration order", () async {
|
||||
Arcane.logger.registerInterceptors([
|
||||
LogInterceptor((event, context) {
|
||||
expect(context.interface, same(myInterface));
|
||||
return event.copyWith(message: "${event.message}:first");
|
||||
}),
|
||||
LogInterceptor((event, context) {
|
||||
expect(context.interface, same(myInterface));
|
||||
return event.copyWith(message: "${event.message}:second");
|
||||
}),
|
||||
]);
|
||||
|
||||
Arcane.log(logMessage);
|
||||
|
||||
expect(myInterface.events.single.message, "Test:first:second");
|
||||
});
|
||||
|
||||
test("global interceptors can drop events for all interfaces", () async {
|
||||
await Arcane.logger.registerInterface(myInterface);
|
||||
Arcane.logger.registerInterceptor(
|
||||
LogInterceptor((event, context) => null),
|
||||
);
|
||||
|
||||
Arcane.log(logMessage);
|
||||
|
||||
expect(myInterface.events, isEmpty);
|
||||
});
|
||||
|
||||
test("custom interceptor classes can implement LogInterceptor", () async {
|
||||
Arcane.logger.registerInterceptor(const RedactingLogInterceptor());
|
||||
|
||||
Arcane.log(
|
||||
logMessage,
|
||||
metadata: {"token": "secret-token"},
|
||||
);
|
||||
|
||||
expect(
|
||||
myInterface.events.single.metadata?["token"],
|
||||
"[redacted]",
|
||||
);
|
||||
});
|
||||
|
||||
test("interface interceptors can drop events per destination", () async {
|
||||
final TestLoggingInterface secondaryInterface =
|
||||
TestLoggingInterface("secondary");
|
||||
final LogInterceptor allowPrimaryOnly = LogInterceptor(
|
||||
(event, context) {
|
||||
final String name =
|
||||
(context.interface as TestLoggingInterface).name;
|
||||
return name == "primary" ? event : null;
|
||||
},
|
||||
);
|
||||
|
||||
Arcane.logger.registerInterceptor(allowPrimaryOnly);
|
||||
await Arcane.logger.registerInterface(
|
||||
secondaryInterface,
|
||||
);
|
||||
|
||||
Arcane.log(logMessage);
|
||||
|
||||
expect(myInterface.events.single.message, logMessage);
|
||||
expect(secondaryInterface.events, isEmpty);
|
||||
});
|
||||
|
||||
test("interface interceptors receive the current interface", () async {
|
||||
final TestLoggingInterface secondaryInterface =
|
||||
TestLoggingInterface("secondary");
|
||||
|
||||
Arcane.logger.registerInterceptor(
|
||||
LogInterceptor((event, context) {
|
||||
final TestLoggingInterface currentInterface =
|
||||
context.interface! as TestLoggingInterface;
|
||||
return event.copyWith(
|
||||
metadata: {
|
||||
...?event.metadata,
|
||||
"target": currentInterface.name,
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
await Arcane.logger.registerInterface(
|
||||
secondaryInterface,
|
||||
);
|
||||
|
||||
Arcane.log(logMessage);
|
||||
|
||||
expect(myInterface.events.single.metadata?["target"], "primary");
|
||||
expect(
|
||||
secondaryInterface.events.single.metadata?["target"],
|
||||
"secondary",
|
||||
);
|
||||
});
|
||||
|
||||
test("interface interceptors cannot mutate sibling interface events",
|
||||
() async {
|
||||
final TestLoggingInterface secondaryInterface =
|
||||
TestLoggingInterface("secondary");
|
||||
|
||||
Arcane.logger.registerInterceptor(
|
||||
LogInterceptor((event, context) {
|
||||
event.metadata?["mutatedBy"] =
|
||||
(context.interface as TestLoggingInterface).name;
|
||||
return event;
|
||||
}),
|
||||
);
|
||||
await Arcane.logger.registerInterface(
|
||||
secondaryInterface,
|
||||
);
|
||||
|
||||
Arcane.log(
|
||||
logMessage,
|
||||
metadata: {"test": "value"},
|
||||
);
|
||||
|
||||
expect(myInterface.events.single.metadata?["mutatedBy"], "primary");
|
||||
expect(
|
||||
secondaryInterface.events.single.metadata?["mutatedBy"],
|
||||
"secondary",
|
||||
);
|
||||
});
|
||||
|
||||
test("unregistering an interface clears registration interceptors",
|
||||
() async {
|
||||
final TestLoggingInterface secondaryInterface =
|
||||
TestLoggingInterface("secondary");
|
||||
|
||||
await Arcane.logger.unregisterInterface(myInterface);
|
||||
myInterface = TestLoggingInterface("primary-with-drop");
|
||||
await Arcane.logger.registerInterface(
|
||||
myInterface,
|
||||
interceptors: [
|
||||
LogInterceptor((event, context) => null),
|
||||
],
|
||||
);
|
||||
|
||||
await Arcane.logger.unregisterInterface(myInterface);
|
||||
await Arcane.logger.registerInterface(secondaryInterface);
|
||||
|
||||
Arcane.log(logMessage);
|
||||
|
||||
expect(myInterface.events, isEmpty);
|
||||
expect(secondaryInterface.events.single.message, logMessage);
|
||||
});
|
||||
|
||||
test("reset clears global interceptors", () async {
|
||||
Arcane.logger.registerInterceptor(prefixInterceptor);
|
||||
Arcane.logger.reset();
|
||||
await Arcane.logger.registerInterface(myInterface);
|
||||
|
||||
Arcane.log(logMessage);
|
||||
|
||||
expect(myInterface.events.single.message, logMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
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;
|
||||
Arcane.theme.reset();
|
||||
});
|
||||
|
||||
test("singleton instance is consistent", () {
|
||||
expect(identical(ArcaneReactiveTheme.I, theme), true);
|
||||
});
|
||||
|
||||
group("theme mode", () {
|
||||
test("initial mode is light", () {
|
||||
expect(theme.currentThemeMode, equals(ThemeMode.light));
|
||||
});
|
||||
|
||||
test("switchTheme toggles between light and dark", () {
|
||||
expect(theme.currentThemeMode, equals(ThemeMode.light));
|
||||
theme.switchTheme();
|
||||
expect(theme.currentThemeMode, equals(ThemeMode.dark));
|
||||
theme.switchTheme();
|
||||
expect(theme.currentThemeMode, equals(ThemeMode.light));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
"switchTheme toggles from effective system mode, not always to dark",
|
||||
(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MediaQuery(
|
||||
data: MediaQueryData(platformBrightness: Brightness.dark),
|
||||
child: ArcaneApp(
|
||||
child: SizedBox(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final BuildContext darkContext = tester.element(find.byType(SizedBox));
|
||||
theme.switchTheme(themeMode: ThemeMode.system);
|
||||
theme.setInitialTheme(darkContext);
|
||||
theme.switchTheme();
|
||||
|
||||
expect(theme.currentThemeMode, equals(ThemeMode.light));
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MediaQuery(
|
||||
data: MediaQueryData(platformBrightness: Brightness.light),
|
||||
child: ArcaneApp(
|
||||
child: SizedBox(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final BuildContext lightContext = tester.element(find.byType(SizedBox));
|
||||
theme.switchTheme(themeMode: ThemeMode.system);
|
||||
theme.setInitialTheme(lightContext);
|
||||
theme.switchTheme();
|
||||
|
||||
expect(theme.currentThemeMode, equals(ThemeMode.dark));
|
||||
});
|
||||
|
||||
test("switching theme notifies theme mode stream", () async {
|
||||
ThemeMode? emittedMode;
|
||||
final subscription = theme.themeModeChanges.listen((mode) {
|
||||
emittedMode = mode;
|
||||
});
|
||||
|
||||
theme.switchTheme();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(emittedMode, equals(theme.currentThemeMode));
|
||||
await subscription.cancel();
|
||||
});
|
||||
|
||||
test("theme mode stream works after listener cancellation", () async {
|
||||
ThemeMode? firstEmission;
|
||||
final firstSubscription = theme.themeModeChanges.listen((mode) {
|
||||
firstEmission = mode;
|
||||
});
|
||||
|
||||
theme.switchTheme();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(firstEmission, ThemeMode.dark);
|
||||
|
||||
await firstSubscription.cancel();
|
||||
|
||||
ThemeMode? secondEmission;
|
||||
final secondSubscription = theme.themeModeChanges.listen((mode) {
|
||||
secondEmission = mode;
|
||||
});
|
||||
|
||||
theme.switchTheme();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(secondEmission, ThemeMode.light);
|
||||
|
||||
await secondSubscription.cancel();
|
||||
});
|
||||
});
|
||||
|
||||
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 notifier and streams", () async {
|
||||
bool darkNotified = false;
|
||||
bool lightNotified = false;
|
||||
ThemeMode? emittedMode;
|
||||
ThemeData? emittedThemeData;
|
||||
|
||||
final modeSubscription = theme.themeModeChanges.listen((mode) {
|
||||
emittedMode = mode;
|
||||
});
|
||||
|
||||
final dataSubscription = theme.themeDataChanges.listen((themeData) {
|
||||
emittedThemeData = themeData;
|
||||
});
|
||||
|
||||
theme.darkTheme.addListener(() {
|
||||
darkNotified = true;
|
||||
});
|
||||
|
||||
theme.lightTheme.addListener(() {
|
||||
lightNotified = true;
|
||||
});
|
||||
|
||||
final darkTheme = ThemeData.dark().copyWith(
|
||||
primaryColor: Colors.teal,
|
||||
);
|
||||
final lightTheme = ThemeData.light().copyWith(
|
||||
primaryColor: Colors.amber,
|
||||
);
|
||||
|
||||
theme.setDarkTheme(darkTheme);
|
||||
theme.setLightTheme(lightTheme);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(darkNotified, true);
|
||||
expect(lightNotified, true);
|
||||
expect(emittedThemeData, isNotNull);
|
||||
|
||||
theme.switchTheme();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(theme.currentThemeMode, ThemeMode.dark);
|
||||
expect(emittedMode, ThemeMode.dark);
|
||||
|
||||
theme.switchTheme();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(theme.currentThemeMode, ThemeMode.light);
|
||||
expect(emittedMode, ThemeMode.light);
|
||||
|
||||
await modeSubscription.cancel();
|
||||
await dataSubscription.cancel();
|
||||
});
|
||||
});
|
||||
|
||||
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: ArcaneApp(
|
||||
child: SizedBox(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final BuildContext lightContext = tester.element(find.byType(SizedBox));
|
||||
Arcane.theme.followSystemTheme(lightContext);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(theme.currentThemeMode, equals(ThemeMode.light));
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MediaQuery(
|
||||
data: MediaQueryData(platformBrightness: Brightness.dark),
|
||||
child: ArcaneApp(
|
||||
child: SizedBox(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final BuildContext darkContext = tester.element(find.byType(SizedBox));
|
||||
Arcane.theme.followSystemTheme(darkContext);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(theme.currentThemeMode, equals(ThemeMode.dark));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import "package:arcane_framework/arcane_framework.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
group("ArcaneThemeService regression", () {
|
||||
setUp(() {
|
||||
Arcane.theme.reset();
|
||||
});
|
||||
|
||||
test("setDarkTheme does not update rendered theme if not in dark mode", () {
|
||||
// Start in light mode
|
||||
expect(Arcane.theme.currentThemeMode, ThemeMode.light);
|
||||
final originalTheme = Arcane.theme.currentTheme;
|
||||
final darkTheme = ThemeData.dark().copyWith(primaryColor: Colors.purple);
|
||||
Arcane.theme.setDarkTheme(darkTheme);
|
||||
// Should not update rendered theme
|
||||
expect(Arcane.theme.currentTheme, originalTheme);
|
||||
expect(Arcane.theme.dark.primaryColor, Colors.purple);
|
||||
});
|
||||
|
||||
test("setLightTheme does not update rendered theme if not in light mode",
|
||||
() {
|
||||
Arcane.theme.switchTheme(themeMode: ThemeMode.dark);
|
||||
expect(Arcane.theme.currentThemeMode, ThemeMode.dark);
|
||||
final originalTheme = Arcane.theme.currentTheme;
|
||||
final lightTheme =
|
||||
ThemeData.light().copyWith(primaryColor: Colors.orange);
|
||||
Arcane.theme.setLightTheme(lightTheme);
|
||||
// Should not update rendered theme
|
||||
expect(Arcane.theme.currentTheme, originalTheme);
|
||||
expect(Arcane.theme.light.primaryColor, Colors.orange);
|
||||
});
|
||||
|
||||
test("setDarkTheme updates rendered theme if in dark mode", () {
|
||||
Arcane.theme.switchTheme(themeMode: ThemeMode.dark);
|
||||
expect(Arcane.theme.currentThemeMode, ThemeMode.dark);
|
||||
final darkTheme = ThemeData.dark().copyWith(primaryColor: Colors.green);
|
||||
Arcane.theme.setDarkTheme(darkTheme);
|
||||
expect(Arcane.theme.currentTheme, darkTheme);
|
||||
});
|
||||
|
||||
test("setLightTheme updates rendered theme if in light mode", () {
|
||||
expect(Arcane.theme.currentThemeMode, ThemeMode.light);
|
||||
final lightTheme = ThemeData.light().copyWith(primaryColor: Colors.blue);
|
||||
Arcane.theme.setLightTheme(lightTheme);
|
||||
expect(Arcane.theme.currentTheme, lightTheme);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import "package:arcane_framework/src/services/theme/theme_extensions.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
testWidgets("isDarkMode returns true for dark theme", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
expect(context.isDarkMode, isTrue);
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("isDarkMode returns false for light theme", (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData.light(),
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
expect(context.isDarkMode, isFalse);
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import "package:arcane_framework/src/services/theme/theme_service.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
|
||||
void main() {
|
||||
group("ArcaneThemeService", () {
|
||||
setUp(() {
|
||||
ArcaneThemeService.I.reset();
|
||||
});
|
||||
|
||||
test("default state is light theme, not following system", () {
|
||||
expect(ArcaneThemeService.I.isFollowingSystemTheme, isFalse);
|
||||
expect(ArcaneThemeService.I.currentThemeMode, ThemeMode.light);
|
||||
expect(ArcaneThemeService.I.currentTheme, isA<ThemeData>());
|
||||
});
|
||||
|
||||
test("switchTheme toggles between light and dark", () {
|
||||
ArcaneThemeService.I.switchTheme(themeMode: ThemeMode.light);
|
||||
expect(ArcaneThemeService.I.currentThemeMode, ThemeMode.light);
|
||||
ArcaneThemeService.I.switchTheme();
|
||||
expect(ArcaneThemeService.I.currentThemeMode, ThemeMode.dark);
|
||||
ArcaneThemeService.I.switchTheme();
|
||||
expect(ArcaneThemeService.I.currentThemeMode, ThemeMode.light);
|
||||
});
|
||||
|
||||
test("setDarkTheme and setLightTheme update themes", () {
|
||||
final customDark =
|
||||
ThemeData(primaryColor: Colors.red, brightness: Brightness.dark);
|
||||
final customLight =
|
||||
ThemeData(primaryColor: Colors.blue, brightness: Brightness.light);
|
||||
ArcaneThemeService.I.setDarkTheme(customDark);
|
||||
expect(ArcaneThemeService.I.dark, customDark);
|
||||
ArcaneThemeService.I.setLightTheme(customLight);
|
||||
expect(ArcaneThemeService.I.light, customLight);
|
||||
});
|
||||
|
||||
test("reset restores defaults", () {
|
||||
ArcaneThemeService.I.setDarkTheme(
|
||||
ThemeData(primaryColor: Colors.red, brightness: Brightness.dark),);
|
||||
ArcaneThemeService.I.setLightTheme(
|
||||
ThemeData(primaryColor: Colors.blue, brightness: Brightness.light),);
|
||||
ArcaneThemeService.I.reset();
|
||||
expect(ArcaneThemeService.I.dark, ThemeData.dark());
|
||||
expect(ArcaneThemeService.I.light, ThemeData.light());
|
||||
expect(ArcaneThemeService.I.isFollowingSystemTheme, isFalse);
|
||||
});
|
||||
|
||||
test("dispose does not throw", () {
|
||||
ArcaneThemeService.I.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user