From 0078e344fd02dca2e1d80c52de17a966d9294572 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Fri, 19 Jun 2026 20:28:24 +0200 Subject: [PATCH] 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 * Bump version to 2.0.2 in pubspec.yaml and update CHANGELOG to reflect the new version Signed-off-by: Hans Kokx * Add migration steps for replacing @LoggingFeature annotation with LoggerName mixin in CHANGELOG Signed-off-by: Hans Kokx * Fix class declaration in migration example for LoggerName mixin Signed-off-by: Hans Kokx * 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 * Revert version bump in pubspec.yaml and remove test coverage note from CHANGELOG Signed-off-by: Hans Kokx * Update test to expect a thrown exception when no interfaces are registered Signed-off-by: Hans Kokx * Add ArcaneEnvironmentModeController and related tests for debug mode management Signed-off-by: Hans Kokx * Refactor theme switcher tests to use platform dispatcher for brightness change simulation Signed-off-by: Hans Kokx --------- Signed-off-by: Hans Kokx * 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 --- .github/workflows/analyze-and-unit-test.yaml | 8 +- CHANGELOG.md | 13 + CONTRIBUTING.md | 3 + lib/arcane_framework.dart | 2 +- .../environment/environment_provider.dart | 18 +- lib/src/services/logging/logging_service.dart | 8 +- pubspec.yaml | 2 +- test/arcane_test.dart | 17 ++ test/providers/service_provider_test.dart | 146 ++++++++++++ .../authentication_interface_test.dart | 31 +++ .../authentication_service_test.dart | 47 ++++ .../environment_interface_test.dart | 38 +++ .../environment_provider_test.dart | 224 ++++++++++++++++++ .../environment/environment_service_test.dart | 107 +++++++++ .../feature_flags_provider_test.dart | 31 +++ .../feature_flags_service_test.dart | 76 ++++++ test/services/logging/log_event_test.dart | 157 ++++++++++++ .../logging_interceptors_service_test.dart | 75 ++++++ .../logging/logging_service_test.dart | 76 ++++++ .../services/theme/theme_extensions_test.dart | 35 ++- test/services/theme/theme_service_test.dart | 50 ++++ test/services/theme/theme_switcher_test.dart | 72 ++++++ tool/check_coverage.sh | 70 ++++++ 23 files changed, 1294 insertions(+), 12 deletions(-) create mode 100644 test/services/environment/environment_interface_test.dart create mode 100644 test/services/environment/environment_provider_test.dart create mode 100644 test/services/environment/environment_service_test.dart create mode 100644 test/services/logging/log_event_test.dart create mode 100644 test/services/logging/logging_interceptors_service_test.dart create mode 100644 test/services/theme/theme_switcher_test.dart create mode 100755 tool/check_coverage.sh diff --git a/.github/workflows/analyze-and-unit-test.yaml b/.github/workflows/analyze-and-unit-test.yaml index 52da24f..96fea91 100644 --- a/.github/workflows/analyze-and-unit-test.yaml +++ b/.github/workflows/analyze-and-unit-test.yaml @@ -4,7 +4,7 @@ on: pull_request: jobs: test: - name: Analyze and test + name: Analyze, test, and enforce coverage runs-on: ubuntu-latest steps: - name: Checkout repository @@ -17,5 +17,7 @@ jobs: run: flutter pub get - name: Analyze run: flutter analyze - - name: Test - run: flutter test + - name: Run tests with coverage + run: flutter test --coverage + - name: Enforce 100% package line coverage + run: bash tool/check_coverage.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb0b89..e84c31f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Arcane Framework diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c3f23b..2a9de43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,9 @@ Contributing - Please include `dart --version` and the package version when reporting bugs. - Code should be formatted with `dartfmt`. - 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. - Use [well-formatted commit messages][git-log-fmt]. diff --git a/lib/arcane_framework.dart b/lib/arcane_framework.dart index bc63c01..7fa92aa 100644 --- a/lib/arcane_framework.dart +++ b/lib/arcane_framework.dart @@ -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_service.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; diff --git a/lib/src/services/environment/environment_provider.dart b/lib/src/services/environment/environment_provider.dart index b5a09da..419f0b0 100644 --- a/lib/src/services/environment/environment_provider.dart +++ b/lib/src/services/environment/environment_provider.dart @@ -3,6 +3,18 @@ import "package:flutter/widgets.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. /// /// The `ArcaneEnvironment` widget holds the current environment and allows @@ -77,7 +89,8 @@ class ArcaneEnvironmentProvider extends StatefulWidget { _ArcaneEnvironmentProviderState(); } -class _ArcaneEnvironmentProviderState extends State { +class _ArcaneEnvironmentProviderState extends State + implements ArcaneEnvironmentModeController { late Environment _environment; void _handleEnvironmentChange() { @@ -111,17 +124,20 @@ class _ArcaneEnvironmentProviderState extends State { } /// Enables debug mode by setting the environment to `Environment.debug`. + @override void enableDebugMode() { if (_environment == Environment.debug) return; setEnvironment(Environment.debug); } /// Disables debug mode by setting the environment to `Environment.normal`. + @override void disableDebugMode() { if (_environment == Environment.normal) return; setEnvironment(Environment.normal); } + @override void setEnvironment(Environment environment) { Arcane.environment.setEnvironment(environment); } diff --git a/lib/src/services/logging/logging_service.dart b/lib/src/services/logging/logging_service.dart index 8b0fac4..e9267b0 100644 --- a/lib/src/services/logging/logging_service.dart +++ b/lib/src/services/logging/logging_service.dart @@ -225,11 +225,9 @@ class ArcaneLogger { ...?parts?.split("(package:").lastOrNull?.split(":"), ]; - if (fileAndLineParts.length < 2) { - filenameAndLineNumber = fileAndLineParts.firstOrNull; - } else { - filenameAndLineNumber = "${fileAndLineParts[0]}:${fileAndLineParts[1]}"; - } + final String parsedFilenameAndLine = fileAndLineParts.take(2).join(":"); + filenameAndLineNumber = + parsedFilenameAndLine.isEmpty ? null : parsedFilenameAndLine; } // Module management diff --git a/pubspec.yaml b/pubspec.yaml index 83dd2dd..5c745a8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: arcane_framework description: "Agnostic Reusable Component Architecture for New Ecosystems: a modern framework for bootstrapping new applications" -version: 2.0.5 +version: 2.0.6 repository: https://github.com/hanskokx/arcane_framework issue_tracker: https://github.com/hanskokx/arcane_framework/issues diff --git a/test/arcane_test.dart b/test/arcane_test.dart index f570868..c4e8613 100644 --- a/test/arcane_test.dart +++ b/test/arcane_test.dart @@ -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())); @@ -17,5 +22,17 @@ void main() { expect(services, contains(isA())); expect(services, contains(isA())); }); + + test( + "fallback getters return singletons when registry is present but empty", + () { + final notifier = ValueNotifier>([]); + 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)); + }); }); } diff --git a/test/providers/service_provider_test.dart b/test/providers/service_provider_test.dart index 869238a..d430221 100644 --- a/test/providers/service_provider_test.dart +++ b/test/providers/service_provider_test.dart @@ -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()), + ); + 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()); + + expect( + () => ArcaneServiceProvider.requiredServiceOfType< + UnregisteredService>(context), + throwsA(isA()), + ); + + 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(); + 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(); + await tester.pump(); + + expect(removed, isTrue); + expect( + provider.registeredServices.whereType(), + isEmpty, + ); + + final secondRemoval = provider.removeService(); + expect(secondRemoval, isFalse); + }); + testWidgets("ArcaneService.of 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(), + isNotEmpty, + ); + }); }); } diff --git a/test/services/authentication/authentication_interface_test.dart b/test/services/authentication/authentication_interface_test.dart index 46f8d43..5000d29 100644 --- a/test/services/authentication/authentication_interface_test.dart +++ b/test/services/authentication/authentication_interface_test.dart @@ -33,6 +33,32 @@ class MockAuth implements ArcaneAuthInterface { } } +class MinimalAuth extends ArcaneAuthInterface { + @override + Future get isSignedIn => Future.value(false); + + @override + Future? get accessToken => Future.value(null); + + @override + Future? get refreshToken => Future.value(null); + + @override + Future> login({ + T? input, + Future Function()? onLoggedIn, + }) async { + return const Result.ok(null); + } + + @override + Future> logout({ + Future 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(); + }); } diff --git a/test/services/authentication/authentication_service_test.dart b/test/services/authentication/authentication_service_test.dart index bb32719..903faa1 100644 --- a/test/services/authentication/authentication_service_test.dart +++ b/test/services/authentication/authentication_service_test.dart @@ -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; diff --git a/test/services/environment/environment_interface_test.dart b/test/services/environment/environment_interface_test.dart new file mode 100644 index 0000000..1cd4ba1 --- /dev/null +++ b/test/services/environment/environment_interface_test.dart @@ -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)"); + }); + }); +} diff --git a/test/services/environment/environment_provider_test.dart b/test/services/environment/environment_provider_test.dart new file mode 100644 index 0000000..6d90dd2 --- /dev/null +++ b/test/services/environment/environment_provider_test.dart @@ -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()), + ); + 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>( + 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>( + 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); + }, + ); + }); +} diff --git a/test/services/environment/environment_service_test.dart b/test/services/environment/environment_service_test.dart new file mode 100644 index 0000000..e82d686 --- /dev/null +++ b/test/services/environment/environment_service_test.dart @@ -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.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; + }); + }); +} diff --git a/test/services/feature_flags/feature_flags_provider_test.dart b/test/services/feature_flags/feature_flags_provider_test.dart index a5f8a0f..c0cb2be 100644 --- a/test/services/feature_flags/feature_flags_provider_test.dart +++ b/test/services/feature_flags/feature_flags_provider_test.dart @@ -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()), + ); + 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", diff --git a/test/services/feature_flags/feature_flags_service_test.dart b/test/services/feature_flags/feature_flags_service_test.dart index c713f66..b7c69cc 100644 --- a/test/services/feature_flags/feature_flags_service_test.dart +++ b/test/services/feature_flags/feature_flags_service_test.dart @@ -1,6 +1,29 @@ import "package:arcane_framework/arcane_framework.dart"; import "package:flutter_test/flutter_test.dart"; +class FeatureFlagLoggingInterface extends LoggingInterface { + final List events = []; + + @override + void log( + String message, { + Map? metadata, + Level? level, + StackTrace? stackTrace, + Object? extra, + }) { + events.add( + LogEvent( + message: message, + metadata: metadata == null ? null : Map.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; + }); }); }); } diff --git a/test/services/logging/log_event_test.dart b/test/services/logging/log_event_test.dart new file mode 100644 index 0000000..cc8dc44 --- /dev/null +++ b/test/services/logging/log_event_test.dart @@ -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}); + }); + }); +} diff --git a/test/services/logging/logging_interceptors_service_test.dart b/test/services/logging/logging_interceptors_service_test.dart new file mode 100644 index 0000000..de06daf --- /dev/null +++ b/test/services/logging/logging_interceptors_service_test.dart @@ -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? 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)); + }); + }); +} diff --git a/test/services/logging/logging_service_test.dart b/test/services/logging/logging_service_test.dart index 5a22d70..82609a8 100644 --- a/test/services/logging/logging_service_test.dart +++ b/test/services/logging/logging_service_test.dart @@ -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: >{}, + ); + + 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) { diff --git a/test/services/theme/theme_extensions_test.dart b/test/services/theme/theme_extensions_test.dart index 217ee11..a6851bd 100644 --- a/test/services/theme/theme_extensions_test.dart +++ b/test/services/theme/theme_extensions_test.dart @@ -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(); + }, + ), + ), + ); + }); } diff --git a/test/services/theme/theme_service_test.dart b/test/services/theme/theme_service_test.dart index 14ddd63..d1bfe3c 100644 --- a/test/services/theme/theme_service_test.dart +++ b/test/services/theme/theme_service_test.dart @@ -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), diff --git a/test/services/theme/theme_switcher_test.dart b/test/services/theme/theme_switcher_test.dart new file mode 100644 index 0000000..c48208c --- /dev/null +++ b/test/services/theme/theme_switcher_test.dart @@ -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); + }); + }); +} diff --git a/tool/check_coverage.sh b/tool/check_coverage.sh new file mode 100755 index 0000000..a6f9524 --- /dev/null +++ b/tool/check_coverage.sh @@ -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 \ No newline at end of file