v2.0.6: Remove export of Error and Ok from result_monad (#12)

* Update Logging feature to replace @LoggingFeature annotation with LoggerName mixin for runtime accessibility

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Bump version to 2.0.2 in pubspec.yaml and update CHANGELOG to reflect the new version

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Add migration steps for replacing @LoggingFeature annotation with LoggerName mixin in CHANGELOG

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Fix class declaration in migration example for LoggerName mixin

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Enhance CI workflow, coverage enforcement, and environment tests (#10)

* Enhance CI workflow and coverage enforcement; update tests and add new environment tests

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Revert version bump in pubspec.yaml and remove test coverage note from CHANGELOG

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Update test to expect a thrown exception when no interfaces are registered

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Add ArcaneEnvironmentModeController and related tests for debug mode management

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* Refactor theme switcher tests to use platform dispatcher for brightness change simulation

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

---------

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>

* refactor(api): Remove export of Error and Ok from result_monad (#11)

The `Error` and `Ok` symbols from the `result_monad` package are no longer
re-exported directly by `arcane_framework`. Consumers needing these
symbols must now explicitly import them from `package:result_monad`.
This clarifies dependency ownership and reduces `arcane_framework`'s
public API surface.

---------

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-06-19 20:28:24 +02:00
committed by GitHub
parent cbb1a78473
commit 0078e344fd
23 changed files with 1294 additions and 12 deletions
+17
View File
@@ -1,4 +1,5 @@
import "package:arcane_framework/arcane_framework.dart";
import "package:flutter/foundation.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
@@ -10,6 +11,10 @@ void main() {
});
group("Arcane", () {
tearDown(() {
Arcane.clearRegistry();
});
test("services getter returns all core services", () {
final services = Arcane.services;
expect(services, contains(isA<ArcaneFeatureFlagService>()));
@@ -17,5 +22,17 @@ void main() {
expect(services, contains(isA<ArcaneReactiveTheme>()));
expect(services, contains(isA<ArcaneEnvironmentService>()));
});
test(
"fallback getters return singletons when registry is present but empty",
() {
final notifier = ValueNotifier<List<ArcaneService>>(<ArcaneService>[]);
Arcane.setRegistry(notifier);
expect(Arcane.features, same(ArcaneFeatureFlagService.I));
expect(Arcane.auth, same(ArcaneAuthenticationService.I));
expect(Arcane.theme, same(ArcaneThemeService.I));
expect(Arcane.environment, same(ArcaneEnvironmentService.I));
});
});
}
+146
View File
@@ -286,6 +286,120 @@ void main() {
expect(buildCount, 3);
});
testWidgets("maybeOf returns null when provider is missing",
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
expect(ArcaneServiceProvider.maybeOf(context), isNull);
return const SizedBox();
},
),
),
);
});
testWidgets("of asserts when provider is missing", (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
expect(
() => ArcaneServiceProvider.of(context),
throwsA(isA<AssertionError>()),
);
return const SizedBox();
},
),
),
);
});
testWidgets(
"requiredServiceOfType returns service and asserts when missing",
(tester) async {
await tester.pumpWidget(
ArcaneApp(
services: testServices,
child: Builder(
builder: (context) {
final service = ArcaneServiceProvider.requiredServiceOfType<
MockArcaneService>(context);
expect(service, isA<MockArcaneService>());
expect(
() => ArcaneServiceProvider.requiredServiceOfType<
UnregisteredService>(context),
throwsA(isA<AssertionError>()),
);
return const SizedBox();
},
),
),
);
});
testWidgets("addService replaces same runtime type", (tester) async {
late ArcaneServiceProvider provider;
final first = MockArcaneService();
final replacement = MockArcaneService();
await tester.pumpWidget(
MaterialApp(
home: ArcaneServiceProvider(
serviceInstances: [first],
child: Builder(
builder: (context) {
provider = ArcaneServiceProvider.of(context);
return const SizedBox();
},
),
),
),
);
provider.addService(replacement);
await tester.pump();
final matches =
provider.registeredServices.whereType<MockArcaneService>();
expect(matches.length, 1);
expect(matches.single, same(replacement));
});
testWidgets("removeService removes matching type and reports status",
(tester) async {
late ArcaneServiceProvider provider;
await tester.pumpWidget(
MaterialApp(
home: ArcaneServiceProvider(
serviceInstances: [MockArcaneService(), AnotherMockService()],
child: Builder(
builder: (context) {
provider = ArcaneServiceProvider.of(context);
return const SizedBox();
},
),
),
),
);
final removed = provider.removeService<MockArcaneService>();
await tester.pump();
expect(removed, isTrue);
expect(
provider.registeredServices.whereType<MockArcaneService>(),
isEmpty,
);
final secondRemoval = provider.removeService<MockArcaneService>();
expect(secondRemoval, isFalse);
});
testWidgets("ArcaneService.of<T> static helper works", (tester) async {
await tester.pumpWidget(
ArcaneApp(
@@ -371,6 +485,38 @@ void main() {
),
);
});
testWidgets(
"ArcaneApp.didUpdateWidget updates notifier when service list changes",
(tester) async {
final first = MockArcaneService();
final second = AnotherMockService();
late StateSetter setStateRef;
var useSecond = false;
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
setStateRef = setState;
return ArcaneApp(
services: useSecond ? [first, second] : [first],
child: const SizedBox(),
);
},
),
);
// Trigger didUpdateWidget with a different service list.
setStateRef(() => useSecond = true);
await tester.pump();
// Verify AnotherMockService is now in the merged service list.
final notifier = Arcane.registry!;
expect(
notifier.value.whereType<AnotherMockService>(),
isNotEmpty,
);
});
});
}
@@ -33,6 +33,32 @@ class MockAuth implements ArcaneAuthInterface {
}
}
class MinimalAuth extends ArcaneAuthInterface {
@override
Future<bool> get isSignedIn => Future.value(false);
@override
Future<String?>? get accessToken => Future.value(null);
@override
Future<String?>? get refreshToken => Future.value(null);
@override
Future<Result<void, String>> login<T>({
T? input,
Future<void> Function()? onLoggedIn,
}) async {
return const Result.ok(null);
}
@override
Future<Result<void, String>> logout({
Future<void> Function()? onLoggedOut,
}) async {
return const Result.ok(null);
}
}
void main() {
test("MockAuth fulfills ArcaneAuthInterface contract", () async {
final auth = MockAuth();
@@ -47,4 +73,9 @@ void main() {
);
expect(called, isTrue);
});
test("default init implementation completes", () async {
final auth = MinimalAuth();
await auth.init();
});
}
@@ -221,6 +221,53 @@ void main() {
ArcaneAuthenticationService.I.dispose();
// No assertion needed; just ensure no crash
});
test("notifier getter reflects unauthenticated default", () {
expect(
ArcaneAuthenticationService.I.notifier.value,
AuthenticationStatus.unauthenticated,
);
});
test("accessToken and refreshToken fall back to empty string", () async {
expect(await ArcaneAuthenticationService.I.accessToken, "");
expect(await ArcaneAuthenticationService.I.refreshToken, "");
});
testWidgets("setDebug and setNormal callbacks run on state transitions",
(tester) async {
late BuildContext capturedContext;
var debugCallbackCalled = false;
var normalCallbackCalled = false;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
capturedContext = context;
return const SizedBox();
},
),
),
);
await ArcaneAuthenticationService.I.setDebug(
capturedContext,
onDebugModeSet: () async {
debugCallbackCalled = true;
},
);
await ArcaneAuthenticationService.I.setNormal(
capturedContext,
onDebugModeUnset: () async {
normalCallbackCalled = true;
},
);
expect(debugCallbackCalled, isTrue);
expect(normalCallbackCalled, isTrue);
});
});
late ArcaneAuthInterface authInterface;
@@ -0,0 +1,38 @@
import "package:arcane_framework/arcane_framework.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("Environment", () {
test("built-in values have expected names", () {
expect(Environment.debug.name, "debug");
expect(Environment.normal.name, "normal");
});
test("isDebug and isNormal reflect built-in values", () {
expect(Environment.debug.isDebug, isTrue);
expect(Environment.debug.isNormal, isFalse);
expect(Environment.normal.isNormal, isTrue);
expect(Environment.normal.isDebug, isFalse);
const staging = Environment("staging");
expect(staging.isDebug, isFalse);
expect(staging.isNormal, isFalse);
});
test("equality and hashCode are name-based", () {
const first = Environment("staging");
const second = Environment("staging");
const prod = Environment("prod");
expect(first, second);
expect(first.hashCode, second.hashCode);
expect(first, isNot(prod));
});
test("toString includes environment name", () {
const staging = Environment("staging");
expect(staging.toString(), "Environment(staging)");
});
});
}
@@ -0,0 +1,224 @@
import "package:arcane_framework/arcane_framework.dart";
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("ArcaneEnvironment", () {
setUp(() {
Arcane.environment.reset();
});
testWidgets("maybeOf returns null without provider", (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
expect(ArcaneEnvironment.maybeOf(context), isNull);
return const SizedBox();
},
),
),
);
});
testWidgets("of throws without provider", (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
expect(
() => ArcaneEnvironment.of(context),
throwsA(isA<StateError>()),
);
return const SizedBox();
},
),
),
);
});
test("updateShouldNotify changes only when environment changes", () {
final normalA = ArcaneEnvironment(
environment: Environment.normal,
switchEnvironment: (_) {},
child: const SizedBox(),
);
final normalB = ArcaneEnvironment(
environment: Environment.normal,
switchEnvironment: (_) {},
child: const SizedBox(),
);
final debug = ArcaneEnvironment(
environment: Environment.debug,
switchEnvironment: (_) {},
child: const SizedBox(),
);
expect(normalA.updateShouldNotify(normalB), isFalse);
expect(debug.updateShouldNotify(normalB), isTrue);
});
});
group("ArcaneEnvironmentProvider", () {
setUp(() {
Arcane.environment.reset();
});
testWidgets("uses widget initial environment on init", (tester) async {
await tester.pumpWidget(
MaterialApp(
home: ArcaneEnvironmentProvider(
environment: Environment.debug,
child: Builder(
builder: (context) {
final env = ArcaneEnvironment.of(context);
expect(env.environment, Environment.debug);
expect(env.current, Environment.debug);
return const SizedBox();
},
),
),
),
);
});
testWidgets("setEnvironment updates service and inherited widget",
(tester) async {
late BuildContext capturedContext;
await tester.pumpWidget(
MaterialApp(
home: ArcaneEnvironmentProvider(
child: Builder(
builder: (context) {
capturedContext = context;
return const SizedBox();
},
),
),
),
);
ArcaneEnvironment.of(capturedContext).setEnvironment(Environment.debug);
await tester.pump();
expect(Arcane.environment.current, Environment.debug);
});
testWidgets("enableDebugMode and disableDebugMode proxy correctly",
(tester) async {
late BuildContext capturedContext;
await tester.pumpWidget(
MaterialApp(
home: ArcaneEnvironmentProvider(
child: Builder(
builder: (context) {
capturedContext = context;
return const SizedBox();
},
),
),
),
);
ArcaneEnvironment.of(capturedContext).enableDebugMode();
await tester.pump();
expect(Arcane.environment.current, Environment.debug);
ArcaneEnvironment.of(capturedContext).disableDebugMode();
await tester.pump();
expect(Arcane.environment.current, Environment.normal);
});
testWidgets("provider rebuilds when service environment changes",
(tester) async {
var buildCount = 0;
await tester.pumpWidget(
MaterialApp(
home: ArcaneEnvironmentProvider(
child: Builder(
builder: (context) {
buildCount++;
// ignore: unnecessary_statements
ArcaneEnvironment.of(context).environment;
return const SizedBox();
},
),
),
),
);
expect(buildCount, 1);
Arcane.environment.setEnvironment(Environment.debug);
await tester.pump();
expect(buildCount, 2);
});
testWidgets(
"state enableDebugMode updates to debug and no-ops when already debug",
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: ArcaneEnvironmentProvider(
child: Builder(
builder: (context) {
return const SizedBox();
},
),
),
),
);
final ArcaneEnvironmentModeController state =
tester.state<State<ArcaneEnvironmentProvider>>(
find.byType(ArcaneEnvironmentProvider),
) as ArcaneEnvironmentModeController;
expect(Arcane.environment.current, Environment.normal);
state.enableDebugMode();
await tester.pump();
expect(Arcane.environment.current, Environment.debug);
state.enableDebugMode();
await tester.pump();
expect(Arcane.environment.current, Environment.debug);
},
);
testWidgets(
"state disableDebugMode updates to normal and no-ops when already normal",
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: ArcaneEnvironmentProvider(
environment: Environment.debug,
child: Builder(
builder: (context) {
return const SizedBox();
},
),
),
),
);
final ArcaneEnvironmentModeController state =
tester.state<State<ArcaneEnvironmentProvider>>(
find.byType(ArcaneEnvironmentProvider),
) as ArcaneEnvironmentModeController;
expect(Arcane.environment.current, Environment.debug);
state.disableDebugMode();
await tester.pump();
expect(Arcane.environment.current, Environment.normal);
state.disableDebugMode();
await tester.pump();
expect(Arcane.environment.current, Environment.normal);
},
);
});
}
@@ -0,0 +1,107 @@
import "package:arcane_framework/arcane_framework.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("ArcaneEnvironmentService", () {
late ArcaneEnvironmentService service;
setUp(() {
service = ArcaneEnvironmentService.I;
service.reset();
});
test("singleton instance is consistent", () {
expect(identical(ArcaneEnvironmentService.I, service), isTrue);
expect(identical(Arcane.environment, service), isTrue);
});
test("default environment is normal", () {
expect(service.current, Environment.normal);
});
test("setEnvironment updates current value", () {
service.setEnvironment(Environment.debug);
expect(service.current, Environment.debug);
});
test("setEnvironment with same value does not emit stream event", () async {
var didEmit = false;
final sub = service.environmentChanges.listen((_) {
didEmit = true;
});
service.setEnvironment(Environment.normal);
await Future<void>.delayed(Duration.zero);
expect(didEmit, isFalse);
await sub.cancel();
});
test("setEnvironment with different value emits stream event", () async {
final event = expectLater(
service.environmentChanges,
emits(Environment.debug),
);
service.setEnvironment(Environment.debug);
await event;
});
test("notifier listeners are notified on changes", () {
var notified = false;
service.notifier.addListener(() {
notified = true;
});
service.setEnvironment(Environment.debug);
expect(notified, isTrue);
});
test("enableDebugMode and disableDebugMode switch built-in modes", () {
service.enableDebugMode();
expect(service.current, Environment.debug);
service.disableDebugMode();
expect(service.current, Environment.normal);
});
test("reset restores normal and emits current state", () async {
service.setEnvironment(Environment.debug);
final event = expectLater(
service.environmentChanges,
emits(Environment.normal),
);
service.reset();
expect(service.current, Environment.normal);
await event;
});
test("environmentChanges works after listener cancellation", () async {
final first = service.environmentChanges.listen((_) {});
await first.cancel();
final event = expectLater(
service.environmentChanges,
emits(Environment.debug),
);
service.setEnvironment(Environment.debug);
await event;
});
test("dispose closes stream and subsequent reads recreate it", () async {
service.dispose();
final event = expectLater(
service.environmentChanges,
emits(Environment.debug),
);
service.setEnvironment(Environment.debug);
await event;
});
});
}
@@ -27,6 +27,23 @@ void main() {
);
});
testWidgets("ArcaneFeatureFlagProvider.of throws without provider",
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
expect(
() => ArcaneFeatureFlagProvider.of(context),
throwsA(isA<StateError>()),
);
return const SizedBox();
},
),
),
);
});
testWidgets("feature flag updates trigger rebuilds for dependent widgets",
(tester) async {
int buildCount = 0;
@@ -100,6 +117,20 @@ void main() {
await tester.tap(find.text("disable"));
await tester.pump();
expect(find.text("off"), findsOneWidget);
await tester.pumpWidget(
MaterialApp(
home: ArcaneApp(
child: Builder(
builder: (context) {
final scope = context.featureFlags;
expect(scope.isDisabled(TestFeature.beta), isTrue);
return const SizedBox();
},
),
),
),
);
});
testWidgets("scope exposes notifier and stream for reactive consumers",
@@ -1,6 +1,29 @@
import "package:arcane_framework/arcane_framework.dart";
import "package:flutter_test/flutter_test.dart";
class FeatureFlagLoggingInterface extends LoggingInterface {
final List<LogEvent> events = <LogEvent>[];
@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,
),
);
}
}
void main() {
group("ArcaneFeatureFlagService", () {
late ArcaneFeatureFlagService featureFlags;
@@ -8,12 +31,21 @@ void main() {
setUp(() {
featureFlags = ArcaneFeatureFlagService.I;
Arcane.features.reset();
Arcane.logger.reset();
});
test("singleton instance is consistent", () {
expect(identical(ArcaneFeatureFlagService.I, featureFlags), true);
});
test("initialized reflects init and reset transitions", () {
expect(ArcaneFeatureFlagService.initialized, isFalse);
featureFlags.enableFeature(MockFeature.test);
expect(ArcaneFeatureFlagService.initialized, isTrue);
featureFlags.reset();
expect(ArcaneFeatureFlagService.initialized, isFalse);
});
group("feature management", () {
test("enableFeature adds feature to enabled list", () {
featureFlags.enableFeature(MockFeature.test);
@@ -99,6 +131,50 @@ void main() {
await secondSubscription.cancel();
});
test("enableFeature logs when logger is initialized", () async {
final logger = FeatureFlagLoggingInterface();
await Arcane.logger.registerInterface(logger);
featureFlags.enableFeature(MockFeature.test);
expect(logger.events, isNotEmpty);
expect(logger.events.last.message, contains("Feature enabled"));
expect(logger.events.last.level, Level.info);
expect(
logger.events.last.metadata?[MockFeature.test.toString()],
"",
);
});
test("disableFeature logs when logger is initialized", () async {
final logger = FeatureFlagLoggingInterface();
await Arcane.logger.registerInterface(logger);
featureFlags.enableFeature(MockFeature.test);
featureFlags.disableFeature(MockFeature.test);
expect(logger.events, isNotEmpty);
expect(logger.events.last.message, contains("Feature disabled"));
expect(logger.events.last.level, Level.info);
expect(
logger.events.last.metadata?[MockFeature.test.toString()],
"",
);
});
test("dispose closes stream and future subscribers still receive events",
() async {
featureFlags.dispose();
final event = expectLater(
featureFlags.enabledFeaturesChanges,
emitsThrough(contains(MockFeature.another)),
);
featureFlags.enableFeature(MockFeature.another);
await event;
});
});
});
}
+157
View File
@@ -0,0 +1,157 @@
import "package:arcane_framework/arcane_framework.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("LogEvent", () {
test("constructor stores provided values", () {
final event = LogEvent(
message: "with-data",
metadata: {
"flag": true,
"items": [1, 2, 3],
},
level: Level.warning,
stackTrace: StackTrace.fromString("trace-here"),
extra: {
"child": ["x"],
},
);
expect(event.message, "with-data");
expect(event.metadata?["flag"], isTrue);
expect(event.metadata?["items"], [1, 2, 3]);
expect(event.level, Level.warning);
expect(event.stackTrace.toString(), "trace-here");
expect(event.extra, {
"child": ["x"],
});
});
test("constructor leaves optional fields null when omitted", () {
const event = LogEvent(message: "plain");
expect(event.message, "plain");
expect(event.metadata, isNull);
expect(event.level, isNull);
expect(event.stackTrace, isNull);
expect(event.extra, isNull);
});
test("copyWith keeps existing values when sentinel defaults are used", () {
final original = LogEvent(
message: "original",
metadata: {"a": 1},
level: Level.info,
stackTrace: StackTrace.fromString("trace"),
extra: "extra",
);
final copy = original.copyWith();
expect(copy.message, "original");
expect(copy.metadata, original.metadata);
expect(copy.level, Level.info);
expect(copy.stackTrace, original.stackTrace);
expect(copy.extra, "extra");
});
test("copyWith supports explicit nulling of nullable fields", () {
final original = LogEvent(
message: "original",
metadata: {"a": 1},
level: Level.error,
stackTrace: StackTrace.fromString("trace"),
extra: 99,
);
final cleared = original.copyWith(
metadata: null,
level: null,
stackTrace: null,
extra: null,
);
expect(cleared.message, "original");
expect(cleared.metadata, isNull);
expect(cleared.level, isNull);
expect(cleared.stackTrace, isNull);
expect(cleared.extra, isNull);
});
});
group("LogEvent.fromJson", () {
test("round-trips a fully populated event", () {
const original = LogEvent(
message: "hello",
metadata: {"key": "value"},
level: Level.warning,
extra: 42,
);
final json = original.toJson();
final restored = LogEvent.fromJson(json);
expect(restored.message, "hello");
expect(restored.metadata, {"key": "value"});
expect(restored.level, Level.warning);
expect(restored.extra, 42);
});
test("round-trips an event with a stack trace", () {
final original = LogEvent(
message: "crash",
stackTrace: StackTrace.fromString("frame #0"),
);
final json = original.toJson();
final restored = LogEvent.fromJson(json);
expect(restored.stackTrace.toString(), "frame #0");
});
test("omits null optional fields from toJson output", () {
const event = LogEvent(message: "bare");
final json = event.toJson();
expect(json.containsKey("metadata"), isFalse);
expect(json.containsKey("level"), isFalse);
expect(json.containsKey("stackTrace"), isFalse);
expect(json.containsKey("extra"), isFalse);
});
test("fromJson handles missing optional fields gracefully", () {
final event = LogEvent.fromJson({"message": "minimal"});
expect(event.message, "minimal");
expect(event.metadata, isNull);
expect(event.level, isNull);
expect(event.stackTrace, isNull);
expect(event.extra, isNull);
});
test("fromJson falls back to Level.debug for unrecognised level name", () {
final event = LogEvent.fromJson({
"message": "x",
"level": "nonExistentLevel",
});
expect(event.level, Level.debug);
});
test("toJson encodes metadata and extra recursively", () {
const event = LogEvent(
message: "nested",
metadata: {
"map": {"inner": 1},
"list": [1, 2, 3],
},
extra: {"deep": true},
);
final json = event.toJson();
expect((json["metadata"] as Map)["map"], {"inner": 1});
expect((json["metadata"] as Map)["list"], [1, 2, 3]);
expect(json["extra"], {"deep": true});
});
});
}
@@ -0,0 +1,75 @@
import "package:arcane_framework/arcane_framework.dart";
import "package:flutter_test/flutter_test.dart";
class _InterceptorsTestInterface extends LoggingInterface {
_InterceptorsTestInterface(this.name);
final String name;
@override
void log(
String message, {
Map<String, Object?>? metadata,
Level? level,
StackTrace? stackTrace,
Object? extra,
}) {}
}
void main() {
group("LoggingInterceptorsService", () {
late _InterceptorsTestInterface primary;
late _InterceptorsTestInterface secondary;
late LogInterceptor global;
late LogInterceptor interfaceScoped;
late LogInterceptor typeScoped;
setUp(() {
Arcane.logger.reset();
Arcane.logger.interceptors.clear();
primary = _InterceptorsTestInterface("primary");
secondary = _InterceptorsTestInterface("secondary");
global = LogInterceptor((event, context) => event);
interfaceScoped = LogInterceptor((event, context) => event);
typeScoped = LogInterceptor((event, context) => event);
});
test("clearGlobal removes global interceptors only", () {
Arcane.logger.interceptors.add(global);
Arcane.logger.interceptors
.registerForInterface(primary, [interfaceScoped]);
Arcane.logger.interceptors.clearGlobal();
final resolved = Arcane.logger.interceptors.resolveForInterface(primary);
expect(resolved, isNot(contains(global)));
expect(resolved, contains(interfaceScoped));
});
test("resolveForInterface includes only matching registrations", () {
Arcane.logger.interceptors.add(global);
Arcane.logger.interceptors
.registerForInterface(primary, [interfaceScoped]);
Arcane.logger.interceptors.add(
typeScoped,
matcher: (interface) => interface is _InterceptorsTestInterface,
);
final forPrimary =
Arcane.logger.interceptors.resolveForInterface(primary);
final forSecondary =
Arcane.logger.interceptors.resolveForInterface(secondary);
expect(forPrimary, contains(global));
expect(forPrimary, contains(interfaceScoped));
expect(forPrimary, contains(typeScoped));
expect(forSecondary, contains(global));
expect(forSecondary, isNot(contains(interfaceScoped)));
expect(forSecondary, contains(typeScoped));
});
});
}
@@ -372,6 +372,45 @@ void main() {
expect(secondary.events.single.message, "[future] hello");
});
test("registerInterfaces accepts empty interceptor map", () async {
await Arcane.logger.registerInterfaces(
[myInterface],
interceptors: <LoggingInterface, List<LogInterceptor>>{},
);
Arcane.log("hello");
expect(myInterface.events.single.message, "hello");
});
test("unregisterInterfaces removes all listed interfaces", () async {
final TestLoggingInterface secondary =
TestLoggingInterface("secondary");
await Arcane.logger.registerInterfaces([myInterface, secondary]);
await Arcane.logger.unregisterInterfaces([myInterface, secondary]);
expect(Arcane.logger.interfaces, isEmpty);
});
test("unregisterAllInterfaces removes all registered interfaces",
() async {
final TestLoggingInterface secondary =
TestLoggingInterface("secondary");
await Arcane.logger.registerInterfaces([myInterface, secondary]);
await Arcane.logger.unregisterAllInterfaces();
expect(Arcane.logger.interfaces, isEmpty);
});
test("initializeInterfaces throws when no interfaces are registered",
() async {
await expectLater(
Arcane.logger.initializeInterfaces(),
throwsException,
);
});
});
group("persistent metadata", () {
@@ -393,6 +432,18 @@ void main() {
Arcane.logger.clearPersistentMetadata();
expect(Arcane.logger.additionalMetadata.isEmpty, true);
});
test("addPersistentMetadata removes an existing key on empty value", () {
Arcane.logger.addPersistentMetadata({"token": "abc"});
Arcane.logger.addPersistentMetadata({"token": ""});
expect(Arcane.logger.additionalMetadata.containsKey("token"), isFalse);
});
test("addPersistentMetadata ignores null values for new keys", () {
Arcane.logger.addPersistentMetadata({"token": null});
expect(Arcane.logger.additionalMetadata.containsKey("token"), isFalse);
});
});
group("logging messages", () {
@@ -455,6 +506,31 @@ void main() {
);
});
test("explicit method parameter is preserved in metadata", () async {
Arcane.log(
logMessage,
method: "customMethod",
skipAutodetection: true,
);
expect(myInterface.events.single.metadata?["method"], "customMethod");
});
test("module and method can be inferred from provided metadata",
() async {
Arcane.log(
logMessage,
metadata: {
"module": "InjectedModule",
"method": "InjectedMethod",
},
skipAutodetection: true,
);
expect(myInterface.events.single.metadata?["module"], "InjectedModule");
expect(myInterface.events.single.metadata?["method"], "InjectedMethod");
});
test("global interceptors run in registration order", () async {
Arcane.logger.interceptors.addAll([
LogInterceptor((event, context) {
+34 -1
View File
@@ -1,4 +1,4 @@
import "package:arcane_framework/src/services/theme/theme_extensions.dart";
import "package:arcane_framework/arcane_framework.dart";
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
@@ -30,4 +30,37 @@ void main() {
),
);
});
testWidgets("themeMode reads value from nearest ArcaneTheme", (tester) async {
await tester.pumpWidget(
MaterialApp(
home: ArcaneTheme(
themeMode: ThemeMode.dark,
child: Builder(
builder: (context) {
expect(context.themeMode, ThemeMode.dark);
return Container();
},
),
),
),
);
});
testWidgets("themeMode falls back to Arcane theme service without provider",
(tester) async {
Arcane.theme.reset();
Arcane.theme.switchTheme(themeMode: ThemeMode.dark);
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
expect(context.themeMode, ArcaneReactiveTheme.I.currentThemeMode);
return Container();
},
),
),
);
});
}
@@ -34,6 +34,56 @@ void main() {
expect(ArcaneThemeService.I.light, customLight);
});
test("dark and light setters delegate to setDarkTheme/setLightTheme", () {
final customDark =
ThemeData(primaryColor: Colors.teal, brightness: Brightness.dark);
final customLight =
ThemeData(primaryColor: Colors.amber, brightness: Brightness.light);
ArcaneThemeService.I.dark = customDark;
ArcaneThemeService.I.light = customLight;
expect(ArcaneThemeService.I.dark, customDark);
expect(ArcaneThemeService.I.light, customLight);
});
testWidgets("currentModeOf returns context themeMode", (tester) async {
ArcaneThemeService.I.switchTheme(themeMode: ThemeMode.dark);
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
expect(
ArcaneThemeService.I.currentModeOf(context),
ThemeMode.dark,
);
return const SizedBox();
},
),
),
);
});
testWidgets("setInitialTheme handles explicit dark mode", (tester) async {
ArcaneThemeService.I.switchTheme(themeMode: ThemeMode.dark);
late BuildContext capturedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
capturedContext = context;
return const SizedBox();
},
),
),
);
ArcaneThemeService.I.setInitialTheme(capturedContext);
expect(ArcaneThemeService.I.currentTheme.brightness, Brightness.dark);
});
test("reset restores defaults", () {
ArcaneThemeService.I.setDarkTheme(
ThemeData(primaryColor: Colors.red, brightness: Brightness.dark),
@@ -0,0 +1,72 @@
import "package:arcane_framework/arcane_framework.dart";
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
void main() {
group("ArcaneThemeSwitcher", () {
setUp(() {
ArcaneThemeService.I.reset();
});
testWidgets("renders child inside ArcaneTheme", (tester) async {
await tester.pumpWidget(
MaterialApp(
home: ArcaneThemeSwitcher(
child: Builder(
builder: (context) {
final theme = ArcaneTheme.of(context);
expect(theme, isNotNull);
return const SizedBox();
},
),
),
),
);
});
testWidgets(
"didChangePlatformBrightness calls followSystemTheme when following system",
(tester) async {
await tester.pumpWidget(
const MaterialApp(
home: ArcaneThemeSwitcher(child: SizedBox()),
),
);
// followSystemTheme is set during didChangeDependencies; enable it.
ArcaneThemeService.I.followSystemTheme(
tester.element(find.byType(ArcaneThemeSwitcher)),
);
expect(ArcaneThemeService.I.isFollowingSystemTheme, isTrue);
// Simulate a platform brightness change via the WidgetsBindingObserver.
tester.binding.platformDispatcher.onPlatformBrightnessChanged?.call();
await tester.pump();
// After the post-frame callback the service should still be in
// follow-system mode — the key assertion is that no exception was thrown
// and the widget remained mounted.
expect(ArcaneThemeService.I.isFollowingSystemTheme, isTrue);
});
testWidgets(
"didChangePlatformBrightness is a no-op when not following system",
(tester) async {
await tester.pumpWidget(
const MaterialApp(
home: ArcaneThemeSwitcher(child: SizedBox()),
),
);
// Explicitly switch to a manual dark mode (disables follow-system).
ArcaneThemeService.I.switchTheme(themeMode: ThemeMode.dark);
expect(ArcaneThemeService.I.isFollowingSystemTheme, isFalse);
// Simulate platform brightness change — should be a no-op without error.
tester.binding.platformDispatcher.onPlatformBrightnessChanged?.call();
await tester.pump();
expect(ArcaneThemeService.I.currentThemeMode, ThemeMode.dark);
});
});
}