diff --git a/modules/ensemble/lib/action/invoke_api_action.dart b/modules/ensemble/lib/action/invoke_api_action.dart index 6c3b12d38..d7d1f2abe 100644 --- a/modules/ensemble/lib/action/invoke_api_action.dart +++ b/modules/ensemble/lib/action/invoke_api_action.dart @@ -218,6 +218,10 @@ class InvokeAPIController { Response response, Map? apiMap, ScopeManager scopeManager) { + // The page may have been disposed while this API was in flight. Running + // onResponse against a deactivated context throws and blocks the main + // isolate, so drop the result instead. + if (scopeManager.pageData.isDisposed) return; // Preserve idle state if already set (e.g., for SSE disconnection) final bool isIdle = response.apiState == APIState.idle; @@ -303,6 +307,9 @@ class InvokeAPIController { dynamic errorResponse, Map? apiMap, ScopeManager apiScopeManager) { + // Skip onError callbacks once the owning page is disposed (see + // _onAPIComplete). + if (apiScopeManager.pageData.isDisposed) return; /// Create child scope applicable for onError only ScopeManager scopeManager = apiScopeManager.createChildScope(ephemeral: true); diff --git a/modules/ensemble/lib/framework/page_event_bus.dart b/modules/ensemble/lib/framework/page_event_bus.dart new file mode 100644 index 000000000..80aaad857 --- /dev/null +++ b/modules/ensemble/lib/framework/page_event_bus.dart @@ -0,0 +1,360 @@ +import 'dart:async'; + +import 'package:event_bus/event_bus.dart'; + +/// Page-scoped EventBus with an index for Ensemble model listeners. +/// +/// Direct listeners registered through either [EventBus.on] or +/// [EventBus.streamController.stream] share ordered slots with indexed +/// listeners. Events snapshot eligible slots when fired and deliver +/// asynchronously in registration order. +class PageEventBus extends EventBus { + PageEventBus({required String? Function(Object? event) modelIdOf}) + : this._(_IndexedStreamController(modelIdOf)); + + PageEventBus._(this._controller) : super.customController(_controller); + + final _IndexedStreamController _controller; + + /// Registers a model-specific listener, with [matches] applying source, + /// property and scope checks after the index narrows candidate listeners. + Stream onIndexed(String modelId, bool Function(T event) matches) => + _IndexedEventStream(_controller, modelId, matches); +} + +class _ListenerSlot { + _ListenerSlot({ + required this.id, + required this.output, + this.modelId, + this.matches, + }); + + final int id; + final StreamController output; + final String? modelId; + final bool Function(dynamic)? matches; + bool active = true; +} + +/// Each listen call becomes one subscription at its EventBus registration +/// position. The single-subscription stream itself may still be used as a +/// normal broadcast source because every listen call receives a fresh slot. +class _IndexedEventStream extends Stream { + _IndexedEventStream(this._controller, this._modelId, this._matches); + + final _IndexedStreamController _controller; + final String? _modelId; + final bool Function(T)? _matches; + + @override + bool get isBroadcast => true; + + @override + StreamSubscription listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => + _controller._listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + modelId: _modelId, + matches: _matches, + ); +} + +/// A StreamController-compatible ordered event hub. Each slot is backed by a +/// standard Dart subscription, which owns its pause queue and callback Zone. +class _IndexedStreamController implements StreamController { + _IndexedStreamController(this._modelIdOf); + + final String? Function(Object? event) _modelIdOf; + final Map _slots = {}; + final Map> _modelSlots = {}; + final Map _modelVersions = {}; + final List _directSlots = []; + final Completer _doneCompleter = Completer(); + int _nextId = 0; + bool _closed = false; + bool _addingStream = false; + + @override + Stream get stream => _IndexedEventStream(this, null, null); + + @override + StreamSink get sink => this; + + @override + bool get isClosed => _closed; + + @override + bool get hasListener => _slots.isNotEmpty; + + @override + bool get isPaused => _slots.values.any((slot) => slot.output.isPaused); + + @override + Future get done => _doneCompleter.future; + + @override + void Function()? onListen; + + @override + void Function()? get onPause => null; + + @override + set onPause(void Function()? callback) => throw UnsupportedError( + 'Broadcast stream controllers do not support pause callbacks'); + + @override + void Function()? get onResume => null; + + @override + set onResume(void Function()? callback) => throw UnsupportedError( + 'Broadcast stream controllers do not support resume callbacks'); + + @override + FutureOr Function()? onCancel; + + StreamSubscription _listen( + void Function(T event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + String? modelId, + bool Function(T event)? matches, + }) { + if (_closed) { + final closed = StreamController(sync: true)..close(); + return closed.stream.listen(onData, + onError: onError, onDone: onDone, cancelOnError: cancelOnError); + } + + late final _ListenerSlot slot; + final output = StreamController.broadcast( + sync: true, + onCancel: () => _remove(slot), + ); + slot = _ListenerSlot( + id: _nextId++, + output: output, + modelId: modelId, + matches: matches == null ? null : (event) => matches(event as T), + ); + _slots[slot.id] = slot; + if (modelId == null) { + _directSlots.add(slot.id); + } else { + (_modelSlots[modelId] ??= []).add(slot.id); + _modelVersions[modelId] = (_modelVersions[modelId] ?? 0) + 1; + } + if (_slots.length == 1) onListen?.call(); + + return output.stream.listen(onData, + onError: onError, onDone: onDone, cancelOnError: cancelOnError); + } + + FutureOr _remove(_ListenerSlot slot) { + if (!slot.active) return null; + slot.active = false; + _slots.remove(slot.id); + if (slot.modelId == null) { + _directSlots.remove(slot.id); + } else { + final ids = _modelSlots[slot.modelId]; + ids?.remove(slot.id); + _modelVersions[slot.modelId!] = (_modelVersions[slot.modelId] ?? 0) + 1; + if (ids?.isEmpty ?? false) _modelSlots.remove(slot.modelId); + } + if (_slots.isEmpty) return onCancel?.call(); + } + + void _ensureCanAdd() { + if (_closed) throw StateError('Cannot add event after closing'); + if (_addingStream) + throw StateError('Cannot add event while adding a stream'); + } + + @override + void add(dynamic event) { + _ensureCanAdd(); + _dispatch(event); + } + + void _dispatch(dynamic event) { + final maxRegistrationId = _nextId - 1; + // Direct subscribers are usually few. Snapshot them so subscriptions + // created while this event is pending or being delivered cannot see it. + final direct = List.of(_directSlots); + scheduleMicrotask(() => _deliver(event, direct, maxRegistrationId)); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + _ensureCanAdd(); + // Errors are not model events, so all listeners (including bindings) get + // the same error delivery they receive from a regular broadcast stream. + final candidates = _slots.keys.toList(growable: false); + scheduleMicrotask(() { + for (final id in candidates) { + final slot = _slots[id]; + if (slot != null && slot.active) + slot.output.addError(error, stackTrace); + } + }); + } + + void _deliver(Object? event, List direct, int maxRegistrationId) { + var cursor = -1; + var directIndex = 0; + var modelId = _modelIdOf(event); + var modelVersion = modelId == null ? 0 : (_modelVersions[modelId] ?? 0); + var modelSlots = modelId == null + ? const [] + : (_modelSlots[modelId] ?? const []); + var modelIndex = _firstAfter(modelSlots, cursor); + + while (true) { + while (directIndex < direct.length && direct[directIndex] <= cursor) { + directIndex++; + } + final directId = directIndex < direct.length && + direct[directIndex] <= maxRegistrationId + ? direct[directIndex] + : null; + + int? bindingId; + while (modelIndex < modelSlots.length) { + final candidateId = modelSlots[modelIndex]; + if (candidateId <= cursor) { + modelIndex++; + continue; + } + // Do not inspect bindings beyond the next direct listener yet. That + // listener may mutate the event, and those later bindings must filter + // the mutated value when their registration turn is reached. + if (directId != null && candidateId > directId) break; + if (candidateId > maxRegistrationId) break; + final candidate = _slots[candidateId]; + if (candidate != null && + candidate.active && + candidate.matches!(event)) { + bindingId = candidateId; + break; + } + modelIndex++; + } + + if (directId == null && bindingId == null) return; + final deliverDirect = + directId != null && (bindingId == null || directId < bindingId); + final id = deliverDirect ? directId : bindingId!; + final slot = _slots[id]; + cursor = id; + if (deliverDirect) directIndex++; + if (slot != null && slot.active) slot.output.add(event); + + // Only recalculate the model index after a callback. This preserves the + // old behavior if a listener mutates the event while avoiding repeated + // model lookups for the normal, immutable-event path. + final updatedModelId = _modelIdOf(event); + final updatedVersion = + updatedModelId == null ? 0 : (_modelVersions[updatedModelId] ?? 0); + if (updatedModelId != modelId || updatedVersion != modelVersion) { + modelId = updatedModelId; + modelVersion = updatedVersion; + modelSlots = modelId == null + ? const [] + : (_modelSlots[modelId] ?? const []); + modelIndex = _firstAfter(modelSlots, cursor); + } else if (!deliverDirect) { + modelIndex++; + } + } + } + + int _firstAfter(List ids, int value) { + var low = 0; + var high = ids.length; + while (low < high) { + final mid = low + ((high - low) >> 1); + if (ids[mid] <= value) { + low = mid + 1; + } else { + high = mid; + } + } + return low; + } + + @override + Future addStream(Stream source, {bool? cancelOnError}) async { + if (_addingStream) throw StateError('addStream is already in progress'); + if (_closed) throw StateError('Cannot add stream after closing'); + _addingStream = true; + final completer = Completer(); + late StreamSubscription subscription; + subscription = source.listen( + _dispatch, + onError: (Object error, StackTrace stack) { + _dispatchError(error, stack); + if (cancelOnError == true && !completer.isCompleted) { + subscription.cancel().whenComplete(completer.complete); + } + }, + onDone: completer.complete, + cancelOnError: cancelOnError ?? false, + ); + try { + await completer.future; + } finally { + await subscription.cancel(); + _addingStream = false; + } + } + + @override + Future close() { + if (_closed) return done; + if (_addingStream) { + throw StateError('Cannot close while adding a stream'); + } + _closed = true; + final slots = _slots.values.toList(growable: false); + // Closing a sync controller from its own callback throws StateError. + // Defer closure until the current ordered delivery has unwound. + scheduleMicrotask(() async { + for (final slot in slots) { + slot.active = false; + } + _slots.clear(); + _modelSlots.clear(); + _modelVersions.clear(); + _directSlots.clear(); + if (slots.isNotEmpty) await onCancel?.call(); + + final closing = >[]; + for (final slot in slots) { + closing.add(slot.output.close()); + } + await Future.wait(closing); + if (!_doneCompleter.isCompleted) _doneCompleter.complete(); + }); + return done; + } + + void _dispatchError(Object error, StackTrace? stackTrace) { + final candidates = _slots.keys.toList(growable: false); + scheduleMicrotask(() { + for (final id in candidates) { + final slot = _slots[id]; + if (slot != null && slot.active) + slot.output.addError(error, stackTrace); + } + }); + } +} diff --git a/modules/ensemble/lib/framework/scope.dart b/modules/ensemble/lib/framework/scope.dart index d10d5144a..b1c3a2c45 100644 --- a/modules/ensemble/lib/framework/scope.dart +++ b/modules/ensemble/lib/framework/scope.dart @@ -11,6 +11,7 @@ import 'package:ensemble/framework/data_utils.dart'; import 'package:ensemble/framework/ensemble_widget.dart'; import 'package:ensemble/framework/error_handling.dart'; import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/page_event_bus.dart'; import 'package:ensemble/framework/stub/location_manager.dart'; import 'package:ensemble/framework/theme_manager.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; @@ -74,6 +75,7 @@ class ScopeManager extends IsScopeManager with ViewBuilder, PageBindingManager { return; } + pageData._disposed = true; PageBindingManager.clearBindingOwners(pageData); // Cancel and release all page-owned bindings before destroying the event @@ -885,18 +887,15 @@ mixin PageBindingManager on IsScopeManager { //log("Binding(remove duplicate): ${me.id}-${bindingSource.modelId}-${bindingSource.property}"); listenerMap[destination.widget]![hash]!.cancel(); } - StreamSubscription subscription = - eventBus.on().listen((event) { - //log("EventBus ${eventBus.hashCode} listening: $event"); - if ((bindingSource is DeferredBindingSource || + StreamSubscription subscription = me.pageData.eventBus + .onIndexed(bindingSource.modelId, (event) { + return (bindingSource is DeferredBindingSource || event.source.runtimeType == bindingSource.runtimeType) && event.source.modelId == bindingSource.modelId && (event.source.property == null || event.source.property == bindingSource.property) && - (event.bindingScope == null || event.bindingScope == scopeManager)) { - onDataChange(event); - } - }); + (event.bindingScope == null || event.bindingScope == scopeManager); + }).listen((event) => onDataChange(event)); // save to the listener map so we can remove later if (listenerMap[destination.widget] == null) { @@ -966,8 +965,16 @@ class PageData { } // we'll have 1 EventBus and listenerMap for each Page - final EventBus eventBus = EventBus(); + final PageEventBus eventBus = PageEventBus( + modelIdOf: (event) => + event is ModelChangeEvent ? event.source.modelId : null, + ); final Map> listenerMap = {}; + bool _disposed = false; + + /// True once the owning page's root scope has been disposed. Used to + /// skip async work (e.g. API callbacks) that outlives the page. + bool get isDisposed => _disposed; // When repeating timers are created at the page level, we need to manage // duplicates as well as the ability to pause (navigate to new page) or diff --git a/modules/ensemble/lib/framework/storage_manager.dart b/modules/ensemble/lib/framework/storage_manager.dart index 92b89eb1b..33c868371 100644 --- a/modules/ensemble/lib/framework/storage_manager.dart +++ b/modules/ensemble/lib/framework/storage_manager.dart @@ -83,11 +83,10 @@ mixin PublicStorage { Future clearPublicStorage() async { const encryptedPrefix = 'enc_'; final keys = GetStorage().getKeys().toList(); - for (final key in keys) { - if (!key.startsWith(encryptedPrefix)) { - await GetStorage().remove(key); - } - } + await Future.wait([ + for (final key in keys) + if (!key.startsWith(encryptedPrefix)) GetStorage().remove(key), + ]); } } diff --git a/modules/ensemble/test/api_dispose_guard_test.dart b/modules/ensemble/test/api_dispose_guard_test.dart new file mode 100644 index 000000000..fe428a524 --- /dev/null +++ b/modules/ensemble/test/api_dispose_guard_test.dart @@ -0,0 +1,274 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:ensemble/action/invoke_api_action.dart'; +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/apiproviders/api_provider.dart'; +import 'package:ensemble/framework/data_context.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/view/data_scope_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:yaml/yaml.dart'; + +/// A provider whose `invokeApi` completion is controlled by the test. +class _FakeProvider extends APIProvider { + final Completer completer = Completer(); + + @override + Future init(String appId, Map config) async {} + + @override + Future invokeApi(BuildContext context, YamlMap api, + DataContext eContext, String apiName) { + return completer.future; + } + + @override + Future invokeMockAPI(DataContext eContext, dynamic mock) async => + _FakeResponse(); + + @override + APIProvider clone() => _FakeProvider(); + + @override + dispose() {} +} + +class _FakeResponse extends Response {} + +/// A live provider (SSE/Firestore-like) whose subscription keeps a listener +/// that can be invoked repeatedly, independent of the page lifecycle. +class _FakeLiveProvider extends APIProvider with LiveAPIProvider { + ResponseListener? listener; + + @override + Future init(String appId, Map config) async {} + + @override + Future invokeApi(BuildContext context, YamlMap api, + DataContext eContext, String apiName) async { + throw UnsupportedError('live provider'); + } + + @override + Future subscribeToApi(BuildContext context, YamlMap api, + DataContext eContext, String apiName, ResponseListener l) async { + listener = l; + return _FakeResponse(); + } + + @override + Future invokeMockAPI(DataContext eContext, dynamic mock) async => + _FakeResponse(); + + @override + APIProvider clone() => _FakeLiveProvider(); + + @override + dispose() {} +} + +/// Counts how many times it is executed, so tests can observe onResponse. +class _CountingAction extends EnsembleAction { + int count = 0; + + @override + Future execute(BuildContext context, ScopeManager scopeManager) async { + count++; + } +} + +/// Builds a widget tree exposing a ScopeManager and an APIProviders map, and +/// returns the scope plus the element context used to execute actions. +Future<(ScopeManager, BuildContext)> _pumpScope( + WidgetTester tester, _FakeProvider fake) async { + late ScopeManager scope; + await tester.pumpWidget(APIProviders( + providers: {'fake': fake}, + child: MaterialApp( + home: Builder(builder: (context) { + scope = ScopeManager( + DataContext(buildContext: context), + PageData(apiMap: { + 'myApi': YamlMap.wrap( + {'type': 'fake', 'url': 'https://example.com/api'}), + }), + ); + scope.dataContext.addInvokableContext('myApi', APIResponse()); + return DataScopeWidget( + scopeManager: scope, child: const SizedBox(width: 1, height: 1)); + }), + ), + )); + final ctx = tester.element(find.byType(SizedBox)); + return (scope, ctx); +} + +void main() { + late Directory storageDir; + + // AppConfig.isMockResponse() reads GetStorage; construct it before the + // fake-async test zone so GetStorage does not leave a pending timer. + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + storageDir = await Directory.systemTemp.createTemp('ensemble-api-guard-'); + const channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => storageDir.path); + await GetStorage.init(); + }); + + tearDownAll(() async { + const channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + await storageDir.delete(recursive: true); + }); + + testWidgets('child scope dispose does not mark the page disposed', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final scope = ScopeManager(DataContext(buildContext: context), pageData); + final child = scope.createChildScope(); + + child.dispose(); + expect(pageData.isDisposed, isFalse, + reason: 'disposing a child scope must not mark the page disposed'); + + scope.dispose(); + expect(pageData.isDisposed, isTrue); + }); + + testWidgets('API onResponse runs while the page is live', (tester) async { + final fake = _FakeProvider(); + final counting = _CountingAction(); + final (scope, ctx) = await _pumpScope(tester, fake); + + final action = InvokeAPIAction(apiName: 'myApi', onResponse: counting); + final future = InvokeAPIController() + .execute(action, ctx, scope, scope.pageData.apiMap); + + fake.completer.complete(_FakeResponse()..apiState = APIState.loading); + await future; + await tester.pump(); + + expect(counting.count, 1, reason: 'onResponse must run for a live page'); + expect(scope.pageData.isDisposed, isFalse); + scope.dispose(); + }); + + testWidgets('API onError runs while the page is live', (tester) async { + final fake = _FakeProvider(); + final counting = _CountingAction(); + final (scope, ctx) = await _pumpScope(tester, fake); + + final action = InvokeAPIAction(apiName: 'myApi', onError: counting); + final future = InvokeAPIController() + .execute(action, ctx, scope, scope.pageData.apiMap); + + fake.completer.complete(_FakeResponse()..isOkay = false); + await future; + await tester.pump(); + + expect(counting.count, 1, reason: 'onError must run for a live page'); + expect(scope.pageData.isDisposed, isFalse); + scope.dispose(); + }); + + testWidgets('API onResponse is skipped after the page is disposed', + (tester) async { + final fake = _FakeProvider(); + final counting = _CountingAction(); + final (scope, ctx) = await _pumpScope(tester, fake); + + final action = InvokeAPIAction(apiName: 'myApi', onResponse: counting); + final future = InvokeAPIController() + .execute(action, ctx, scope, scope.pageData.apiMap); + + // Simulate navigating away before the in-flight API completes. + scope.dispose(); + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + expect(scope.pageData.isDisposed, isTrue); + + fake.completer.complete(_FakeResponse()..apiState = APIState.loading); + await future; + await tester.pump(); + + expect(counting.count, 0, + reason: 'onResponse must not run once the page is disposed'); + }); + + testWidgets('API onError is skipped after the page is disposed', + (tester) async { + final fake = _FakeProvider(); + final counting = _CountingAction(); + final (scope, ctx) = await _pumpScope(tester, fake); + + final action = InvokeAPIAction(apiName: 'myApi', onError: counting); + final future = InvokeAPIController() + .execute(action, ctx, scope, scope.pageData.apiMap); + + scope.dispose(); + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + final failed = _FakeResponse()..isOkay = false; + fake.completer.complete(failed); + await future; + await tester.pump(); + + expect(counting.count, 0, + reason: 'onError must not run once the page is disposed'); + }); + + testWidgets( + 'live provider events after dispose are ignored (subscription stays)', + (tester) async { + final live = _FakeLiveProvider(); + final counting = _CountingAction(); + late ScopeManager scope; + await tester.pumpWidget(APIProviders( + providers: {'fakeLive': live}, + child: MaterialApp( + home: Builder(builder: (context) { + scope = ScopeManager( + DataContext(buildContext: context), + PageData(apiMap: { + 'liveApi': YamlMap.wrap({ + 'type': 'fakeLive', + 'listenForChanges': true, + 'url': 'https://example.com/sse', + }), + }), + ); + scope.dataContext.addInvokableContext('liveApi', APIResponse()); + return DataScopeWidget( + scopeManager: scope, child: const SizedBox(width: 1, height: 1)); + }), + ), + )); + final ctx = tester.element(find.byType(SizedBox)); + final action = InvokeAPIAction(apiName: 'liveApi', onResponse: counting); + + await InvokeAPIController() + .execute(action, ctx, scope, scope.pageData.apiMap); + await tester.pump(); + expect(counting.count, 1, + reason: 'initial live response runs while the page is mounted'); + + scope.dispose(); + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + + // Simulate a streamed event arriving after the page is gone. The provider + // still holds and can invoke the listener (its subscription is app-level + // and never cancelled), but the framework must ignore the callback. + live.listener!(_FakeResponse()..apiState = APIState.loading); + await tester.pump(); + expect(counting.count, 1, + reason: 'post-dispose live events must be ignored'); + }); +} diff --git a/modules/ensemble/test/ensemble_widget_rebind_test.dart b/modules/ensemble/test/ensemble_widget_rebind_test.dart index d7af1d293..3495a2e40 100644 --- a/modules/ensemble/test/ensemble_widget_rebind_test.dart +++ b/modules/ensemble/test/ensemble_widget_rebind_test.dart @@ -1,784 +1,1091 @@ -import 'dart:async'; - -import 'package:ensemble/framework/data_context.dart'; -import 'package:ensemble/framework/bindings.dart'; -import 'package:ensemble/framework/action.dart'; -import 'package:ensemble/framework/ensemble_widget.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/view/data_scope_widget.dart'; -import 'package:ensemble/framework/view/page.dart' as ensemble_page; -import 'package:ensemble/page_model.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble/widget/divider.dart'; -import 'package:ensemble/action/bottom_sheet_actions.dart'; -import 'package:ensemble/action/dialog_actions.dart'; -import 'package:ensemble/framework/stub/location_manager.dart'; -import 'package:ensemble/widget/helpers/controllers.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokablecontroller.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:yaml/yaml.dart'; - -void main() { - testWidgets('closing a dialog cancels its binding subscriptions', - (tester) async { - final customWidgets = { - 'MyCard': loadYaml(''' -inputs: [label] -body: - Text: - text: card-\${label} -''') as YamlMap, - }; - late ScopeManager scope; - await tester.pumpWidget(MaterialApp( - home: Builder(builder: (context) { - scope = ScopeManager(DataContext(buildContext: context), - PageData(customViewDefinitions: customWidgets)); - final root = scope.buildWidgetFromDefinition(loadYaml(''' -Column: - children: - - Text: - id: source - text: dialog-value -''')); - return DataScopeWidget( - scopeManager: scope, child: Scaffold(body: root)); - }), - )); - await tester.pump(); - final beforeDialog = scope.listenerMap.length; - final context = tester.element(find.byType(Scaffold)); - final dialogAction = ShowDialogAction( - body: loadYaml(''' -MyCard: - id: dialogCard - inputs: - label: \${source.text} -'''), - dismissible: true, - ); - await dialogAction.execute(context, scope); - await tester.pumpAndSettle(); - expect(scope.listenerMap.length, greaterThan(beforeDialog)); - - Navigator.of(scope.openedDialogs.last).pop(); - await tester.pumpAndSettle(); - - expect(scope.listenerMap.length, beforeDialog); - expect(scope.openedDialogs, isEmpty); - - await dialogAction.execute(context, scope); - await tester.pumpAndSettle(); - final source = scope.dataContext.getContextById('source') as Invokable; - InvokableController.setProperty(source, 'text', 'reopened'); - await tester.pump(); - await tester.pump(); - expect(find.text('card-reopened'), findsOneWidget); - Navigator.of(scope.openedDialogs.last).pop(); - await tester.pumpAndSettle(); - expect(scope.listenerMap.length, beforeDialog); - }); - - testWidgets('closing a bottom sheet cancels its binding subscriptions', - (tester) async { - final customWidgets = { - 'MyCard': loadYaml(''' -inputs: [label] -body: - Text: - text: card-\${label} -''') as YamlMap, - }; - late ScopeManager scope; - await tester.pumpWidget(MaterialApp( - home: Builder(builder: (context) { - scope = ScopeManager(DataContext(buildContext: context), - PageData(customViewDefinitions: customWidgets)); - final root = scope.buildWidgetFromDefinition(loadYaml(''' -Column: - children: - - Text: - id: source - text: sheet-value -''')); - return DataScopeWidget( - scopeManager: scope, child: Scaffold(body: root)); - }), - )); - await tester.pump(); - final beforeSheet = scope.listenerMap.length; - final context = tester.element(find.byType(Scaffold)); - await ShowBottomSheetAction( - body: loadYaml(''' -MyCard: - id: sheetCard - inputs: - label: \${source.text} -'''), - payload: {}, - ).execute(context, scope); - await tester.pumpAndSettle(); - expect(scope.listenerMap.length, greaterThan(beforeSheet)); - - Navigator.of(tester.element(find.text('card-sheet-value'))).pop(); - await tester.pumpAndSettle(); - - expect(scope.listenerMap.length, beforeSheet); - }); - - testWidgets('disposing a page scope clears page-owned listener references', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final context = tester.element(find.byType(Placeholder)); - final pageData = PageData(); - final scope = ScopeManager(DataContext(buildContext: context), pageData); - final timer = Timer(const Duration(days: 1), () {}); - scope.addTimer(StartTimerAction(onTimer: DismissDialogAction()), timer); - final controller = EnsembleBoxController(); - PageBindingManager.retainBindingOwner(scope, controller); - expect(PageBindingManager.debugBindingOwnerCount(pageData), 1); - final events = StreamController(); - var delivered = 0; - final subscription = events.stream.listen((_) => delivered++); - pageData.listenerMap[controller] = {0: subscription}; - scope.openedDialogs.add(context); - - scope.dispose(); - events.add(1); - - expect(scope.listenerMap, isEmpty); - expect(scope.openedDialogs, isEmpty); - expect(scope.eventBus.streamController.isClosed, isTrue); - expect(delivered, 0); - expect(timer.isActive, isFalse); - expect(PageBindingManager.debugBindingOwnerCount(pageData), 0, - reason: 'disposing the page must release owner bookkeeping'); - events.close(); - }); - - testWidgets('disposing a child scope does not tear down page resources', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final context = tester.element(find.byType(Placeholder)); - final pageData = PageData(); - final pageScope = - ScopeManager(DataContext(buildContext: context), pageData); - final childScope = pageScope.createChildScope(); - final siblingScope = pageScope.createChildScope(); - final controller = EnsembleBoxController(); - final siblingController = EnsembleBoxController(); - final timer = Timer(const Duration(days: 1), () {}); - pageScope.addTimer(StartTimerAction(onTimer: DismissDialogAction()), timer); - final locationSubscription = Stream.empty().listen((_) {}); - pageScope.addLocationListener(locationSubscription); - pageScope.dataContext.addDataContextById('local', 'page'); - childScope.dataContext.addDataContextById('local', 'child'); - siblingScope.dataContext.addDataContextById('local', 'sibling'); - pageScope.dataContext.addDataContextById('external', 'page'); - final expression = - DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); - pageScope.registerBindingListener( - pageScope, BindingDestination(controller, 'opacity'), expression); - childScope.registerBindingListener( - childScope, BindingDestination(controller, 'testId'), expression); - siblingScope.registerBindingListener( - siblingScope, - BindingDestination(siblingController, 'testId'), - expression); - expect(pageData.listenerMap[controller], hasLength(2)); - expect(pageData.listenerMap[siblingController], hasLength(1)); - PageBindingManager.retainBindingOwner(pageScope, controller); - PageBindingManager.retainBindingOwner(childScope, controller); - PageBindingManager.retainBindingOwner(siblingScope, siblingController); - final events = StreamController(); - var delivered = 0; - final subscription = events.stream.listen((_) => delivered++); - pageData.listenerMap[controller]![0] = subscription; - final externalController = EnsembleBoxController(); - var externalListenerFired = 0; - pageScope.listen( - pageScope, - r'${external}', - destination: BindingDestination(externalController, 'testId'), - onDataChange: (_) => externalListenerFired++, - ); - - expect(pageData.listenerMap[controller], hasLength(3)); - childScope.dispose(); - events.add(1); - pageScope.dispatch(ModelChangeEvent(SimpleBindingSource('external'), 'new', - bindingScope: pageScope)); - siblingScope.dataContext.addDataContextById('local', 'new'); - siblingScope.dispatch(ModelChangeEvent(SimpleBindingSource('local'), 'new', - bindingScope: siblingScope)); - await tester.pump(); - - expect(pageScope.eventBus.streamController.isClosed, isFalse); - expect(pageData.listenerMap, contains(controller)); - expect(pageData.listenerMap[controller], hasLength(2), - reason: - 'child cleanup removes only its binding and keeps page and sibling listeners'); - expect(pageData.listenerMap[siblingController], hasLength(1)); - expect(delivered, 1); - expect(siblingController.testId, 'new', - reason: 'disposing one child must not remove a sibling binding'); - expect(externalListenerFired, 1, - reason: 'disposing a child must not cancel another scope\'s listener'); - expect(timer.isActive, isTrue); - expect(pageData.locationListener, same(locationSubscription)); - expect(PageBindingManager.debugBindingOwnerCount(pageData), 2, - reason: 'only the disposing child owner should be released'); - - pageScope.dispose(); - events.add(2); - await tester.pump(); - expect(pageData.listenerMap, isEmpty); - expect(delivered, 1); - expect(timer.isActive, isFalse); - expect(pageData.locationListener, isNull); - expect(PageBindingManager.debugBindingOwnerCount(pageData), 0); - events.close(); - }); - - testWidgets('replacing a page route disposes its owning page scope', - (tester) async { - late ensemble_page.Page page; - await tester.pumpWidget(MaterialApp( - navigatorKey: Utils.globalAppKey, - home: Builder(builder: (context) { - final pageModel = PageModel.fromYaml(loadYaml(''' -View: - body: - Text: - text: lifecycle-page -''') as YamlMap) as SinglePageModel; - page = ensemble_page.Page( - dataContext: DataContext(buildContext: context), - pageModel: pageModel, - onRendered: () {}, - ); - return page; - }), - )); - await tester.pumpAndSettle(); - final pageScope = page.rootScopeManager!; - expect(pageScope.eventBus.streamController.isClosed, isFalse); - - final pageContext = tester.element(find.byType(ensemble_page.Page)); - Navigator.of(pageContext).pushReplacement( - MaterialPageRoute(builder: (_) => const Placeholder()), - ); - await tester.pumpAndSettle(); - - expect(pageScope.eventBus.streamController.isClosed, isTrue); - expect(pageScope.listenerMap, isEmpty); - }); - - testWidgets('legacy child bindings do not accumulate on parent rebuilds', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: SizedBox())); - final ctx = tester.element(find.byType(SizedBox)); - final scope = ScopeManager(DataContext(buildContext: ctx), PageData()); - final root = scope.buildWidgetFromDefinition(loadYaml(''' -Column: - children: - - Column: - id: box - children: - - Text: - text: \${source.text} - - Text: - id: source - text: initial -''')); - await tester.pumpWidget(MaterialApp( - home: - Scaffold(body: DataScopeWidget(scopeManager: scope, child: root)))); - await tester.pump(); - - final box = scope.dataContext.getContextById('box') as Invokable; - final initialListenerCount = scope.listenerMap.length; - for (var i = 0; i < 10; i++) { - InvokableController.setProperty(box, 'gap', i + 1); - await tester.pump(); - } - - expect(scope.listenerMap.length, initialListenerCount, - reason: - 'rebuilding a legacy container should not retain replaced children'); - final source = scope.dataContext.getContextById('source') as Invokable; - InvokableController.setProperty(source, 'text', 'updated'); - await tester.pump(); - await tester.pump(); - expect(find.text('updated'), findsNWidgets(2)); - }); - - testWidgets('legacy binding stays until the final shared widget owner leaves', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final context = tester.element(find.byType(Placeholder)); - final scope = ScopeManager(DataContext(buildContext: context), PageData()); - final legacyWidget = EnsembleDivider(); - scope.registerBindingListener( - scope, - BindingDestination(legacyWidget, 'gap'), - DataExpression(rawExpression: r'${local}', expressions: [r'${local}']), - ); - - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget( - scopeManager: scope, - child: Row(children: [legacyWidget, legacyWidget]), - ), - )); - expect(scope.listenerMap, contains(legacyWidget)); - - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget(scopeManager: scope, child: legacyWidget), - )); - expect(scope.listenerMap, contains(legacyWidget), - reason: 'disposing one EWidgetState must not cancel its shared peer'); - - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - expect(scope.listenerMap, isNot(contains(legacyWidget)), - reason: 'the last legacy owner should release the binding'); - }); - - testWidgets('legacy binding follows a scope change on the same page', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final context = tester.element(find.byType(Placeholder)); - final pageData = PageData(); - final firstScope = - ScopeManager(DataContext(buildContext: context), pageData); - final secondScope = - ScopeManager(DataContext(buildContext: context), pageData); - firstScope.dataContext.addDataContextById('local', 1.0); - secondScope.dataContext.addDataContextById('local', 2.0); - final widget = EnsembleDivider(); - final expression = - DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); - firstScope.registerBindingListener( - firstScope, BindingDestination(widget, 'gap'), expression); - - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget(scopeManager: firstScope, child: widget), - )); - secondScope.registerBindingListener( - secondScope, BindingDestination(widget, 'gap'), expression); - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget(scopeManager: secondScope, child: widget), - )); - - firstScope.dispatch(ModelChangeEvent(SimpleBindingSource('local'), 3.0, - bindingScope: firstScope)); - secondScope.dispatch(ModelChangeEvent(SimpleBindingSource('local'), 4.0, - bindingScope: secondScope)); - await tester.pump(); - expect(widget.controller.gap, 2.0); - expect(pageData.listenerMap[widget], hasLength(1)); - }); - - testWidgets('legacy scope change does not restore the previous scope binding', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final context = tester.element(find.byType(Placeholder)); - final pageData = PageData(); - final firstScope = - ScopeManager(DataContext(buildContext: context), pageData); - final secondScope = - ScopeManager(DataContext(buildContext: context), pageData); - firstScope.dataContext.addDataContextById('local', 1.0); - final widget = EnsembleDivider(); - final oldSource = - BindingSource.getBindingSources(r'${local}', firstScope.dataContext) - .single; - firstScope.registerBindingListener( - firstScope, - BindingDestination(widget, 'gap'), - DataExpression(rawExpression: r'${local}', expressions: [r'${local}']), - ); - - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget(scopeManager: firstScope, child: widget), - )); - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget(scopeManager: secondScope, child: widget), - )); - - firstScope.dataContext.addDataContextById('local', 3.0); - firstScope - .dispatch(ModelChangeEvent(oldSource, 3.0, bindingScope: firstScope)); - await tester.pump(); - - expect(widget.controller.gap, isNull, - reason: 'a legacy state must not restore/evaluate its previous scope'); - expect(pageData.listenerMap[widget], isNull); - }); - - testWidgets('scope change without replacement drops the old binding', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final context = tester.element(find.byType(Placeholder)); - final pageData = PageData(); - final firstScope = - ScopeManager(DataContext(buildContext: context), pageData); - final secondScope = - ScopeManager(DataContext(buildContext: context), pageData); - firstScope.dataContext.addDataContextById('local', 'first'); - final controller = EnsembleBoxController()..testId = 'seed'; - final expression = - DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); - final oldSource = - BindingSource.getBindingSources(r'${local}', firstScope.dataContext) - .single; - firstScope.registerBindingListener( - firstScope, BindingDestination(controller, 'testId'), expression); - - final probe = _BindingOwnerProbe(controller); - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget(scopeManager: firstScope, child: probe), - )); - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget(scopeManager: secondScope, child: probe), - )); - - firstScope.dataContext.addDataContextById('local', 'stale'); - firstScope.dispatch( - ModelChangeEvent(oldSource, 'stale', bindingScope: firstScope)); - await tester.pump(); - - expect(controller.testId, 'seed'); - expect(pageData.listenerMap[controller], isNull); - }); - - testWidgets('replacing a binding expression cancels the previous source', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final context = tester.element(find.byType(Placeholder)); - final scope = ScopeManager(DataContext(buildContext: context), PageData()); - scope.dataContext.addDataContextById('first', 'initial-first'); - scope.dataContext.addDataContextById('second', 'initial-second'); - final controller = EnsembleBoxController()..testId = 'seed'; - scope.registerBindingListener( - scope, - BindingDestination(controller, 'testId'), - DataExpression(rawExpression: r'${first}', expressions: [r'${first}']), - ); - scope.registerBindingListener( - scope, - BindingDestination(controller, 'testId'), - DataExpression(rawExpression: r'${second}', expressions: [r'${second}']), - ); - expect(scope.listenerMap[controller], hasLength(1)); - - scope.dataContext.addDataContextById('second', 'updated-second'); - final firstSource = - BindingSource.getBindingSources(r'${first}', scope.dataContext).single; - scope.dispatch( - ModelChangeEvent(firstSource, 'ignored', bindingScope: scope)); - expect(controller.testId, 'seed', - reason: 'updates from the replaced source must no longer be observed'); - - final secondSource = - BindingSource.getBindingSources(r'${second}', scope.dataContext).single; - scope.dispatch( - ModelChangeEvent(secondSource, 'updated-second', bindingScope: scope)); - await tester.pump(); - expect(controller.testId, 'updated-second'); - - scope.removeBindingListenersForScope(controller, scope); - expect(scope.listenerMap[controller], isNull); - }); - - testWidgets('new widget binding stays until the final shared owner leaves', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final context = tester.element(find.byType(Placeholder)); - final scope = ScopeManager(DataContext(buildContext: context), PageData()); - final controller = EnsembleBoxController(); - scope.registerBindingListener( - scope, - BindingDestination(controller, 'testId'), - DataExpression(rawExpression: r'${local}', expressions: [r'${local}']), - ); - - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget( - scopeManager: scope, - child: Row(children: [ - _BindingOwnerProbe(controller), - _BindingOwnerProbe(controller), - ]), - ), - )); - expect(scope.listenerMap, contains(controller)); - - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget( - scopeManager: scope, - child: _BindingOwnerProbe(controller), - ), - )); - expect(scope.listenerMap, contains(controller), - reason: 'disposing one owner must preserve the shared controller'); - - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - expect(scope.listenerMap, isNot(contains(controller))); - }); - - testWidgets('restoration fills in a missing property subscription', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final context = tester.element(find.byType(Placeholder)); - final scope = ScopeManager(DataContext(buildContext: context), PageData()); - scope.dataContext.addDataContextById('local', 'initial'); - final controller = EnsembleBoxController(); - final expression = - DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); - scope.registerBindingListener( - scope, BindingDestination(controller, 'testId'), expression); - scope.registerBindingListener( - scope, BindingDestination(controller, 'opacity'), expression); - expect(scope.listenerMap[controller], hasLength(2)); - - final source = - BindingSource.getBindingSources(r'${local}', scope.dataContext).single; - final missingHash = scope.getHash( - destinationSetter: 'testId', source: source, scopeManager: scope); - scope.listenerMap[controller]!.remove(missingHash)!.cancel(); - expect(scope.listenerMap[controller], hasLength(1)); - - scope.restoreBindingListeners(controller); - expect(scope.listenerMap[controller], hasLength(2)); - }); - - testWidgets('custom widget with id keeps input bindings after parent rebuild', - (tester) async { - final customWidgets = { - 'MyCard': loadYaml(''' -inputs: [label] -body: - Text: - text: card-\${label} -''') as YamlMap, - }; - await tester.pumpWidget(const MaterialApp(home: SizedBox())); - final ctx = tester.element(find.byType(SizedBox)); - final scope = ScopeManager(DataContext(buildContext: ctx), - PageData(customViewDefinitions: customWidgets)); - final root = scope.buildWidgetFromDefinition(loadYaml(''' -Column: - children: - - Text: - id: source - text: first - - Column: - id: box - children: - - MyCard: - id: card - inputs: - label: \${source.text} -''')); - await tester.pumpWidget(MaterialApp( - home: - Scaffold(body: DataScopeWidget(scopeManager: scope, child: root)))); - await tester.pump(); - expect(find.text('card-first'), findsOneWidget); - - final box = scope.dataContext.getContextById('box') as Invokable; - final source = scope.dataContext.getContextById('source') as Invokable; - InvokableController.setProperty(box, 'gap', 8); - await tester.pump(); - await tester.pump(); - - InvokableController.setProperty(source, 'text', 'second'); - await tester.pump(); - await tester.pump(); - - expect(find.text('card-second'), findsOneWidget, - reason: - 'custom widget input binding should use the current child scope'); - }); - - testWidgets('scope switch on the same page preserves controller bindings', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final buildContext = tester.element(find.byType(Placeholder)); - final pageData = PageData(); - final firstScope = - ScopeManager(DataContext(buildContext: buildContext), pageData); - final secondScope = - ScopeManager(DataContext(buildContext: buildContext), pageData); - final controller = EnsembleBoxController(); - final widget = _BindingOwnerProbe(controller); - firstScope.dataContext.addDataContextById('local', 'first'); - secondScope.dataContext.addDataContextById('local', 'second'); - final expression = - DataExpression(rawExpression: '\${local}', expressions: ['\${local}']); - firstScope.registerBindingListener( - firstScope, BindingDestination(controller, 'testId'), expression); - - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget(scopeManager: firstScope, child: widget), - )); - // YAML bindings for the new model are registered before its state builds. - secondScope.registerBindingListener( - secondScope, BindingDestination(controller, 'testId'), expression); - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget(scopeManager: secondScope, child: widget), - )); - expect(pageData.listenerMap, contains(controller)); - expect(pageData.listenerMap[controller], hasLength(1)); - secondScope.dataContext.addDataContextById('local', 'new'); - secondScope.dispatch(ModelChangeEvent(DeferredBindingSource('local'), 'new', - bindingScope: secondScope)); - await tester.pump(); - expect(controller.testId, 'new'); - firstScope.dataContext.addDataContextById('local', 'stale'); - firstScope.dispatch(ModelChangeEvent( - DeferredBindingSource('local'), 'stale', - bindingScope: firstScope)); - await tester.pump(); - expect(controller.testId, 'new'); - }); - - testWidgets('replacing a controller removes its old binding entry', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final buildContext = tester.element(find.byType(Placeholder)); - final scope = - ScopeManager(DataContext(buildContext: buildContext), PageData()); - final oldController = EnsembleBoxController(); - final newController = EnsembleBoxController(); - scope.listenerMap[oldController] = {}; - - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget( - scopeManager: scope, - child: _BindingOwnerProbe(oldController, key: const ValueKey('probe')), - ), - )); - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget( - scopeManager: scope, - child: _BindingOwnerProbe(newController, key: const ValueKey('probe')), - ), - )); - - expect(scope.listenerMap, isNot(contains(oldController))); - }); - - testWidgets( - 'templated custom widget keeps its input binding after parent hide/show', - (tester) async { - final customWidgets = { - 'MyCard': loadYaml(''' -inputs: [label] -body: - Text: - text: card-\${label} -''') as YamlMap, - }; - - await tester.pumpWidget(const MaterialApp(home: SizedBox())); - final ctx = tester.element(find.byType(SizedBox)); - final scope = ScopeManager(DataContext(buildContext: ctx), - PageData(customViewDefinitions: customWidgets)); - final root = scope.buildWidgetFromDefinition(loadYaml(''' -Column: - children: - - Text: - id: src - text: a - - Column: - id: box - item-template: - data: \${[1]} - name: it - template: - MyCard: - inputs: - label: \${src.text} -''')); - await tester.pumpWidget(MaterialApp( - home: - Scaffold(body: DataScopeWidget(scopeManager: scope, child: root)))); - await tester.pump(); - expect(find.text('card-a'), findsOneWidget); - - final src = scope.dataContext.getContextById('src') as Invokable; - final box = scope.dataContext.getContextById('box') as Invokable; - - Future settle() async { - await tester.pump(); - await tester.pump(); - } - - InvokableController.setProperty(src, 'text', 'b'); - await settle(); - expect(find.text('card-b'), findsOneWidget); - - InvokableController.setProperty(box, 'visible', false); - await settle(); - expect(find.text('card-b'), findsNothing); - InvokableController.setProperty(box, 'visible', true); - await settle(); - expect(find.text('card-b'), findsOneWidget); - - InvokableController.setProperty(src, 'text', 'c'); - await settle(); - expect(find.text('card-c'), findsOneWidget, - reason: 'custom widget input binding lost after hide/show'); - }); - - testWidgets('disposing a widget cancels its direct listen() subscriptions', - (tester) async { - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - final context = tester.element(find.byType(Placeholder)); - final scope = ScopeManager(DataContext(buildContext: context), PageData()); - scope.dataContext.addDataContextById('local', 1.0); - final widget = EnsembleDivider(); - var fired = 0; - - // Conditional/TabBar subscribe through listen() directly, bypassing - // registerBindingListener. - scope.listen( - scope, - r'${local}', - destination: BindingDestination(widget, 'gap'), - onDataChange: (_) => fired++, - ); - await tester.pumpWidget(MaterialApp( - home: DataScopeWidget(scopeManager: scope, child: widget), - )); - - final source = - BindingSource.getBindingSources(r'${local}', scope.dataContext).single; - scope.dispatch(ModelChangeEvent(source, 2.0, bindingScope: scope)); - await tester.pump(); - expect(fired, 1, reason: 'listener should fire while mounted'); - - await tester.pumpWidget(const MaterialApp(home: Placeholder())); - scope.dispatch(ModelChangeEvent(source, 3.0, bindingScope: scope)); - await tester.pump(); - expect(fired, 1, - reason: 'direct listen() subscriptions must be cancelled on dispose'); - expect(scope.listenerMap, isNot(contains(widget))); - }); -} - -class _BindingOwnerProbe extends EnsembleWidget { - const _BindingOwnerProbe(super.controller, {super.key}); - - @override - State<_BindingOwnerProbe> createState() => _BindingOwnerProbeState(); -} - -class _BindingOwnerProbeState extends EnsembleWidgetState<_BindingOwnerProbe> { - @override - Widget buildWidget(BuildContext context) => const SizedBox(); -} +import 'dart:async'; + +import 'package:ensemble/framework/data_context.dart'; +import 'package:ensemble/framework/bindings.dart'; +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/ensemble_widget.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/view/data_scope_widget.dart'; +import 'package:ensemble/framework/view/page.dart' as ensemble_page; +import 'package:ensemble/page_model.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/divider.dart'; +import 'package:ensemble/action/bottom_sheet_actions.dart'; +import 'package:ensemble/action/dialog_actions.dart'; +import 'package:ensemble/framework/stub/location_manager.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokablecontroller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaml/yaml.dart'; + +void main() { + testWidgets('storage updates reach only matching bindings and direct listeners', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + final child = scope.createChildScope(); + final target = EnsembleBoxController(); + final other = EnsembleBoxController(); + var targetUpdates = 0; + var otherUpdates = 0; + var directUpdates = 0; + final deliveryOrder = []; + + child.listen(child, r'${ensemble.storage.target}', + destination: BindingDestination(target, 'testId'), + onDataChange: (_) { + targetUpdates++; + deliveryOrder.add('binding'); + }); + scope.listen(scope, r'${ensemble.storage.other}', + destination: BindingDestination(other, 'testId'), + onDataChange: (_) => otherUpdates++); + final direct = scope.eventBus.on().listen((event) { + if (event.source is StorageBindingSource) { + directUpdates++; + deliveryOrder.add('direct'); + } + }); + + scope.eventBus.fire(ModelChangeEvent(StorageBindingSource('target'), 'first')); + await tester.pump(); + expect(targetUpdates, 1); + expect(otherUpdates, 0); + expect(directUpdates, 1); + expect(deliveryOrder, ['binding', 'direct']); + + scope.dispatch(ModelChangeEvent(SimpleBindingSource('target'), 'ignored')); + scope.dispatch(ModelChangeEvent(StorageBindingSource('target'), 'scoped', + bindingScope: scope)); + await tester.pump(); + expect(targetUpdates, 1); + expect(directUpdates, 2); + + child.removeBindingListeners(target); + child.listen(child, r'${ensemble.storage.other}', + destination: BindingDestination(target, 'testId'), + onDataChange: (_) => targetUpdates++); + scope.dispatch(ModelChangeEvent(StorageBindingSource('target'), 'second')); + scope.dispatch(ModelChangeEvent(StorageBindingSource('other'), 'third')); + await tester.pump(); + expect(targetUpdates, 2); + expect(otherUpdates, 1); + expect(directUpdates, 4); + + child.removeBindingListeners(target); + scope.removeBindingListeners(other); + direct.cancel(); + scope.dispose(); + }); + + testWidgets( + 'binding registered after a change is not notified by the earlier change', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + final first = EnsembleBoxController(); + final second = EnsembleBoxController(); + var firstUpdates = 0; + var secondUpdates = 0; + + scope.listen(scope, r'${ensemble.storage.target}', + destination: BindingDestination(first, 'testId'), + onDataChange: (_) => firstUpdates++); + + // Fire the change, then register a second binding for the same model before + // the asynchronous EventBus delivers the event. + scope.dispatch(ModelChangeEvent(StorageBindingSource('target'), 'first')); + scope.listen(scope, r'${ensemble.storage.target}', + destination: BindingDestination(second, 'testId'), + onDataChange: (_) => secondUpdates++); + + await tester.pump(); + expect(firstUpdates, 1, reason: 'the subscribed binding must be notified'); + expect(secondUpdates, 0, + reason: + 'a binding registered after the event was fired must not receive it'); + scope.dispose(); + }); + + testWidgets( + 'binding registered after a change for an unheard model is not notified', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + final other = EnsembleBoxController(); + final target = EnsembleBoxController(); + var targetUpdates = 0; + var directDelivered = 0; + + // Establish a listener for a different model, then fire a change for a + // model that currently has no binding listener. + scope.listen(scope, r'${ensemble.storage.other}', + destination: BindingDestination(other, 'testId'), + onDataChange: (_) {}); + final direct = scope.eventBus.on().listen((event) { + if (event.source is StorageBindingSource && + event.source.modelId == 'target') { + directDelivered++; + } + }); + scope.dispatch(ModelChangeEvent(StorageBindingSource('target'), 'stale')); + scope.listen(scope, r'${ensemble.storage.target}', + destination: BindingDestination(target, 'testId'), + onDataChange: (_) => targetUpdates++); + + await tester.pump(); + expect(directDelivered, 1, + reason: 'positive control: the event must have been delivered'); + expect(targetUpdates, 0, + reason: 'a late binding must not receive an event fired before it'); + direct.cancel(); + scope.dispose(); + }); + + testWidgets( + 'binding callbacks keep their order relative to interleaved direct listeners', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + final first = EnsembleBoxController(); + final second = EnsembleBoxController(); + final order = []; + + scope.listen(scope, r'${ensemble.storage.target}', + destination: BindingDestination(first, 'testId'), + onDataChange: (_) => order.add('bindingA')); + final direct = scope.eventBus.on().listen((event) { + if (event.source is StorageBindingSource) { + order.add('direct'); + } + }); + scope.listen(scope, r'${ensemble.storage.target}', + destination: BindingDestination(second, 'testId'), + onDataChange: (_) => order.add('bindingB')); + + scope.dispatch(ModelChangeEvent(StorageBindingSource('target'), 'x')); + await tester.pump(); + + expect(order, ['bindingA', 'direct', 'bindingB'], + reason: 'delivery must follow listener registration order'); + direct.cancel(); + scope.dispose(); + }); + + testWidgets('closing a dialog cancels its binding subscriptions', + (tester) async { + final customWidgets = { + 'MyCard': loadYaml(''' +inputs: [label] +body: + Text: + text: card-\${label} +''') as YamlMap, + }; + late ScopeManager scope; + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (context) { + scope = ScopeManager(DataContext(buildContext: context), + PageData(customViewDefinitions: customWidgets)); + final root = scope.buildWidgetFromDefinition(loadYaml(''' +Column: + children: + - Text: + id: source + text: dialog-value +''')); + return DataScopeWidget( + scopeManager: scope, child: Scaffold(body: root)); + }), + )); + await tester.pump(); + final beforeDialog = scope.listenerMap.length; + final context = tester.element(find.byType(Scaffold)); + final dialogAction = ShowDialogAction( + body: loadYaml(''' +MyCard: + id: dialogCard + inputs: + label: \${source.text} +'''), + dismissible: true, + ); + await dialogAction.execute(context, scope); + await tester.pumpAndSettle(); + expect(scope.listenerMap.length, greaterThan(beforeDialog)); + + Navigator.of(scope.openedDialogs.last).pop(); + await tester.pumpAndSettle(); + + expect(scope.listenerMap.length, beforeDialog); + expect(scope.openedDialogs, isEmpty); + + await dialogAction.execute(context, scope); + await tester.pumpAndSettle(); + final source = scope.dataContext.getContextById('source') as Invokable; + InvokableController.setProperty(source, 'text', 'reopened'); + await tester.pump(); + await tester.pump(); + expect(find.text('card-reopened'), findsOneWidget); + Navigator.of(scope.openedDialogs.last).pop(); + await tester.pumpAndSettle(); + expect(scope.listenerMap.length, beforeDialog); + }); + + testWidgets('closing a bottom sheet cancels its binding subscriptions', + (tester) async { + final customWidgets = { + 'MyCard': loadYaml(''' +inputs: [label] +body: + Text: + text: card-\${label} +''') as YamlMap, + }; + late ScopeManager scope; + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (context) { + scope = ScopeManager(DataContext(buildContext: context), + PageData(customViewDefinitions: customWidgets)); + final root = scope.buildWidgetFromDefinition(loadYaml(''' +Column: + children: + - Text: + id: source + text: sheet-value +''')); + return DataScopeWidget( + scopeManager: scope, child: Scaffold(body: root)); + }), + )); + await tester.pump(); + final beforeSheet = scope.listenerMap.length; + final context = tester.element(find.byType(Scaffold)); + await ShowBottomSheetAction( + body: loadYaml(''' +MyCard: + id: sheetCard + inputs: + label: \${source.text} +'''), + payload: {}, + ).execute(context, scope); + await tester.pumpAndSettle(); + expect(scope.listenerMap.length, greaterThan(beforeSheet)); + + Navigator.of(tester.element(find.text('card-sheet-value'))).pop(); + await tester.pumpAndSettle(); + + expect(scope.listenerMap.length, beforeSheet); + }); + + testWidgets('disposing a page scope clears page-owned listener references', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final scope = ScopeManager(DataContext(buildContext: context), pageData); + final timer = Timer(const Duration(days: 1), () {}); + scope.addTimer(StartTimerAction(onTimer: DismissDialogAction()), timer); + final controller = EnsembleBoxController(); + PageBindingManager.retainBindingOwner(scope, controller); + expect(PageBindingManager.debugBindingOwnerCount(pageData), 1); + final events = StreamController(); + var delivered = 0; + final subscription = events.stream.listen((_) => delivered++); + pageData.listenerMap[controller] = {0: subscription}; + scope.openedDialogs.add(context); + + scope.dispose(); + events.add(1); + + expect(scope.listenerMap, isEmpty); + expect(scope.openedDialogs, isEmpty); + expect(scope.eventBus.streamController.isClosed, isTrue); + expect(delivered, 0); + expect(timer.isActive, isFalse); + expect(PageBindingManager.debugBindingOwnerCount(pageData), 0, + reason: 'disposing the page must release owner bookkeeping'); + events.close(); + }); + + testWidgets('disposing a child scope does not tear down page resources', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final pageScope = + ScopeManager(DataContext(buildContext: context), pageData); + final childScope = pageScope.createChildScope(); + final siblingScope = pageScope.createChildScope(); + final controller = EnsembleBoxController(); + final siblingController = EnsembleBoxController(); + final timer = Timer(const Duration(days: 1), () {}); + pageScope.addTimer(StartTimerAction(onTimer: DismissDialogAction()), timer); + final locationSubscription = Stream.empty().listen((_) {}); + pageScope.addLocationListener(locationSubscription); + pageScope.dataContext.addDataContextById('local', 'page'); + childScope.dataContext.addDataContextById('local', 'child'); + siblingScope.dataContext.addDataContextById('local', 'sibling'); + pageScope.dataContext.addDataContextById('external', 'page'); + final expression = + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); + pageScope.registerBindingListener( + pageScope, BindingDestination(controller, 'opacity'), expression); + childScope.registerBindingListener( + childScope, BindingDestination(controller, 'testId'), expression); + siblingScope.registerBindingListener( + siblingScope, + BindingDestination(siblingController, 'testId'), + expression); + expect(pageData.listenerMap[controller], hasLength(2)); + expect(pageData.listenerMap[siblingController], hasLength(1)); + PageBindingManager.retainBindingOwner(pageScope, controller); + PageBindingManager.retainBindingOwner(childScope, controller); + PageBindingManager.retainBindingOwner(siblingScope, siblingController); + final events = StreamController(); + var delivered = 0; + final subscription = events.stream.listen((_) => delivered++); + pageData.listenerMap[controller]![0] = subscription; + final externalController = EnsembleBoxController(); + var externalListenerFired = 0; + pageScope.listen( + pageScope, + r'${external}', + destination: BindingDestination(externalController, 'testId'), + onDataChange: (_) => externalListenerFired++, + ); + + expect(pageData.listenerMap[controller], hasLength(3)); + childScope.dispose(); + events.add(1); + pageScope.dispatch(ModelChangeEvent(SimpleBindingSource('external'), 'new', + bindingScope: pageScope)); + siblingScope.dataContext.addDataContextById('local', 'new'); + siblingScope.dispatch(ModelChangeEvent(SimpleBindingSource('local'), 'new', + bindingScope: siblingScope)); + await tester.pump(); + + expect(pageScope.eventBus.streamController.isClosed, isFalse); + expect(pageData.listenerMap, contains(controller)); + expect(pageData.listenerMap[controller], hasLength(2), + reason: + 'child cleanup removes only its binding and keeps page and sibling listeners'); + expect(pageData.listenerMap[siblingController], hasLength(1)); + expect(delivered, 1); + expect(siblingController.testId, 'new', + reason: 'disposing one child must not remove a sibling binding'); + expect(externalListenerFired, 1, + reason: 'disposing a child must not cancel another scope\'s listener'); + expect(timer.isActive, isTrue); + expect(pageData.locationListener, same(locationSubscription)); + expect(PageBindingManager.debugBindingOwnerCount(pageData), 2, + reason: 'only the disposing child owner should be released'); + + pageScope.dispose(); + events.add(2); + await tester.pump(); + expect(pageData.listenerMap, isEmpty); + expect(delivered, 1); + expect(timer.isActive, isFalse); + expect(pageData.locationListener, isNull); + expect(PageBindingManager.debugBindingOwnerCount(pageData), 0); + events.close(); + }); + + testWidgets('replacing a page route disposes its owning page scope', + (tester) async { + late ensemble_page.Page page; + await tester.pumpWidget(MaterialApp( + navigatorKey: Utils.globalAppKey, + home: Builder(builder: (context) { + final pageModel = PageModel.fromYaml(loadYaml(''' +View: + body: + Text: + text: lifecycle-page +''') as YamlMap) as SinglePageModel; + page = ensemble_page.Page( + dataContext: DataContext(buildContext: context), + pageModel: pageModel, + onRendered: () {}, + ); + return page; + }), + )); + await tester.pumpAndSettle(); + final pageScope = page.rootScopeManager!; + expect(pageScope.eventBus.streamController.isClosed, isFalse); + + final pageContext = tester.element(find.byType(ensemble_page.Page)); + Navigator.of(pageContext).pushReplacement( + MaterialPageRoute(builder: (_) => const Placeholder()), + ); + await tester.pumpAndSettle(); + + expect(pageScope.eventBus.streamController.isClosed, isTrue); + expect(pageScope.listenerMap, isEmpty); + }); + + testWidgets('legacy child bindings do not accumulate on parent rebuilds', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final ctx = tester.element(find.byType(SizedBox)); + final scope = ScopeManager(DataContext(buildContext: ctx), PageData()); + final root = scope.buildWidgetFromDefinition(loadYaml(''' +Column: + children: + - Column: + id: box + children: + - Text: + text: \${source.text} + - Text: + id: source + text: initial +''')); + await tester.pumpWidget(MaterialApp( + home: + Scaffold(body: DataScopeWidget(scopeManager: scope, child: root)))); + await tester.pump(); + + final box = scope.dataContext.getContextById('box') as Invokable; + final initialListenerCount = scope.listenerMap.length; + for (var i = 0; i < 10; i++) { + InvokableController.setProperty(box, 'gap', i + 1); + await tester.pump(); + } + + expect(scope.listenerMap.length, initialListenerCount, + reason: + 'rebuilding a legacy container should not retain replaced children'); + final source = scope.dataContext.getContextById('source') as Invokable; + InvokableController.setProperty(source, 'text', 'updated'); + await tester.pump(); + await tester.pump(); + expect(find.text('updated'), findsNWidgets(2)); + }); + + testWidgets('legacy binding stays until the final shared widget owner leaves', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + final legacyWidget = EnsembleDivider(); + scope.registerBindingListener( + scope, + BindingDestination(legacyWidget, 'gap'), + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']), + ); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget( + scopeManager: scope, + child: Row(children: [legacyWidget, legacyWidget]), + ), + )); + expect(scope.listenerMap, contains(legacyWidget)); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: scope, child: legacyWidget), + )); + expect(scope.listenerMap, contains(legacyWidget), + reason: 'disposing one EWidgetState must not cancel its shared peer'); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + expect(scope.listenerMap, isNot(contains(legacyWidget)), + reason: 'the last legacy owner should release the binding'); + }); + + testWidgets('legacy binding follows a scope change on the same page', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final firstScope = + ScopeManager(DataContext(buildContext: context), pageData); + final secondScope = + ScopeManager(DataContext(buildContext: context), pageData); + firstScope.dataContext.addDataContextById('local', 1.0); + secondScope.dataContext.addDataContextById('local', 2.0); + final widget = EnsembleDivider(); + final expression = + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); + firstScope.registerBindingListener( + firstScope, BindingDestination(widget, 'gap'), expression); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: firstScope, child: widget), + )); + secondScope.registerBindingListener( + secondScope, BindingDestination(widget, 'gap'), expression); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: secondScope, child: widget), + )); + + firstScope.dispatch(ModelChangeEvent(SimpleBindingSource('local'), 3.0, + bindingScope: firstScope)); + secondScope.dispatch(ModelChangeEvent(SimpleBindingSource('local'), 4.0, + bindingScope: secondScope)); + await tester.pump(); + expect(widget.controller.gap, 2.0); + expect(pageData.listenerMap[widget], hasLength(1)); + }); + + testWidgets('legacy scope change does not restore the previous scope binding', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final firstScope = + ScopeManager(DataContext(buildContext: context), pageData); + final secondScope = + ScopeManager(DataContext(buildContext: context), pageData); + firstScope.dataContext.addDataContextById('local', 1.0); + final widget = EnsembleDivider(); + final oldSource = + BindingSource.getBindingSources(r'${local}', firstScope.dataContext) + .single; + firstScope.registerBindingListener( + firstScope, + BindingDestination(widget, 'gap'), + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']), + ); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: firstScope, child: widget), + )); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: secondScope, child: widget), + )); + + firstScope.dataContext.addDataContextById('local', 3.0); + firstScope + .dispatch(ModelChangeEvent(oldSource, 3.0, bindingScope: firstScope)); + await tester.pump(); + + expect(widget.controller.gap, isNull, + reason: 'a legacy state must not restore/evaluate its previous scope'); + expect(pageData.listenerMap[widget], isNull); + }); + + testWidgets('scope change without replacement drops the old binding', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final firstScope = + ScopeManager(DataContext(buildContext: context), pageData); + final secondScope = + ScopeManager(DataContext(buildContext: context), pageData); + firstScope.dataContext.addDataContextById('local', 'first'); + final controller = EnsembleBoxController()..testId = 'seed'; + final expression = + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); + final oldSource = + BindingSource.getBindingSources(r'${local}', firstScope.dataContext) + .single; + firstScope.registerBindingListener( + firstScope, BindingDestination(controller, 'testId'), expression); + + final probe = _BindingOwnerProbe(controller); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: firstScope, child: probe), + )); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: secondScope, child: probe), + )); + + firstScope.dataContext.addDataContextById('local', 'stale'); + firstScope.dispatch( + ModelChangeEvent(oldSource, 'stale', bindingScope: firstScope)); + await tester.pump(); + + expect(controller.testId, 'seed'); + expect(pageData.listenerMap[controller], isNull); + }); + + testWidgets('replacing a binding expression cancels the previous source', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + scope.dataContext.addDataContextById('first', 'initial-first'); + scope.dataContext.addDataContextById('second', 'initial-second'); + final controller = EnsembleBoxController()..testId = 'seed'; + scope.registerBindingListener( + scope, + BindingDestination(controller, 'testId'), + DataExpression(rawExpression: r'${first}', expressions: [r'${first}']), + ); + scope.registerBindingListener( + scope, + BindingDestination(controller, 'testId'), + DataExpression(rawExpression: r'${second}', expressions: [r'${second}']), + ); + expect(scope.listenerMap[controller], hasLength(1)); + + scope.dataContext.addDataContextById('second', 'updated-second'); + final firstSource = + BindingSource.getBindingSources(r'${first}', scope.dataContext).single; + scope.dispatch( + ModelChangeEvent(firstSource, 'ignored', bindingScope: scope)); + expect(controller.testId, 'seed', + reason: 'updates from the replaced source must no longer be observed'); + + final secondSource = + BindingSource.getBindingSources(r'${second}', scope.dataContext).single; + scope.dispatch( + ModelChangeEvent(secondSource, 'updated-second', bindingScope: scope)); + await tester.pump(); + expect(controller.testId, 'updated-second'); + + scope.removeBindingListenersForScope(controller, scope); + expect(scope.listenerMap[controller], isNull); + }); + + testWidgets('new widget binding stays until the final shared owner leaves', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + final controller = EnsembleBoxController(); + scope.registerBindingListener( + scope, + BindingDestination(controller, 'testId'), + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']), + ); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget( + scopeManager: scope, + child: Row(children: [ + _BindingOwnerProbe(controller), + _BindingOwnerProbe(controller), + ]), + ), + )); + expect(scope.listenerMap, contains(controller)); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget( + scopeManager: scope, + child: _BindingOwnerProbe(controller), + ), + )); + expect(scope.listenerMap, contains(controller), + reason: 'disposing one owner must preserve the shared controller'); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + expect(scope.listenerMap, isNot(contains(controller))); + }); + + testWidgets('restoration fills in a missing property subscription', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + scope.dataContext.addDataContextById('local', 'initial'); + final controller = EnsembleBoxController(); + final expression = + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); + scope.registerBindingListener( + scope, BindingDestination(controller, 'testId'), expression); + scope.registerBindingListener( + scope, BindingDestination(controller, 'opacity'), expression); + expect(scope.listenerMap[controller], hasLength(2)); + + final source = + BindingSource.getBindingSources(r'${local}', scope.dataContext).single; + final missingHash = scope.getHash( + destinationSetter: 'testId', source: source, scopeManager: scope); + scope.listenerMap[controller]!.remove(missingHash)!.cancel(); + expect(scope.listenerMap[controller], hasLength(1)); + + scope.restoreBindingListeners(controller); + expect(scope.listenerMap[controller], hasLength(2)); + }); + + testWidgets('custom widget with id keeps input bindings after parent rebuild', + (tester) async { + final customWidgets = { + 'MyCard': loadYaml(''' +inputs: [label] +body: + Text: + text: card-\${label} +''') as YamlMap, + }; + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final ctx = tester.element(find.byType(SizedBox)); + final scope = ScopeManager(DataContext(buildContext: ctx), + PageData(customViewDefinitions: customWidgets)); + final root = scope.buildWidgetFromDefinition(loadYaml(''' +Column: + children: + - Text: + id: source + text: first + - Column: + id: box + children: + - MyCard: + id: card + inputs: + label: \${source.text} +''')); + await tester.pumpWidget(MaterialApp( + home: + Scaffold(body: DataScopeWidget(scopeManager: scope, child: root)))); + await tester.pump(); + expect(find.text('card-first'), findsOneWidget); + + final box = scope.dataContext.getContextById('box') as Invokable; + final source = scope.dataContext.getContextById('source') as Invokable; + InvokableController.setProperty(box, 'gap', 8); + await tester.pump(); + await tester.pump(); + + InvokableController.setProperty(source, 'text', 'second'); + await tester.pump(); + await tester.pump(); + + expect(find.text('card-second'), findsOneWidget, + reason: + 'custom widget input binding should use the current child scope'); + }); + + testWidgets('scope switch on the same page preserves controller bindings', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final buildContext = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final firstScope = + ScopeManager(DataContext(buildContext: buildContext), pageData); + final secondScope = + ScopeManager(DataContext(buildContext: buildContext), pageData); + final controller = EnsembleBoxController(); + final widget = _BindingOwnerProbe(controller); + firstScope.dataContext.addDataContextById('local', 'first'); + secondScope.dataContext.addDataContextById('local', 'second'); + final expression = + DataExpression(rawExpression: '\${local}', expressions: ['\${local}']); + firstScope.registerBindingListener( + firstScope, BindingDestination(controller, 'testId'), expression); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: firstScope, child: widget), + )); + // YAML bindings for the new model are registered before its state builds. + secondScope.registerBindingListener( + secondScope, BindingDestination(controller, 'testId'), expression); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: secondScope, child: widget), + )); + expect(pageData.listenerMap, contains(controller)); + expect(pageData.listenerMap[controller], hasLength(1)); + secondScope.dataContext.addDataContextById('local', 'new'); + secondScope.dispatch(ModelChangeEvent(DeferredBindingSource('local'), 'new', + bindingScope: secondScope)); + await tester.pump(); + expect(controller.testId, 'new'); + firstScope.dataContext.addDataContextById('local', 'stale'); + firstScope.dispatch(ModelChangeEvent( + DeferredBindingSource('local'), 'stale', + bindingScope: firstScope)); + await tester.pump(); + expect(controller.testId, 'new'); + }); + + testWidgets('replacing a controller removes its old binding entry', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final buildContext = tester.element(find.byType(Placeholder)); + final scope = + ScopeManager(DataContext(buildContext: buildContext), PageData()); + final oldController = EnsembleBoxController(); + final newController = EnsembleBoxController(); + scope.listenerMap[oldController] = {}; + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget( + scopeManager: scope, + child: _BindingOwnerProbe(oldController, key: const ValueKey('probe')), + ), + )); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget( + scopeManager: scope, + child: _BindingOwnerProbe(newController, key: const ValueKey('probe')), + ), + )); + + expect(scope.listenerMap, isNot(contains(oldController))); + }); + + testWidgets( + 'templated custom widget keeps its input binding after parent hide/show', + (tester) async { + final customWidgets = { + 'MyCard': loadYaml(''' +inputs: [label] +body: + Text: + text: card-\${label} +''') as YamlMap, + }; + + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final ctx = tester.element(find.byType(SizedBox)); + final scope = ScopeManager(DataContext(buildContext: ctx), + PageData(customViewDefinitions: customWidgets)); + final root = scope.buildWidgetFromDefinition(loadYaml(''' +Column: + children: + - Text: + id: src + text: a + - Column: + id: box + item-template: + data: \${[1]} + name: it + template: + MyCard: + inputs: + label: \${src.text} +''')); + await tester.pumpWidget(MaterialApp( + home: + Scaffold(body: DataScopeWidget(scopeManager: scope, child: root)))); + await tester.pump(); + expect(find.text('card-a'), findsOneWidget); + + final src = scope.dataContext.getContextById('src') as Invokable; + final box = scope.dataContext.getContextById('box') as Invokable; + + Future settle() async { + await tester.pump(); + await tester.pump(); + } + + InvokableController.setProperty(src, 'text', 'b'); + await settle(); + expect(find.text('card-b'), findsOneWidget); + + InvokableController.setProperty(box, 'visible', false); + await settle(); + expect(find.text('card-b'), findsNothing); + InvokableController.setProperty(box, 'visible', true); + await settle(); + expect(find.text('card-b'), findsOneWidget); + + InvokableController.setProperty(src, 'text', 'c'); + await settle(); + expect(find.text('card-c'), findsOneWidget, + reason: 'custom widget input binding lost after hide/show'); + }); + + testWidgets('disposing a widget cancels its direct listen() subscriptions', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + scope.dataContext.addDataContextById('local', 1.0); + final widget = EnsembleDivider(); + var fired = 0; + + // Conditional/TabBar subscribe through listen() directly, bypassing + // registerBindingListener. + scope.listen( + scope, + r'${local}', + destination: BindingDestination(widget, 'gap'), + onDataChange: (_) => fired++, + ); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: scope, child: widget), + )); + + final source = + BindingSource.getBindingSources(r'${local}', scope.dataContext).single; + scope.dispatch(ModelChangeEvent(source, 2.0, bindingScope: scope)); + await tester.pump(); + expect(fired, 1, reason: 'listener should fire while mounted'); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + scope.dispatch(ModelChangeEvent(source, 3.0, bindingScope: scope)); + await tester.pump(); + expect(fired, 1, + reason: 'direct listen() subscriptions must be cancelled on dispose'); + expect(scope.listenerMap, isNot(contains(widget))); + }); + + testWidgets('page disposal completes bus cleanup and rejects late events', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final scope = ScopeManager(DataContext(buildContext: context), pageData); + final controller = EnsembleBoxController(); + var bindingUpdates = 0; + var directEvents = 0; + var directDone = false; + scope.listen(scope, r'${ensemble.storage.target}', + destination: BindingDestination(controller, 'testId'), + onDataChange: (_) => bindingUpdates++); + final direct = pageData.eventBus.on().listen( + (_) => directEvents++, + onDone: () => directDone = true, + ); + + scope.dispatch(ModelChangeEvent(StorageBindingSource('target'), 'before')); + await tester.pump(); + expect(bindingUpdates, 1); + expect(directEvents, 1); + + scope.dispose(); + expect(pageData.eventBus.streamController.isClosed, isTrue); + + // Dispatch after disposal is a guarded no-op. + scope.dispatch(ModelChangeEvent(StorageBindingSource('target'), 'after')); + // A raw fire after close follows normal EventBus semantics. + expect( + () => pageData.eventBus + .fire(ModelChangeEvent(StorageBindingSource('target'), 'raw')), + throwsStateError); + await pageData.eventBus.streamController.done; + + expect(bindingUpdates, 1, reason: 'post-dispose dispatch must be dropped'); + expect(directEvents, 1); + expect(directDone, isTrue, + reason: 'page disposal must close every direct subscription'); + expect(pageData.eventBus.streamController.hasListener, isFalse); + expect(scope.listenerMap, isEmpty); + direct.cancel(); + }); + + testWidgets('listeners registered after page disposal never fire', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final scope = ScopeManager(DataContext(buildContext: context), pageData); + scope.dispose(); + + final late = EnsembleBoxController(); + var lateUpdates = 0; + scope.listen(scope, r'${ensemble.storage.target}', + destination: BindingDestination(late, 'testId'), + onDataChange: (_) => lateUpdates++); + scope.dispatch(ModelChangeEvent(StorageBindingSource('target'), 'late')); + await tester.pump(); + + expect(lateUpdates, 0); + expect(pageData.eventBus.streamController.hasListener, isFalse, + reason: 'no slot may be added to a closed page bus'); + scope.removeBindingListeners(late); + await pageData.eventBus.streamController.done; + }); + + testWidgets('rapid page create/dispose and reentrant dispatch stays clean', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + for (var i = 0; i < 50; i++) { + final pageData = PageData(); + final scope = ScopeManager(DataContext(buildContext: context), pageData); + final controller = EnsembleBoxController(); + var updates = 0; + scope.listen(scope, r'${ensemble.storage.target}', + destination: BindingDestination(controller, 'testId'), + onDataChange: (_) { + updates++; + if (updates == 1) { + // Reentrant dispatch from inside a binding callback. + scope.dispatch( + ModelChangeEvent(StorageBindingSource('target'), 'reentrant')); + } + }); + scope.dispatch(ModelChangeEvent(StorageBindingSource('target'), 'first')); + await tester.pump(); + await tester.pump(); + expect(updates, 2, reason: 'reentrant dispatch must be delivered too'); + scope.dispose(); + await pageData.eventBus.streamController.done; + expect(pageData.eventBus.streamController.hasListener, isFalse); + expect(scope.listenerMap, isEmpty); + } + }); + + testWidgets('many bindings sharing one model id each receive one dispatch', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + const count = 300; + final controllers = []; + final counts = []; + for (var i = 0; i < count; i++) { + final controller = EnsembleBoxController(); + controllers.add(controller); + counts.add(0); + scope.listen(scope, r'${ensemble.storage.target}', + destination: BindingDestination(controller, 'testId'), + onDataChange: (_) => counts[i]++); + } + scope.dispatch(ModelChangeEvent(StorageBindingSource('target'), 'value')); + await tester.pump(); + expect(counts.every((value) => value == 1), isTrue, + reason: 'each of the $count bindings must fire exactly once'); + for (final controller in controllers) { + scope.removeBindingListeners(controller); + } + scope.dispose(); + }); + + testWidgets('bindings on distinct models only receive their own model', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + const count = 20; + final controllers = []; + final counts = []; + for (var i = 0; i < count; i++) { + final controller = EnsembleBoxController(); + controllers.add(controller); + counts.add(0); + scope.listen(scope, '\${ensemble.storage.key$i}', + destination: BindingDestination(controller, 'testId'), + onDataChange: (_) => counts[i]++); + } + scope.dispatch(ModelChangeEvent(StorageBindingSource('key7'), 'value')); + await tester.pump(); + expect(counts[7], 1); + expect(counts.where((value) => value == 1).length, 1, + reason: 'only the binding for the dispatched model may fire'); + for (final controller in controllers) { + scope.removeBindingListeners(controller); + } + scope.dispose(); + }); +} + +class _BindingOwnerProbe extends EnsembleWidget { + const _BindingOwnerProbe(super.controller, {super.key}); + + @override + State<_BindingOwnerProbe> createState() => _BindingOwnerProbeState(); +} + +class _BindingOwnerProbeState extends EnsembleWidgetState<_BindingOwnerProbe> { + @override + Widget buildWidget(BuildContext context) => const SizedBox(); +} diff --git a/modules/ensemble/test/page_event_bus_test.dart b/modules/ensemble/test/page_event_bus_test.dart new file mode 100644 index 000000000..36b07dcf0 --- /dev/null +++ b/modules/ensemble/test/page_event_bus_test.dart @@ -0,0 +1,545 @@ +import 'dart:async'; + +import 'package:ensemble/framework/page_event_bus.dart'; +import 'package:event_bus/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _ModelEvent { + _ModelEvent(this.modelId, this.value); + + final String modelId; + final Object? value; +} + +class _ParentEvent {} + +class _ChildEvent extends _ParentEvent {} + +class _MutableModelEvent { + _MutableModelEvent(this.modelId); + + String modelId; +} + +class _MutablePropertyEvent { + _MutablePropertyEvent(this.property); + + final String modelId = 'same-model'; + String property; +} + +PageEventBus _newBus() => PageEventBus( + modelIdOf: (event) => event is _ModelEvent ? event.modelId : null, + ); + +void main() { + test('EventBus public streams preserve registration order across indexes', + () async { + final bus = _newBus(); + final order = []; + bus + .onIndexed<_ModelEvent>('a', (event) => true) + .listen((_) => order.add('A')); + bus.on<_ModelEvent>().listen((_) => order.add('direct-on')); + bus.streamController.stream.listen((_) => order.add('direct-controller')); + bus + .onIndexed<_ModelEvent>('a', (event) => true) + .listen((_) => order.add('B')); + + bus.fire(_ModelEvent('a', 1)); + expect(order, isEmpty, reason: 'the page EventBus remains asynchronous'); + await Future.delayed(Duration.zero); + + expect(order, ['A', 'direct-on', 'direct-controller', 'B']); + await bus.streamController.close(); + }); + + test('indexed bindings skip unrelated model listeners', () async { + final bus = _newBus(); + final seen = []; + var unrelatedPredicateChecks = 0; + bus.onIndexed<_ModelEvent>('a', (_) => true).listen((_) => seen.add('a')); + bus.onIndexed<_ModelEvent>('b', (_) { + unrelatedPredicateChecks++; + return true; + }).listen((_) => seen.add('b')); + bus.fire(_ModelEvent('a', 1)); + await Future.delayed(Duration.zero); + expect(seen, ['a']); + expect(unrelatedPredicateChecks, 0, + reason: 'unrelated model bindings must not run their event filter'); + await bus.streamController.close(); + }); + + test('registration after fire cannot see an event already in flight', + () async { + final bus = _newBus(); + final initial = []; + final late = []; + bus + .onIndexed<_ModelEvent>('a', (_) => true) + .listen((event) => initial.add(event.value as int)); + bus.fire(_ModelEvent('a', 1)); + bus + .onIndexed<_ModelEvent>('a', (_) => true) + .listen((event) => late.add(event.value as int)); + await Future.delayed(Duration.zero); + expect(initial, [1]); + expect(late, isEmpty); + await bus.streamController.close(); + }); + + test('bindings and direct listeners retain ordinary EventBus order', + () async { + final bus = _newBus(); + final order = []; + bus.onIndexed<_ModelEvent>('a', (_) => true).listen((_) => order.add('A')); + bus.on<_ModelEvent>().listen((_) => order.add('direct')); + bus.onIndexed<_ModelEvent>('a', (_) => true).listen((_) => order.add('B')); + bus.fire(_ModelEvent('a', 1)); + await Future.delayed(Duration.zero); + expect(order, ['A', 'direct', 'B']); + await bus.streamController.close(); + }); + + test('routing observes model ID mutation before and during delivery', + () async { + Future> run(EventBus bus, {required bool indexed}) async { + final seen = []; + void bind(String model, String label) { + if (indexed) { + (bus as PageEventBus) + .onIndexed<_MutableModelEvent>( + model, (event) => event.modelId == model) + .listen((_) => seen.add(label)); + } else { + bus.on<_MutableModelEvent>().listen((event) { + if (event.modelId == model) seen.add(label); + }); + } + } + + bind('a', 'A'); + bus.on<_MutableModelEvent>().listen((event) { + event.modelId = 'b'; + seen.add('direct'); + }); + bind('b', 'B'); + + bus.fire(_MutableModelEvent('a')); + await Future.delayed(Duration.zero); + seen.clear(); + final changedBeforeDelivery = _MutableModelEvent('a'); + bus.fire(changedBeforeDelivery); + changedBeforeDelivery.modelId = 'b'; + await Future.delayed(Duration.zero); + return seen; + } + + final reference = EventBus(); + final referenceSeen = await run(reference, indexed: false); + reference.destroy(); + final bus = PageEventBus( + modelIdOf: (event) => event is _MutableModelEvent ? event.modelId : null, + ); + final indexedSeen = await run(bus, indexed: true); + expect(indexedSeen, referenceSeen); + expect(indexedSeen, ['direct', 'B']); + await bus.streamController.close(); + }); + + test('later indexed callbacks filter after preceding direct mutation', + () async { + Future> run(EventBus bus, {required bool indexed}) async { + final seen = []; + void bind(String property, String label) { + if (indexed) { + (bus as PageEventBus) + .onIndexed<_MutablePropertyEvent>( + 'same-model', (event) => event.property == property) + .listen((_) => seen.add(label)); + } else { + bus.on<_MutablePropertyEvent>().listen((event) { + if (event.modelId == 'same-model' && event.property == property) { + seen.add(label); + } + }); + } + } + + bind('not-current', 'before'); + bus.on<_MutablePropertyEvent>().listen((event) { + event.property = 'changed'; + seen.add('direct'); + }); + bind('changed', 'after'); + bus.fire(_MutablePropertyEvent('initial')); + await Future.delayed(Duration.zero); + return seen; + } + + final reference = EventBus(); + final expected = await run(reference, indexed: false); + reference.destroy(); + final bus = PageEventBus( + modelIdOf: (event) => + event is _MutablePropertyEvent ? event.modelId : null, + ); + final actual = await run(bus, indexed: true); + expect(actual, expected); + expect(actual, ['direct', 'after']); + await bus.streamController.close(); + }); + + test('cancel after fire suppresses a pending slot; new slot starts fresh', + () async { + final bus = _newBus(); + final seen = []; + final subscription = bus + .onIndexed<_ModelEvent>('a', (_) => true) + .listen((event) => seen.add(event.value)); + bus.fire(_ModelEvent('a', 'old')); + await subscription.cancel(); + bus + .onIndexed<_ModelEvent>('a', (_) => true) + .listen((event) => seen.add(event.value)); + bus.fire(_ModelEvent('a', 'new')); + await Future.delayed(Duration.zero); + expect(seen, ['new']); + await bus.streamController.close(); + }); + + test('cancelling a later slot from an earlier callback prevents delivery', + () async { + Future> run(EventBus bus) async { + final seen = []; + late StreamSubscription<_ModelEvent> later; + bus.on<_ModelEvent>().listen((_) { + seen.add('first'); + later.cancel(); + }); + later = bus.on<_ModelEvent>().listen((_) => seen.add('later')); + bus.on<_ModelEvent>().listen((_) => seen.add('last')); + bus.fire(_ModelEvent('a', 1)); + await Future.delayed(Duration.zero); + return seen; + } + + final reference = EventBus(); + final referenceSeen = await run(reference); + reference.destroy(); + final bus = _newBus(); + final seen = await run(bus); + expect(seen, referenceSeen); + await Future.delayed(Duration.zero); + expect(seen, ['first', 'last']); + await bus.streamController.close(); + }); + + test('controller listener lifecycle callbacks follow first/last listener', + () async { + final bus = _newBus(); + final callbacks = []; + bus.streamController.onListen = () => callbacks.add('listen'); + bus.streamController.onCancel = () => callbacks.add('cancel'); + final first = bus.on().listen((_) {}); + final second = bus.streamController.stream.listen((_) {}); + expect(bus.streamController.hasListener, isTrue); + expect(callbacks, ['listen']); + await first.cancel(); + expect(callbacks, ['listen']); + await second.cancel(); + expect(callbacks, ['listen', 'cancel']); + expect(bus.streamController.hasListener, isFalse); + await bus.streamController.close(); + }); + + test('broadcast controller rejects pause and resume callbacks', () { + final bus = _newBus(); + expect(() => bus.streamController.onPause = () {}, throwsUnsupportedError); + expect(() => bus.streamController.onResume = () {}, throwsUnsupportedError); + bus.destroy(); + }); + + test('controller close callback ordering matches EventBus', () async { + Future> run(EventBus bus) async { + final callbacks = []; + bus.streamController.onCancel = () => callbacks.add('cancel'); + bus.on().listen((_) {}, onDone: () => callbacks.add('done')); + await bus.streamController.close(); + await Future.delayed(Duration.zero); + return callbacks; + } + + final reference = EventBus(); + final expected = await run(reference); + final actual = await run(_newBus()); + expect(actual, expected); + expect(actual, ['cancel', 'done']); + }); + + test('pause/resume queues events independently per subscription', () async { + final bus = _newBus(); + final paused = []; + final active = []; + final pausedSubscription = bus + .onIndexed<_ModelEvent>('a', (_) => true) + .listen((event) => paused.add(event.value as int)); + bus + .onIndexed<_ModelEvent>('a', (_) => true) + .listen((event) => active.add(event.value as int)); + pausedSubscription.pause(); + bus.fire(_ModelEvent('a', 1)); + bus.fire(_ModelEvent('a', 2)); + await Future.delayed(Duration.zero); + expect(paused, isEmpty); + expect(active, [1, 2]); + pausedSubscription.resume(); + await Future.delayed(Duration.zero); + expect(paused, [1, 2]); + await bus.streamController.close(); + }); + + test('reentrant fire retains asynchronous ordered delivery', () async { + final bus = _newBus(); + final seen = []; + bus.on<_ModelEvent>().listen((event) { + seen.add('A${event.value}'); + if (event.value == 1) bus.fire(_ModelEvent('a', 2)); + }); + bus.on<_ModelEvent>().listen((event) => seen.add('B${event.value}')); + bus.fire(_ModelEvent('a', 1)); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(seen, ['A1', 'B1', 'A2', 'B2']); + await bus.streamController.close(); + }); + + test('typed listeners preserve EventBus subtype filtering', () async { + final bus = _newBus(); + final seen = []; + bus.on<_ParentEvent>().listen((_) => seen.add('parent')); + bus.on<_ChildEvent>().listen((_) => seen.add('child')); + bus.on().listen((_) => seen.add('dynamic')); + bus.fire(_ChildEvent()); + await Future.delayed(Duration.zero); + expect(seen, ['parent', 'child', 'dynamic']); + await bus.streamController.close(); + }); + + test('addError is delivered to indexed and direct subscribers', () async { + final bus = _newBus(); + final errors = []; + bus + .onIndexed<_ModelEvent>('a', (_) => true) + .listen((_) {}, onError: errors.add); + bus.on<_ModelEvent>().listen((_) {}, onError: errors.add); + bus.streamController.addError(StateError('bus error')); + await Future.delayed(Duration.zero); + expect(errors, hasLength(2)); + expect(errors.every((error) => error is StateError), isTrue); + await bus.streamController.close(); + }); + + test('a listener exception stays in its Zone and does not block later slots', + () async { + Future<(List, List)> run(EventBus bus) async { + final seen = []; + final errors = []; + runZonedGuarded(() { + bus.on<_ModelEvent>().listen((_) => throw StateError('listener')); + bus.on<_ModelEvent>().listen((event) => seen.add(event.value as int)); + bus.fire(_ModelEvent('a', 1)); + }, (error, _) => errors.add(error)); + await Future.delayed(Duration.zero); + return (seen, errors); + } + + final reference = EventBus(); + final expected = await run(reference); + reference.destroy(); + final bus = _newBus(); + final actual = await run(bus); + expect(actual.$1, expected.$1); + expect(actual.$2.map((error) => error.runtimeType), + expected.$2.map((error) => error.runtimeType)); + expect(actual.$1, [1]); + expect(actual.$2.single, isA()); + await bus.streamController.close(); + }); + + test('addStream forwards events and restores controller state', () async { + final bus = _newBus(); + final seen = []; + bus.on<_ModelEvent>().listen((event) => seen.add(event.value as int)); + await bus.streamController.addStream( + Stream<_ModelEvent>.fromIterable([ + _ModelEvent('a', 1), + _ModelEvent('b', 2), + ]), + ); + await Future.delayed(Duration.zero); + expect(seen, [1, 2]); + await bus.streamController.close(); + }); + + test('addStream cancelOnError forwards error and releases add lock', + () async { + final bus = _newBus(); + final errors = []; + bus.on<_ModelEvent>().listen((_) {}, onError: errors.add); + final source = StreamController<_ModelEvent>(); + final adding = + bus.streamController.addStream(source.stream, cancelOnError: true); + source.addError(StateError('source error')); + await adding; + bus.fire(_ModelEvent('a', 'after stream')); + await Future.delayed(Duration.zero); + expect(errors, hasLength(1)); + await source.close(); + await bus.streamController.close(); + }); + + test('destroy closes stream and reports isClosed', () async { + final bus = _newBus(); + var done = false; + bus.on().listen((_) {}, onDone: () => done = true); + bus.destroy(); + expect(bus.streamController.isClosed, isTrue); + await bus.streamController.done; + expect(done, isTrue); + }); + + test('close waits for a paused subscriber to resume and receive done', + () async { + final bus = _newBus(); + final seen = []; + var done = false; + final subscription = + bus.on().listen(seen.add, onDone: () => done = true); + subscription.pause(); + bus.fire(1); + bus.destroy(); + await Future.delayed(Duration.zero); + expect(seen, isEmpty); + expect(done, isFalse); + subscription.resume(); + await bus.streamController.done; + expect(seen, [1]); + expect(done, isTrue); + }); + + test('regular EventBus remains unchanged for app-wide events', () async { + final bus = EventBus(); + final seen = []; + bus.on().listen(seen.add); + bus.fire('global'); + await Future.delayed(Duration.zero); + expect(seen, ['global']); + bus.destroy(); + }); + + test('registering a listener after destroy is inert and receives done', + () async { + final bus = _newBus(); + var onListenCalls = 0; + bus.streamController.onListen = () => onListenCalls++; + bus.on<_ModelEvent>().listen((_) {}); + expect(bus.streamController.hasListener, isTrue); + + bus.destroy(); + await bus.streamController.done; + expect(bus.streamController.isClosed, isTrue); + expect(bus.streamController.hasListener, isFalse); + + final seen = []; + var indexedDone = false; + bus.onIndexed<_ModelEvent>('a', (_) => true).listen( + (event) => seen.add(event.value), + onDone: () => indexedDone = true, + ); + var directDone = false; + bus.on<_ModelEvent>().listen((_) {}, onDone: () => directDone = true); + await Future.delayed(Duration.zero); + + expect(seen, isEmpty, + reason: 'a listener registered after destroy must not receive events'); + expect(indexedDone, isTrue); + expect(directDone, isTrue); + expect(onListenCalls, 1, + reason: 'late registrations must not be treated as first listeners'); + }); + + test('firing after destroy throws and destroy/close are idempotent', + () async { + final bus = _newBus(); + bus.on<_ModelEvent>().listen((_) {}); + bus.destroy(); + bus.destroy(); + expect(() => bus.fire(_ModelEvent('a', 1)), throwsStateError); + expect(() => bus.streamController.add(_ModelEvent('a', 1)), + throwsStateError); + await bus.streamController.done; + expect(bus.streamController.isClosed, isTrue); + }); + + test('many indexed listeners sharing one model id all receive the event', + () async { + final bus = _newBus(); + const count = 1000; + final received = List.filled(count, 0); + var unrelatedPredicateChecks = 0; + for (var i = 0; i < count; i++) { + bus + .onIndexed<_ModelEvent>( + 'target', (event) => event.modelId == 'target') + .listen((event) => received[i] = (event.value as int) + 1); + // A second listener tree for a different model must never run its filter. + bus.onIndexed<_ModelEvent>('other', (event) { + unrelatedPredicateChecks++; + return true; + }).listen((_) {}); + } + bus.fire(_ModelEvent('target', 7)); + await Future.delayed(Duration.zero); + expect(received.every((value) => value == 8), isTrue, + reason: 'every binding on the shared model must fire exactly once'); + expect(unrelatedPredicateChecks, 0, + reason: 'bindings on other models must not evaluate their predicate'); + await bus.streamController.close(); + }); + + test('reentrant fired events from indexed listeners stay ordered', () async { + final bus = _newBus(); + final seen = []; + bus.onIndexed<_ModelEvent>('a', (_) => true).listen((event) { + seen.add('A${event.value}'); + if (event.value == 1) { + bus.fire(_ModelEvent('a', 2)); + } + }); + bus + .onIndexed<_ModelEvent>('a', (_) => true) + .listen((event) => seen.add('B${event.value}')); + bus.fire(_ModelEvent('a', 1)); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(seen, ['A1', 'B1', 'A2', 'B2']); + await bus.streamController.close(); + }); + + test('rapid create/destroy cycles leave no retained slots', () async { + for (var i = 0; i < 200; i++) { + final bus = _newBus(); + for (var j = 0; j < 5; j++) { + bus.onIndexed<_ModelEvent>('a', (_) => true).listen((_) {}); + } + bus.on<_ModelEvent>().listen((_) {}); + expect(bus.streamController.hasListener, isTrue); + bus.destroy(); + await bus.streamController.done; + expect(bus.streamController.hasListener, isFalse); + expect(bus.streamController.isClosed, isTrue); + } + }); +} diff --git a/modules/ensemble/test/storage_manager_test.dart b/modules/ensemble/test/storage_manager_test.dart index 3510c98a9..aa261c6aa 100644 --- a/modules/ensemble/test/storage_manager_test.dart +++ b/modules/ensemble/test/storage_manager_test.dart @@ -1,6 +1,42 @@ +import 'dart:io'; + +import 'package:ensemble/framework/storage_manager.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:get_storage/get_storage.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('clear removes public keys but retains encrypted keys', () async { + final directory = await Directory.systemTemp.createTemp('ensemble-storage-'); + const channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => directory.path); + try { + final storage = StorageManager(); + await storage.initPublicStorage(); + await storage.write('clear_test_a', 'a'); + await storage.write('clear_test_b', 'b'); + await storage.write('enc_clear_test', 'secret'); + + await storage.clearPublicStorage(); + + expect(storage.getKeys(), isNot(contains('clear_test_a'))); + expect(storage.getKeys(), isNot(contains('clear_test_b'))); + expect(storage.read('enc_clear_test'), 'secret'); + await storage.remove('enc_clear_test'); + expect(storage.getKeys(), isNot(contains('enc_clear_test'))); + await storage.write('clear_test_after', 'new'); + expect(storage.read('clear_test_after'), 'new'); + } finally { + await GetStorage().queue.add(() async {}); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + await directory.delete(recursive: true); + } + }); + test('clear logic filters out encrypted keys correctly', () { final storage = { 'name': 'Alice',