mirror of
https://github.com/hanskokx/arcane_framework.git
synced 2026-08-12 06:10:55 +02:00
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:
@@ -4,7 +4,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Analyze and test
|
name: Analyze, test, and enforce coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -17,5 +17,7 @@ jobs:
|
|||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
- name: Analyze
|
- name: Analyze
|
||||||
run: flutter analyze
|
run: flutter analyze
|
||||||
- name: Test
|
- name: Run tests with coverage
|
||||||
run: flutter test
|
run: flutter test --coverage
|
||||||
|
- name: Enforce 100% package line coverage
|
||||||
|
run: bash tool/check_coverage.sh
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
## 2.0.6
|
||||||
|
|
||||||
|
### Arcane Framework
|
||||||
|
|
||||||
|
- [BREAKING] This package no longer exports the `Error` and `Ok` symbols from
|
||||||
|
the `result_monad` package.
|
||||||
|
|
||||||
|
#### Migration Steps (`Error`/`Ok`)
|
||||||
|
|
||||||
|
1. Add the `result_monad` package to your `pubspec.yaml` file.
|
||||||
|
2. Use the import `import 'package:result_monad/result_monad.dart';` to access
|
||||||
|
`Error` and `Ok` symbols.
|
||||||
|
|
||||||
## 2.0.5
|
## 2.0.5
|
||||||
|
|
||||||
### Arcane Framework
|
### Arcane Framework
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ Contributing
|
|||||||
- Please include `dart --version` and the package version when reporting bugs.
|
- Please include `dart --version` and the package version when reporting bugs.
|
||||||
- Code should be formatted with `dartfmt`.
|
- Code should be formatted with `dartfmt`.
|
||||||
- Public methods should have doc comments and test coverage.
|
- Public methods should have doc comments and test coverage.
|
||||||
|
- Follow TDD for all framework changes: write or update tests first, then implement.
|
||||||
|
- Coverage is enforced in CI at 100% line coverage for `lib/src/**` and `lib/arcane_framework.dart`.
|
||||||
|
- Use `flutter test --coverage` and then `bash tool/check_coverage.sh` locally to validate the same coverage gate used by CI.
|
||||||
- Itemize user-facing changes in the `HEAD` section of the `CHANGELOG` file.
|
- Itemize user-facing changes in the `HEAD` section of the `CHANGELOG` file.
|
||||||
- Use [well-formatted commit messages][git-log-fmt].
|
- Use [well-formatted commit messages][git-log-fmt].
|
||||||
|
|
||||||
|
|||||||
@@ -52,4 +52,4 @@ export "package:arcane_framework/src/services/theme/arcane_theme.dart";
|
|||||||
export "package:arcane_framework/src/services/theme/theme_extensions.dart";
|
export "package:arcane_framework/src/services/theme/theme_extensions.dart";
|
||||||
export "package:arcane_framework/src/services/theme/theme_service.dart";
|
export "package:arcane_framework/src/services/theme/theme_service.dart";
|
||||||
export "package:arcane_framework/src/services/theme/theme_switcher.dart";
|
export "package:arcane_framework/src/services/theme/theme_switcher.dart";
|
||||||
export "package:result_monad/result_monad.dart";
|
export "package:result_monad/result_monad.dart" hide Error, Ok;
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ import "package:flutter/widgets.dart";
|
|||||||
|
|
||||||
import "environment_interface.dart";
|
import "environment_interface.dart";
|
||||||
|
|
||||||
|
/// Typed API for mutating environment mode from provider state.
|
||||||
|
abstract interface class ArcaneEnvironmentModeController {
|
||||||
|
/// Enables debug mode by setting the environment to `Environment.debug`.
|
||||||
|
void enableDebugMode();
|
||||||
|
|
||||||
|
/// Disables debug mode by setting the environment to `Environment.normal`.
|
||||||
|
void disableDebugMode();
|
||||||
|
|
||||||
|
/// Sets the current environment.
|
||||||
|
void setEnvironment(Environment environment);
|
||||||
|
}
|
||||||
|
|
||||||
/// An `InheritedWidget` that provides access to the application environment.
|
/// An `InheritedWidget` that provides access to the application environment.
|
||||||
///
|
///
|
||||||
/// The `ArcaneEnvironment` widget holds the current environment and allows
|
/// The `ArcaneEnvironment` widget holds the current environment and allows
|
||||||
@@ -77,7 +89,8 @@ class ArcaneEnvironmentProvider extends StatefulWidget {
|
|||||||
_ArcaneEnvironmentProviderState();
|
_ArcaneEnvironmentProviderState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ArcaneEnvironmentProviderState extends State<ArcaneEnvironmentProvider> {
|
class _ArcaneEnvironmentProviderState extends State<ArcaneEnvironmentProvider>
|
||||||
|
implements ArcaneEnvironmentModeController {
|
||||||
late Environment _environment;
|
late Environment _environment;
|
||||||
|
|
||||||
void _handleEnvironmentChange() {
|
void _handleEnvironmentChange() {
|
||||||
@@ -111,17 +124,20 @@ class _ArcaneEnvironmentProviderState extends State<ArcaneEnvironmentProvider> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Enables debug mode by setting the environment to `Environment.debug`.
|
/// Enables debug mode by setting the environment to `Environment.debug`.
|
||||||
|
@override
|
||||||
void enableDebugMode() {
|
void enableDebugMode() {
|
||||||
if (_environment == Environment.debug) return;
|
if (_environment == Environment.debug) return;
|
||||||
setEnvironment(Environment.debug);
|
setEnvironment(Environment.debug);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disables debug mode by setting the environment to `Environment.normal`.
|
/// Disables debug mode by setting the environment to `Environment.normal`.
|
||||||
|
@override
|
||||||
void disableDebugMode() {
|
void disableDebugMode() {
|
||||||
if (_environment == Environment.normal) return;
|
if (_environment == Environment.normal) return;
|
||||||
setEnvironment(Environment.normal);
|
setEnvironment(Environment.normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
void setEnvironment(Environment environment) {
|
void setEnvironment(Environment environment) {
|
||||||
Arcane.environment.setEnvironment(environment);
|
Arcane.environment.setEnvironment(environment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,11 +225,9 @@ class ArcaneLogger {
|
|||||||
...?parts?.split("(package:").lastOrNull?.split(":"),
|
...?parts?.split("(package:").lastOrNull?.split(":"),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (fileAndLineParts.length < 2) {
|
final String parsedFilenameAndLine = fileAndLineParts.take(2).join(":");
|
||||||
filenameAndLineNumber = fileAndLineParts.firstOrNull;
|
filenameAndLineNumber =
|
||||||
} else {
|
parsedFilenameAndLine.isEmpty ? null : parsedFilenameAndLine;
|
||||||
filenameAndLineNumber = "${fileAndLineParts[0]}:${fileAndLineParts[1]}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module management
|
// Module management
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: arcane_framework
|
name: arcane_framework
|
||||||
description: "Agnostic Reusable Component Architecture for New Ecosystems: a
|
description: "Agnostic Reusable Component Architecture for New Ecosystems: a
|
||||||
modern framework for bootstrapping new applications"
|
modern framework for bootstrapping new applications"
|
||||||
version: 2.0.5
|
version: 2.0.6
|
||||||
repository: https://github.com/hanskokx/arcane_framework
|
repository: https://github.com/hanskokx/arcane_framework
|
||||||
issue_tracker: https://github.com/hanskokx/arcane_framework/issues
|
issue_tracker: https://github.com/hanskokx/arcane_framework/issues
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import "package:arcane_framework/arcane_framework.dart";
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
|
import "package:flutter/foundation.dart";
|
||||||
import "package:flutter_test/flutter_test.dart";
|
import "package:flutter_test/flutter_test.dart";
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -10,6 +11,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group("Arcane", () {
|
group("Arcane", () {
|
||||||
|
tearDown(() {
|
||||||
|
Arcane.clearRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
test("services getter returns all core services", () {
|
test("services getter returns all core services", () {
|
||||||
final services = Arcane.services;
|
final services = Arcane.services;
|
||||||
expect(services, contains(isA<ArcaneFeatureFlagService>()));
|
expect(services, contains(isA<ArcaneFeatureFlagService>()));
|
||||||
@@ -17,5 +22,17 @@ void main() {
|
|||||||
expect(services, contains(isA<ArcaneReactiveTheme>()));
|
expect(services, contains(isA<ArcaneReactiveTheme>()));
|
||||||
expect(services, contains(isA<ArcaneEnvironmentService>()));
|
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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,120 @@ void main() {
|
|||||||
expect(buildCount, 3);
|
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 {
|
testWidgets("ArcaneService.of<T> static helper works", (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ArcaneApp(
|
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() {
|
void main() {
|
||||||
test("MockAuth fulfills ArcaneAuthInterface contract", () async {
|
test("MockAuth fulfills ArcaneAuthInterface contract", () async {
|
||||||
final auth = MockAuth();
|
final auth = MockAuth();
|
||||||
@@ -47,4 +73,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(called, isTrue);
|
expect(called, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("default init implementation completes", () async {
|
||||||
|
final auth = MinimalAuth();
|
||||||
|
await auth.init();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,53 @@ void main() {
|
|||||||
ArcaneAuthenticationService.I.dispose();
|
ArcaneAuthenticationService.I.dispose();
|
||||||
// No assertion needed; just ensure no crash
|
// 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;
|
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",
|
testWidgets("feature flag updates trigger rebuilds for dependent widgets",
|
||||||
(tester) async {
|
(tester) async {
|
||||||
int buildCount = 0;
|
int buildCount = 0;
|
||||||
@@ -100,6 +117,20 @@ void main() {
|
|||||||
await tester.tap(find.text("disable"));
|
await tester.tap(find.text("disable"));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text("off"), findsOneWidget);
|
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",
|
testWidgets("scope exposes notifier and stream for reactive consumers",
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import "package:arcane_framework/arcane_framework.dart";
|
import "package:arcane_framework/arcane_framework.dart";
|
||||||
import "package:flutter_test/flutter_test.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() {
|
void main() {
|
||||||
group("ArcaneFeatureFlagService", () {
|
group("ArcaneFeatureFlagService", () {
|
||||||
late ArcaneFeatureFlagService featureFlags;
|
late ArcaneFeatureFlagService featureFlags;
|
||||||
@@ -8,12 +31,21 @@ void main() {
|
|||||||
setUp(() {
|
setUp(() {
|
||||||
featureFlags = ArcaneFeatureFlagService.I;
|
featureFlags = ArcaneFeatureFlagService.I;
|
||||||
Arcane.features.reset();
|
Arcane.features.reset();
|
||||||
|
Arcane.logger.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("singleton instance is consistent", () {
|
test("singleton instance is consistent", () {
|
||||||
expect(identical(ArcaneFeatureFlagService.I, featureFlags), true);
|
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", () {
|
group("feature management", () {
|
||||||
test("enableFeature adds feature to enabled list", () {
|
test("enableFeature adds feature to enabled list", () {
|
||||||
featureFlags.enableFeature(MockFeature.test);
|
featureFlags.enableFeature(MockFeature.test);
|
||||||
@@ -99,6 +131,50 @@ void main() {
|
|||||||
|
|
||||||
await secondSubscription.cancel();
|
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;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
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", () {
|
group("persistent metadata", () {
|
||||||
@@ -393,6 +432,18 @@ void main() {
|
|||||||
Arcane.logger.clearPersistentMetadata();
|
Arcane.logger.clearPersistentMetadata();
|
||||||
expect(Arcane.logger.additionalMetadata.isEmpty, true);
|
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", () {
|
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 {
|
test("global interceptors run in registration order", () async {
|
||||||
Arcane.logger.interceptors.addAll([
|
Arcane.logger.interceptors.addAll([
|
||||||
LogInterceptor((event, context) {
|
LogInterceptor((event, context) {
|
||||||
|
|||||||
@@ -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/material.dart";
|
||||||
import "package:flutter_test/flutter_test.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);
|
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", () {
|
test("reset restores defaults", () {
|
||||||
ArcaneThemeService.I.setDarkTheme(
|
ArcaneThemeService.I.setDarkTheme(
|
||||||
ThemeData(primaryColor: Colors.red, brightness: Brightness.dark),
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LCOV_FILE="coverage/lcov.info"
|
||||||
|
THRESHOLD_PERCENT="100.00"
|
||||||
|
|
||||||
|
if [[ ! -f "$LCOV_FILE" ]]; then
|
||||||
|
echo "Coverage file not found: $LCOV_FILE"
|
||||||
|
echo "Run 'flutter test --coverage' before running this check."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Scope: framework package code only.
|
||||||
|
is_in_scope() {
|
||||||
|
local path="$1"
|
||||||
|
[[ "$path" =~ (^|/)lib/src/ ]] || [[ "$path" =~ (^|/)lib/arcane_framework\.dart$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
total_lines=0
|
||||||
|
covered_lines=0
|
||||||
|
in_scope_files=0
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [[ "$line" == SF:* ]]; then
|
||||||
|
current_file="${line#SF:}"
|
||||||
|
current_in_scope=0
|
||||||
|
if is_in_scope "$current_file"; then
|
||||||
|
current_in_scope=1
|
||||||
|
((in_scope_files += 1))
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$line" == DA:* ]] && [[ "${current_in_scope:-0}" -eq 1 ]]; then
|
||||||
|
payload="${line#DA:}"
|
||||||
|
line_number="${payload%%,*}"
|
||||||
|
rest="${payload#*,}"
|
||||||
|
hits="${rest%%,*}"
|
||||||
|
|
||||||
|
if [[ "$line_number" =~ ^[0-9]+$ ]] && [[ "$hits" =~ ^[0-9]+$ ]]; then
|
||||||
|
((total_lines += 1))
|
||||||
|
if [[ "$hits" -gt 0 ]]; then
|
||||||
|
((covered_lines += 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < "$LCOV_FILE"
|
||||||
|
|
||||||
|
if [[ "$in_scope_files" -eq 0 ]]; then
|
||||||
|
echo "No in-scope files were found in $LCOV_FILE."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$total_lines" -eq 0 ]]; then
|
||||||
|
echo "No in-scope executable lines were found in $LCOV_FILE."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
coverage_percent=$(awk -v covered="$covered_lines" -v total="$total_lines" 'BEGIN { printf "%.2f", (covered / total) * 100 }')
|
||||||
|
|
||||||
|
echo "Scoped line coverage: $coverage_percent% ($covered_lines/$total_lines)"
|
||||||
|
echo "Scope: lib/src/** and lib/arcane_framework.dart"
|
||||||
|
|
||||||
|
if awk -v actual="$coverage_percent" -v required="$THRESHOLD_PERCENT" 'BEGIN { exit (actual + 0.0 >= required + 0.0 ? 0 : 1) }'; then
|
||||||
|
echo "Coverage gate passed (>= $THRESHOLD_PERCENT%)."
|
||||||
|
else
|
||||||
|
echo "Coverage gate failed: required $THRESHOLD_PERCENT%, got $coverage_percent%."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user