diff --git a/coverage_baseline.yaml b/coverage_baseline.yaml index 7bc26ea10..8a5f1285d 100644 --- a/coverage_baseline.yaml +++ b/coverage_baseline.yaml @@ -7,7 +7,7 @@ dev_tools/catalog_gallery: 52.91 dev_tools/composer: 20.49 packages/a2ui_core: 76.30 packages/genai_primitives: 100.00 -packages/genui: 79.71 +packages/genui: 79.38 packages/genui_a2a: 91.37 packages/json_schema_builder: 79.09 tool/e2e: 100.00 diff --git a/dev_tools/catalog_gallery/lib/sample_parser.dart b/dev_tools/catalog_gallery/lib/sample_parser.dart index 7f7860b9d..121bd837e 100644 --- a/dev_tools/catalog_gallery/lib/sample_parser.dart +++ b/dev_tools/catalog_gallery/lib/sample_parser.dart @@ -4,14 +4,14 @@ import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:file/file.dart'; -import 'package:genui/genui.dart'; import 'package:yaml/yaml.dart'; class Sample { final String name; final String description; - final Stream messages; + final Stream messages; Sample({ required this.name, @@ -52,14 +52,14 @@ class SampleParser { final String name = header['name'] as String? ?? 'Untitled Sample'; final String description = header['description'] as String? ?? ''; - final Stream messages = Stream.fromIterable( + final Stream messages = Stream.fromIterable( const LineSplitter() .convert(jsonlBody) .where((line) => line.trim().isNotEmpty) .map((line) { final dynamic json = jsonDecode(line); if (json is Map) { - return A2uiMessage.fromJson(json); + return core.A2uiMessage.fromJson(json); } throw FormatException('Invalid JSON line: $line'); }), diff --git a/dev_tools/catalog_gallery/lib/samples_view.dart b/dev_tools/catalog_gallery/lib/samples_view.dart index b81139301..f5b68005d 100644 --- a/dev_tools/catalog_gallery/lib/samples_view.dart +++ b/dev_tools/catalog_gallery/lib/samples_view.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:flutter/material.dart'; @@ -35,7 +36,7 @@ class _SamplesViewState extends State { final List _surfaceIds = []; int _currentSurfaceIndex = 0; StreamSubscription? _surfaceSubscription; - StreamSubscription? _messageSubscription; + StreamSubscription? _messageSubscription; @override void initState() { diff --git a/dev_tools/catalog_gallery/pubspec.yaml b/dev_tools/catalog_gallery/pubspec.yaml index b75e56ccb..73d30bc67 100644 --- a/dev_tools/catalog_gallery/pubspec.yaml +++ b/dev_tools/catalog_gallery/pubspec.yaml @@ -13,6 +13,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-wip002 args: ^2.7.0 file: ^7.0.1 flutter: diff --git a/dev_tools/catalog_gallery/test/layout_test.dart b/dev_tools/catalog_gallery/test/layout_test.dart index 47fae8f70..911fedebf 100644 --- a/dev_tools/catalog_gallery/test/layout_test.dart +++ b/dev_tools/catalog_gallery/test/layout_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:catalog_gallery/sample_parser.dart'; import 'package:file/file.dart' as file_pkg; import 'package:flutter/material.dart'; @@ -38,13 +39,13 @@ void main() { catalogs: [BasicCatalogItems.asCatalog()], ); - await for (final A2uiMessage message in sample.messages) { + await for (final core.A2uiMessage message in sample.messages) { var messageToProcess = message; - if (message is CreateSurface) { + if (message is core.CreateSurfaceMessage) { // We manually inject the basic catalog since createSurface might ref // external URL in this test environment, we just assume the basic // catalog is available - messageToProcess = CreateSurface( + messageToProcess = core.CreateSurfaceMessage( surfaceId: message.surfaceId, catalogId: basicCatalogId, theme: message.theme, @@ -81,7 +82,7 @@ void main() { catalogs: [BasicCatalogItems.asCatalog()], ); - await for (final A2uiMessage message in sample.messages) { + await for (final core.A2uiMessage message in sample.messages) { controller.handleMessage(message); } diff --git a/dev_tools/catalog_gallery/test/sample_parser_test.dart b/dev_tools/catalog_gallery/test/sample_parser_test.dart index 5c4efadb4..97ae69623 100644 --- a/dev_tools/catalog_gallery/test/sample_parser_test.dart +++ b/dev_tools/catalog_gallery/test/sample_parser_test.dart @@ -2,9 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:catalog_gallery/sample_parser.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/genui.dart'; void main() { test('SampleParser parses valid sample string', () async { @@ -21,19 +21,19 @@ description: A test description expect(sample.name, 'Test Sample'); expect(sample.description, 'A test description'); - final List messages = await sample.messages.toList(); + final List messages = await sample.messages.toList(); expect(messages.length, 2); - expect(messages.first, isA()); - expect(messages.last, isA()); + expect(messages.first, isA()); + expect(messages.last, isA()); - final update = messages.first as UpdateComponents; + final update = messages.first as core.UpdateComponentsMessage; expect(update.surfaceId, 'default'); expect(update.components.length, 1); - expect(update.components.first.type, 'Text'); + expect(update.components.first['component'], 'Text'); - final begin = messages.last as CreateSurface; + final begin = messages.last as core.CreateSurfaceMessage; expect(begin.surfaceId, 'default'); - // begin.root check removed as it doesn't exist in CreateSurface + // begin.root check removed as it doesn't exist in CreateSurfaceMessage }); test( @@ -48,7 +48,7 @@ description: A description '''; final Sample sample = SampleParser.parseString(sampleContent); expect(sample.name, 'Frontmatter Sample'); - final List messages = await sample.messages.toList(); + final List messages = await sample.messages.toList(); expect(messages.length, 1); }, ); @@ -61,7 +61,7 @@ description: A description '''; final Sample sample = SampleParser.parseString(sampleContent); expect(sample.name, 'Untitled Sample'); - final List messages = await sample.messages.toList(); + final List messages = await sample.messages.toList(); expect(messages.length, 1); }); diff --git a/dev_tools/catalog_gallery/test/samples_rendering_test.dart b/dev_tools/catalog_gallery/test/samples_rendering_test.dart index a9f8f8d44..adad62cf5 100644 --- a/dev_tools/catalog_gallery/test/samples_rendering_test.dart +++ b/dev_tools/catalog_gallery/test/samples_rendering_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:catalog_gallery/sample_parser.dart'; import 'package:file/file.dart'; import 'package:file/local.dart'; @@ -78,7 +79,7 @@ void main() { ); try { - await for (final A2uiMessage message in sample.messages) { + await for (final core.A2uiMessage message in sample.messages) { controller.handleMessage(message); await tester.pump(); } diff --git a/dev_tools/composer/lib/ai_client_transport.dart b/dev_tools/composer/lib/ai_client_transport.dart index 32428bf9b..33557c345 100644 --- a/dev_tools/composer/lib/ai_client_transport.dart +++ b/dev_tools/composer/lib/ai_client_transport.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:dartantic_ai/dartantic_ai.dart' as dartantic; import 'package:genui/genui.dart'; import 'package:logging/logging.dart'; @@ -20,7 +21,7 @@ class AiClientTransport implements Transport { final Logger _logger = Logger('AiClientTransport'); @override - Stream get incomingMessages => _adapter.incomingMessages; + Stream get incomingMessages => _adapter.incomingMessages; @override Stream get incomingText => _adapter.incomingText; diff --git a/dev_tools/composer/lib/sample_parser.dart b/dev_tools/composer/lib/sample_parser.dart index ac2e85aa5..b75e8a8fb 100644 --- a/dev_tools/composer/lib/sample_parser.dart +++ b/dev_tools/composer/lib/sample_parser.dart @@ -4,7 +4,7 @@ import 'dart:convert'; -import 'package:genui/genui.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:yaml/yaml.dart'; /// A parsed sample containing metadata and a stream of A2UI messages. @@ -12,7 +12,7 @@ class Sample { final String name; final String description; final String rawJsonl; - final Stream messages; + final Stream messages; Sample({ required this.name, @@ -50,14 +50,14 @@ class SampleParser { final String name = header['name'] as String? ?? 'Untitled Sample'; final String description = header['description'] as String? ?? ''; - final Stream messages = Stream.fromIterable( + final Stream messages = Stream.fromIterable( const LineSplitter() .convert(jsonlBody) .where((line) => line.trim().isNotEmpty) .map((line) { final Object? json = jsonDecode(line); if (json is Map) { - return A2uiMessage.fromJson(json); + return core.A2uiMessage.fromJson(json); } throw FormatException('Invalid JSON line: $line'); }), diff --git a/dev_tools/composer/lib/surface_editor.dart b/dev_tools/composer/lib/surface_editor.dart index 3fd885159..7a523ae83 100644 --- a/dev_tools/composer/lib/surface_editor.dart +++ b/dev_tools/composer/lib/surface_editor.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/material.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart'; import 'package:flutter_highlight/themes/vs.dart'; @@ -150,7 +151,9 @@ class _SurfaceEditorViewState extends State { _dataModelNotifier?.removeListener(_onDataModelChanged); if (_surfaceIds.isEmpty) return; - final dataModel = _surfaceController.store.getDataModel(_surfaceIds.first); + final dataModel = _surfaceController + .contextFor(_surfaceIds.first) + .dataModel; _dataModelNotifier = dataModel.subscribe(DataPath.root); _dataModelNotifier!.addListener(_onDataModelChanged); } @@ -164,7 +167,7 @@ class _SurfaceEditorViewState extends State { if (_surfaceIds.isEmpty) return; final surfaceId = _surfaceIds.first; - final dataModel = _surfaceController.store.getDataModel(surfaceId); + final dataModel = _surfaceController.contextFor(surfaceId).dataModel; final data = dataModel.getValue(DataPath.root); final dataJson = const JsonEncoder.withIndent(' ').convert(data); @@ -202,7 +205,7 @@ class _SurfaceEditorViewState extends State { final obj = jsonDecode(trimmedChunk); if (obj is Map) { - final message = A2uiMessage.fromJson(obj); + final message = core.A2uiMessage.fromJson(obj); _surfaceController.handleMessage(message); } } @@ -227,7 +230,7 @@ class _SurfaceEditorViewState extends State { if (parsed is Map) { final surfaceId = _surfaceIds.first; _surfaceController.handleMessage( - A2uiMessage.fromJson({ + core.A2uiMessage.fromJson({ 'version': kProtocolVersion, 'updateDataModel': { 'surfaceId': surfaceId, diff --git a/dev_tools/composer/pubspec.yaml b/dev_tools/composer/pubspec.yaml index f637117b3..df3a8b6f3 100644 --- a/dev_tools/composer/pubspec.yaml +++ b/dev_tools/composer/pubspec.yaml @@ -12,6 +12,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-wip002 dartantic_ai: ^3.2.0 flutter: sdk: flutter diff --git a/docs/usage/migration/migration_0.9.1_to_0.10.0.md b/docs/usage/migration/migration_0.9.1_to_0.10.0.md new file mode 100644 index 000000000..7f10d8af5 --- /dev/null +++ b/docs/usage/migration/migration_0.9.1_to_0.10.0.md @@ -0,0 +1,72 @@ +# Migration Guide: 0.9.1 to 0.10.0 + +`package:genui` now runs on the shared `package:a2ui_core` runtime (#811). The +only customer-facing change is for code that **implements a custom `Transport` or +constructs/parses A2UI messages directly** — those message types moved to +`a2ui_core`. The default AI/transport flow, catalog widgets, and data-binding code +are unaffected. + +## What you have to change + +### A2UI messages are now `a2ui_core` types + +The genui message classes (`A2uiMessage`, `CreateSurface`, `UpdateComponents`, +`UpdateDataModel`, `DeleteSurface`) are removed. Add `a2ui_core` to your +dependencies and use its message types. They don't collide with anything genui +exports, so import them unprefixed with a `show` list: + +```dart +// Before +controller.handleMessage( + UpdateComponents(surfaceId: 's', components: [ + Component(id: 'root', type: 'Text', properties: {'text': 'Hi'}), + ]), +); +controller.handleMessage(CreateSurface(surfaceId: 's', catalogId: 'demo')); + +// After +import 'package:a2ui_core/a2ui_core.dart' + show CreateSurfaceMessage, UpdateComponentsMessage; + +controller.handleMessage( + UpdateComponentsMessage(surfaceId: 's', components: [ + {'id': 'root', 'component': 'Text', 'text': 'Hi'}, + ]), +); +controller.handleMessage( + CreateSurfaceMessage(surfaceId: 's', catalogId: 'demo'), +); +``` + +- **Custom transport:** `Transport.incomingMessages` and + `SurfaceController.handleMessage` now use `a2ui_core`'s `A2uiMessage`. Update + those signatures if you implement `Transport` or drive the controller directly. +- **Building messages:** `UpdateComponentsMessage` takes raw component JSON maps + (`{'id': ..., 'component': ..., ...props}`), not `Component` objects. +- **Parsing raw JSON:** use `A2uiMessage.fromJson(json)`. + +### `SurfaceController.store` is removed + +Read a surface's data model via `SurfaceController.contextFor(id).dataModel` +(writable, and usable before the surface is created). + +## Behavior you may notice + +- **`DataModel` writes are stricter.** Some writes that used to silently do + nothing now throw, e.g. writing through a path whose intermediate value isn't a + map or list. +- **Malformed messages are rejected more consistently** (missing or wrong + version, or more than one action key in a single message). +- **A duplicate `createSurface` for an active surface id is now an error** rather + than silently reusing the existing surface. +- **`updateDataModel` with `value: null` removes the key**, the same as omitting + the value. Distinguishing the two is pending flutter/genui#938. + +## What does not change + +Your catalog widgets and data-binding code are untouched: `CatalogItemContext`, +`dataContext`, the `DataModel` / `DataPath` API, the `Bound*` widgets, and the +`SurfaceDefinition` / `Component` snapshots all keep their current shape. + +A follow-up (#801) will unify these with the `a2ui_core` models once the upstream +Node Layer (A2UI#1282) lands. diff --git a/examples/simple_chat/lib/a2ui_transport.dart b/examples/simple_chat/lib/a2ui_transport.dart index 85bf7f3b9..ab8306f26 100644 --- a/examples/simple_chat/lib/a2ui_transport.dart +++ b/examples/simple_chat/lib/a2ui_transport.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:genui/genui.dart'; import 'agent/agent.dart'; @@ -22,7 +23,7 @@ class SimpleChatA2aTransport implements Transport { final A2uiTransportAdapter _adapter = A2uiTransportAdapter(); @override - Stream get incomingMessages => _adapter.incomingMessages; + Stream get incomingMessages => _adapter.incomingMessages; @override Stream get incomingText => _adapter.incomingText; diff --git a/examples/simple_chat/lib/chat_session.dart b/examples/simple_chat/lib/chat_session.dart index 5e7ce9fa0..59e6ed858 100644 --- a/examples/simple_chat/lib/chat_session.dart +++ b/examples/simple_chat/lib/chat_session.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:genui/genui.dart'; import 'package:logging/logging.dart'; @@ -190,7 +191,7 @@ class A2uiChatSession extends ChatSession { @override SurfaceController get surfaceController => _surfaceController; - late final StreamSubscription _messageSub; + late final StreamSubscription _messageSub; late final StreamSubscription _textSub; late final StreamSubscription _submitSub; late final StreamSubscription _surfaceSub; diff --git a/examples/simple_chat/pubspec.yaml b/examples/simple_chat/pubspec.yaml index de0848b3e..1a60e7c93 100644 --- a/examples/simple_chat/pubspec.yaml +++ b/examples/simple_chat/pubspec.yaml @@ -12,6 +12,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-wip002 dartantic_ai: ^3.2.0 flutter: sdk: flutter diff --git a/examples/simple_chat/test/fake_ai_client.dart b/examples/simple_chat/test/fake_ai_client.dart index 072f83db2..fefa9dfe2 100644 --- a/examples/simple_chat/test/fake_ai_client.dart +++ b/examples/simple_chat/test/fake_ai_client.dart @@ -4,14 +4,14 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:dartantic_ai/dartantic_ai.dart' as dartantic; -import 'package:genui/genui.dart'; import 'package:simple_chat/agent/ai_client.dart'; /// A fake implementation of [AiClient] for testing. class FakeAiClient implements AiClient { - final StreamController _a2uiMessageController = - StreamController.broadcast(); + final StreamController _a2uiMessageController = + StreamController.broadcast(); final StreamController _textResponseController = StreamController.broadcast(); @@ -22,7 +22,8 @@ class FakeAiClient implements AiClient { final List _receivedPrompts = []; List get receivedPrompts => List.unmodifiable(_receivedPrompts); - Stream get a2uiMessageStream => _a2uiMessageController.stream; + Stream get a2uiMessageStream => + _a2uiMessageController.stream; Stream get textResponseStream => _textResponseController.stream; diff --git a/examples/verdure/client/lib/features/ai/ai_provider.dart b/examples/verdure/client/lib/features/ai/ai_provider.dart index 09b1e55b7..886da8b1e 100644 --- a/examples/verdure/client/lib/features/ai/ai_provider.dart +++ b/examples/verdure/client/lib/features/ai/ai_provider.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:genui/genui.dart'; @@ -93,7 +94,7 @@ class Ai extends _$Ai { final surfaceUpdateController = StreamController.broadcast(); connector.stream.listen((message) { - if (message is CreateSurface) { + if (message is core.CreateSurfaceMessage) { surfaceUpdateController.add(message.surfaceId); } }); diff --git a/examples/verdure/client/pubspec.yaml b/examples/verdure/client/pubspec.yaml index 8b636dce4..89c178257 100644 --- a/examples/verdure/client/pubspec.yaml +++ b/examples/verdure/client/pubspec.yaml @@ -14,6 +14,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-wip002 device_info_plus: ^13.1.0 flutter: sdk: flutter diff --git a/packages/a2ui_core/CHANGELOG.md b/packages/a2ui_core/CHANGELOG.md index 4b51ff99e..b4ee9bf3d 100644 --- a/packages/a2ui_core/CHANGELOG.md +++ b/packages/a2ui_core/CHANGELOG.md @@ -2,4 +2,8 @@ ## 0.0.1-wip002 +- **Feature**: Export `effect` and `Effect`. + +## 0.0.1-dev002 + - Initial version. diff --git a/packages/a2ui_core/lib/src/primitives/reactivity.dart b/packages/a2ui_core/lib/src/primitives/reactivity.dart index c867253ba..1b06466cf 100644 --- a/packages/a2ui_core/lib/src/primitives/reactivity.dart +++ b/packages/a2ui_core/lib/src/primitives/reactivity.dart @@ -10,4 +10,12 @@ library; export 'package:preact_signals/preact_signals.dart' - show Computed, Effect, ReadonlySignal, Signal, batch, computed, signal; + show + Computed, + Effect, + ReadonlySignal, + Signal, + batch, + computed, + effect, + signal; diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 0ce448fd6..cd8f84d5c 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -1,6 +1,27 @@ # `genui` Changelog -## 0.9.2 +## 0.10.0 (in progress) + +- **Refactor**: genui now runs on `package:a2ui_core`. See + [the migration guide](../../docs/usage/migration/migration_0.9.1_to_0.10.0.md). +- **BREAKING**: A2UI message types are now `package:a2ui_core` types. The GenUI + message classes (`A2uiMessage`, `CreateSurface`, `UpdateComponents`, + `UpdateDataModel`, `DeleteSurface`) are removed; `SurfaceController.handleMessage` + and `Transport.incomingMessages` take `a2ui_core` messages, and + `UpdateComponentsMessage` carries raw component JSON maps rather than `Component` + objects. Depend on `a2ui_core` directly. +- **BREAKING**: `SurfaceController.store` and `DataModelStore` are removed. Read a + surface's data model via `SurfaceController.contextFor(id).dataModel`. +- **BREAKING**: `SurfaceRegistry.updateSurface(...)` is removed; drive surfaces + through `SurfaceController.handleMessage`. +- **Behavior**: `DataModel` writes are stricter; some writes that previously did + nothing now throw, and sparse list writes fill skipped entries with `null`. +- **Behavior**: A duplicate `createSurface` for an already-active surface id is now + an error. +- The catalog-widget authoring API is unchanged; `SurfaceDefinition` and + `Component` remain genui types. + +## 0.9.1 - **Feature**: Updated example/README.md. diff --git a/packages/genui/lib/src/development_utilities/catalog_view.dart b/packages/genui/lib/src/development_utilities/catalog_view.dart index 4c095d6e9..ba750580c 100644 --- a/packages/genui/lib/src/development_utilities/catalog_view.dart +++ b/packages/genui/lib/src/development_utilities/catalog_view.dart @@ -5,15 +5,13 @@ import 'dart:async' show StreamSubscription; import 'dart:convert'; -import 'package:collection/collection.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/material.dart'; import '../engine/surface_controller.dart'; -import '../model/a2ui_message.dart'; import '../model/catalog.dart'; import '../model/catalog_item.dart'; import '../model/chat_message.dart'; -import '../model/ui_models.dart'; import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; import '../widgets/surface.dart'; @@ -73,14 +71,11 @@ class _DebugCatalogViewState extends State { try { final exampleData = jsonDecode(exampleJsonString) as List; - final List components = exampleData - .map((e) => Component.fromJson(e as JsonMap)) + final List components = exampleData + .map((e) => e as JsonMap) .toList(); - Component? rootComponent; - rootComponent = components.firstWhereOrNull((c) => c.id == 'root'); - - if (rootComponent == null) { + if (!components.any((c) => c['id'] == 'root')) { genUiLogger.info( 'Skipping example for ${item.name} because it is missing a root ' 'component.', @@ -89,10 +84,16 @@ class _DebugCatalogViewState extends State { } _surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + core.UpdateComponentsMessage( + surfaceId: surfaceId, + components: components, + ), ); _surfaceController.handleMessage( - CreateSurface(surfaceId: surfaceId, catalogId: catalog.catalogId!), + core.CreateSurfaceMessage( + surfaceId: surfaceId, + catalogId: catalog.catalogId!, + ), ); surfaceIds.add(surfaceId); } catch (exception, stackTrace) { diff --git a/packages/genui/lib/src/engine.dart b/packages/genui/lib/src/engine.dart index 0ba6b7c5a..86e2dc429 100644 --- a/packages/genui/lib/src/engine.dart +++ b/packages/genui/lib/src/engine.dart @@ -5,6 +5,5 @@ /// The core runtime components for GenUI. library; -export 'engine/data_model_store.dart'; export 'engine/surface_controller.dart'; export 'engine/surface_registry.dart'; diff --git a/packages/genui/lib/src/engine/data_model_store.dart b/packages/genui/lib/src/engine/data_model_store.dart deleted file mode 100644 index 5a63ff916..000000000 --- a/packages/genui/lib/src/engine/data_model_store.dart +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import '../model/data_model.dart'; - -/// Manages the data models for surfaces. -class DataModelStore { - final Map _dataModels = {}; - final Set _attachedSurfaces = {}; - - /// Retrieves the data model for the given [surfaceId], creating it if it - /// does not exist. - DataModel getDataModel(String surfaceId) { - return _dataModels.putIfAbsent(surfaceId, InMemoryDataModel.new); - } - - /// Removes the data model for the given [surfaceId] and detaches the surface. - void removeDataModel(String surfaceId) { - final DataModel? model = _dataModels.remove(surfaceId); - model?.dispose(); - _attachedSurfaces.remove(surfaceId); - } - - /// Marks the surface with the given [surfaceId] as attached. - void attachSurface(String surfaceId) { - _attachedSurfaces.add(surfaceId); - } - - /// Marks the surface with the given [surfaceId] as detached. - void detachSurface(String surfaceId) { - _attachedSurfaces.remove(surfaceId); - } - - /// An unmodifiable map of all registered data models. - Map get dataModels => Map.unmodifiable(_dataModels); - - /// Disposes of all data models in this store. - void dispose() { - for (final DataModel model in _dataModels.values) { - model.dispose(); - } - } -} diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 69731d290..7558fac47 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -12,63 +13,64 @@ import '../interfaces/a2ui_message_sink.dart'; import '../interfaces/surface_context.dart'; import '../interfaces/surface_host.dart'; import '../model/a2ui_client_capabilities.dart'; -import '../model/a2ui_message.dart'; import '../model/catalog.dart'; import '../model/chat_message.dart'; import '../model/data_model.dart'; +import '../model/schema_validation.dart' as schema_validation; import '../model/ui_models.dart'; -import '../primitives/constants.dart'; +import '../primitives/a2ui_validation_exception.dart'; import '../primitives/logging.dart'; -import 'data_model_store.dart'; import 'surface_registry.dart' as surface_reg; /// The runtime controller for the GenUI system. /// -/// Orchestrates the lifecycle of UI surfaces, manages communication with the -/// AI service, and handles data model updates. +/// Wraps [core.MessageProcessor] and adds Flutter-side concerns: pre-create +/// message buffering, catalog-schema validation, and a [SurfaceUpdate] +/// stream the Flutter facade subscribes to. interface class SurfaceController implements SurfaceHost, A2uiMessageSink { - /// Creates a [SurfaceController]. - /// - /// The [catalogs] parameter defines the set of component catalogs available - /// for use by surfaces managed by this controller. - /// - /// The [pendingUpdateTimeout] specifies how long to wait for a surface - /// creation message before discarding buffered updates for that surface. SurfaceController({ required this.catalogs, this.pendingUpdateTimeout = const Duration(minutes: 1), - }); + }) { + _processor = core.MessageProcessor( + // Growable: handleMessage injects stub catalogs for unknown catalogIds. + catalogs: catalogs.map((c) => c.coreCatalog).toList(), + ); + _processor.groupModel.onSurfaceCreated.addListener(_onCoreSurfaceCreated); + _processor.groupModel.onSurfaceDeleted.addListener(_onCoreSurfaceDeleted); + } /// The catalogs available to surfaces in this engine. final Iterable catalogs; - /// The timeout for pending updates waiting for a surface creation. + /// The timeout for buffered updates waiting for a surface creation. final Duration pendingUpdateTimeout; + late final core.MessageProcessor _processor; late final surface_reg.SurfaceRegistry _registry = surface_reg.SurfaceRegistry(); - late final DataModelStore _store = DataModelStore(); + // Writable data models handed out by `contextFor(id).dataModel` before the + // surface exists; copied into the live core model on surface creation. + final Map _preCreateDataModels = {}; + final Map _liveDataModels = {}; final _onSubmit = StreamController.broadcast(); - final _pendingUpdates = >{}; + final _pendingUpdates = >{}; final _pendingUpdateTimers = {}; - // Expose registry events as surface updates @override Stream get surfaceUpdates => _registry.events.map( (e) => switch (e) { - surface_reg.SurfaceAdded(:final surfaceId, :final definition) => - SurfaceAdded(surfaceId, definition), - surface_reg.SurfaceUpdated(:final surfaceId, :final definition) => - ComponentsUpdated(surfaceId, definition), + surface_reg.SurfaceAdded(:final surfaceId, :final surface) => + SurfaceAdded.fromCore(surfaceId, surface), + surface_reg.SurfaceUpdated(:final surfaceId, :final surface) => + ComponentsUpdated.fromCore(surfaceId, surface), surface_reg.SurfaceRemoved(:final surfaceId) => SurfaceRemoved(surfaceId), }, ); /// A stream of messages to be submitted to the AI service. - /// - /// This includes user actions and validation errors. Stream get onSubmit => _onSubmit.stream; /// The IDs of the currently active surfaces. @@ -87,37 +89,178 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { /// The registry of surfaces managed by this controller. surface_reg.SurfaceRegistry get registry => _registry; - /// The store of data models managed by this controller. - DataModelStore get store => _store; + DataModel _dataModelFor(String surfaceId) { + final DataModel? live = _liveDataModels[surfaceId]; + if (live != null) return live; + final core.SurfaceModel? surface = _registry.getLiveSurface(surfaceId); + if (surface != null) { + final DataModel wrapped = InMemoryDataModel.wrap(surface.dataModel); + _liveDataModels[surfaceId] = wrapped; + return wrapped; + } + return _preCreateDataModels.putIfAbsent(surfaceId, InMemoryDataModel.new); + } - /// Process an [message] from the AI service. - /// - /// Decodes the message and updates the state of the relevant surface, - /// provided the message passes validation. - /// - /// If validation fails, a [A2uiValidationException] is caught and logged, - /// and an error message is sent back via [onSubmit]. + /// Processes a message from the AI service. @override - void handleMessage(A2uiMessage message) { + void handleMessage(core.A2uiMessage message) { genUiLogger.info( 'SurfaceController.handleMessage received: ${message.runtimeType}', ); + _handleCoreMessage(message); + } + + void _handleCoreMessage(core.A2uiMessage coreMessage) { + // Reject an empty surfaceId on any message that carries one. CreateSurface + // would otherwise create a surface with id ""; updates and deletes would + // buffer under "" until they time out, since a surface "" can never exist. + final String? surfaceId = _surfaceIdOf(coreMessage); + if (surfaceId != null && surfaceId.isEmpty) { + reportError( + A2uiValidationException( + 'Surface ID cannot be empty', + surfaceId: '', + path: 'surfaceId', + ), + StackTrace.current, + ); + return; + } + + final String? bufferSurfaceId = _bufferSurfaceIdIfNoSurface(coreMessage); + if (bufferSurfaceId != null) { + _bufferMessage(bufferSurfaceId, coreMessage); + return; + } + + // Tolerate unknown catalogIds by registering an empty stub rather than + // rejecting the surface. + if (coreMessage is core.CreateSurfaceMessage) { + final core.CreateSurfaceMessage createMessage = coreMessage; + if (!_processor.catalogs.any((c) => c.id == createMessage.catalogId)) { + _processor.catalogs.add( + core.Catalog( + id: createMessage.catalogId, + components: const [], + ), + ); + } + } try { - _handleMessageInternal(message); + _processor.processMessages([coreMessage]); + } on core.A2uiStateError catch (e) { + genUiLogger.warning('State error from MessageProcessor: ${e.message}'); + reportError( + A2uiValidationException( + e.message, + surfaceId: _surfaceIdOf(coreMessage), + ), + StackTrace.current, + ); + return; + } on core.A2uiValidationError catch (e) { + genUiLogger.warning( + 'Validation error from MessageProcessor: ${e.message}', + ); + reportError( + A2uiValidationException( + e.message, + surfaceId: _surfaceIdOf(coreMessage), + ), + StackTrace.current, + ); + return; } on A2uiValidationException catch (e) { genUiLogger.warning('Validation failed for surface ${e.surfaceId}: $e'); reportError(e, StackTrace.current); + return; } catch (exception, stackTrace) { genUiLogger.severe( - 'Error handling message: $message', + 'Error handling message: $coreMessage', exception, stackTrace, ); reportError(exception, stackTrace); + return; + } + + if (coreMessage is core.UpdateComponentsMessage) { + final core.SurfaceModel? surface = _processor + .groupModel + .getSurface(coreMessage.surfaceId); + if (surface != null) { + _registry.notifyUpdated(surface); + // Validation does not roll back the mutation; we surface the error + // and let the caller decide. + try { + final Catalog? genuiCatalog = catalogs.firstWhereOrNull( + (c) => c.catalogId == surface.catalog.id, + ); + if (genuiCatalog != null) { + _validateComponents(coreMessage.surfaceId, surface, genuiCatalog); + } + } on A2uiValidationException catch (e) { + genUiLogger.warning( + 'Schema validation failed for surface ${e.surfaceId}: $e', + ); + reportError(e, StackTrace.current); + } + } + } + } + + /// If [message] targets a surface that does not yet exist, returns that + /// surfaceId so the caller can buffer the message. Otherwise returns null. + String? _bufferSurfaceIdIfNoSurface(core.A2uiMessage message) { + final String? targetId = switch (message) { + core.UpdateComponentsMessage(:final surfaceId) => surfaceId, + core.UpdateDataModelMessage(:final surfaceId) => surfaceId, + _ => null, + }; + if (targetId == null) return null; + if (_processor.groupModel.getSurface(targetId) != null) return null; + return targetId; + } + + String? _surfaceIdOf(core.A2uiMessage message) => switch (message) { + core.CreateSurfaceMessage(:final surfaceId) => surfaceId, + core.UpdateComponentsMessage(:final surfaceId) => surfaceId, + core.UpdateDataModelMessage(:final surfaceId) => surfaceId, + core.DeleteSurfaceMessage(:final surfaceId) => surfaceId, + _ => null, + }; + + void _onCoreSurfaceCreated(core.SurfaceModel surface) { + // Copy pre-create fallback data into the live model BEFORE notifying + // registry listeners; otherwise a synchronous listener could call + // contextFor(...).dataModel and cache an empty live wrapper before the + // fallback's data is copied in. + final DataModel live = InMemoryDataModel.wrap(surface.dataModel); + final DataModel? fallback = _preCreateDataModels.remove(surface.id); + if (fallback != null) { + live.update(DataPath.root, fallback.getValue(DataPath.root)); + fallback.dispose(); + } + _liveDataModels[surface.id] = live; + _registry.addSurface(surface); + final List? pending = _pendingUpdates.remove(surface.id); + _pendingUpdateTimers.remove(surface.id)?.cancel(); + if (pending != null) { + for (final core.A2uiMessage msg in pending) { + _handleCoreMessage(msg); + } } } + void _onCoreSurfaceDeleted(String surfaceId) { + _pendingUpdates.remove(surfaceId); + _pendingUpdateTimers.remove(surfaceId)?.cancel(); + _preCreateDataModels.remove(surfaceId)?.dispose(); + _liveDataModels.remove(surfaceId)?.dispose(); + _registry.removeSurface(surfaceId); + } + /// Reports an error to the AI service. void reportError(Object error, StackTrace? stack) { var errorCode = 'RUNTIME_ERROR'; @@ -149,103 +292,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { ); } - void _handleMessageInternal(A2uiMessage message) { - switch (message) { - case CreateSurface( - :final surfaceId, - :final catalogId, - :final theme, - :final sendDataModel, - ): - if (surfaceId.isEmpty) { - throw A2uiValidationException( - 'Surface ID cannot be empty', - surfaceId: surfaceId, - path: 'surfaceId', - ); - } - - final List? pending = _pendingUpdates.remove(surfaceId); - _pendingUpdateTimers.remove(surfaceId)?.cancel(); - - _store.getDataModel(surfaceId); // Ensure model exists - - final SurfaceDefinition? existing = _registry.getSurface(surfaceId); - final SurfaceDefinition newDefinition = - (existing ?? SurfaceDefinition(surfaceId: surfaceId)).copyWith( - catalogId: catalogId, - theme: theme, - ); - - if (sendDataModel) { - _store.attachSurface(surfaceId); - } else { - _store.detachSurface(surfaceId); - } - - _registry.updateSurface( - surfaceId, - newDefinition, - isNew: existing == null, - ); - - final Catalog? catalog = _findCatalogForDefinition(newDefinition); - if (catalog != null) { - newDefinition.validate(catalog.definition); - } - - if (pending != null) { - for (final A2uiMessage msg in pending) { - _handleMessageInternal(msg); - } - } - - case UpdateComponents(:final surfaceId, :final components): - if (!_registry.hasSurface(surfaceId)) { - _bufferMessage(surfaceId, message); - return; - } - - final SurfaceDefinition current = _registry.getSurface(surfaceId)!; - final Map newComponents = Map.of(current.components); - for (final component in components) { - newComponents[component.id] = component; - } - - _registry.updateSurface( - surfaceId, - current.copyWith(components: newComponents), - ); - - final SurfaceDefinition updatedDefinition = _registry.getSurface( - surfaceId, - )!; - final Catalog? catalog = _findCatalogForDefinition(updatedDefinition); - if (catalog != null) { - updatedDefinition.validate(catalog.definition); - } - - case UpdateDataModel(:final surfaceId, :final path, :final value): - if (!_registry.hasSurface(surfaceId)) { - _bufferMessage(surfaceId, message); - return; - } - - final DataModel model = _store.getDataModel(surfaceId); - model.update(path, value); - - // Note: We don't trigger a surface update here to avoid full UI refreshes - // on data changes. Components should listen to the DataModel directly. - - case DeleteSurface(:final surfaceId): - _pendingUpdates.remove(surfaceId); - _pendingUpdateTimers.remove(surfaceId)?.cancel(); - _registry.removeSurface(surfaceId); - _store.removeDataModel(surfaceId); - } - } - - void _bufferMessage(String surfaceId, A2uiMessage message) { + void _bufferMessage(String surfaceId, core.A2uiMessage message) { _pendingUpdates.putIfAbsent(surfaceId, () => []).add(message); if (!_pendingUpdateTimers.containsKey(surfaceId)) { _pendingUpdateTimers[surfaceId] = Timer(pendingUpdateTimeout, () { @@ -255,12 +302,10 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { } } - /// Handles a UI event from a surface. - /// - /// Converts the event into a [ChatMessage] and adds it to the [onSubmit] - /// stream. + /// Sends a [UserActionEvent] to [onSubmit] as a [ChatMessage]. No-op for + /// non-action [UiEvent]s. void handleUiEvent(UiEvent event) { - if (event is! UserActionEvent) return; + if (!event.isUserAction) return; _onSubmit.add( ChatMessage.user( '', @@ -273,22 +318,45 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { ); } - Catalog? _findCatalogForDefinition(SurfaceDefinition definition) { - genUiLogger.fine( - 'Finding catalog for ${definition.catalogId} in ' - '${catalogs.map((c) => c.catalogId).toList()}', - ); - return catalogs.firstWhereOrNull( - (catalog) => catalog.catalogId == definition.catalogId, + Catalog? _findCatalogForSurface(String surfaceId) { + final core.SurfaceModel? surface = _registry + .getLiveSurface(surfaceId); + if (surface == null) return null; + return catalogs.firstWhereOrNull((c) => c.catalogId == surface.catalog.id); + } + + /// Validates the components currently in [surface] against [catalog]'s + /// schema. Throws [A2uiValidationException] on the first failing component. + void _validateComponents( + String surfaceId, + core.SurfaceModel surface, + Catalog catalog, + ) { + schema_validation.validateComponents( + surfaceId: surfaceId, + components: surface.componentsModel.all.map( + (c) => (id: c.id, type: c.type, json: c.toJson()), + ), + schema: catalog.definition, ); } /// Disposes of the controller and releases all resources. - /// - /// Closes the [onSubmit] stream and cancels any pending timers. void dispose() { + _processor.groupModel.onSurfaceCreated.removeListener( + _onCoreSurfaceCreated, + ); + _processor.groupModel.onSurfaceDeleted.removeListener( + _onCoreSurfaceDeleted, + ); + _processor.groupModel.dispose(); + for (final DataModel model in _preCreateDataModels.values) { + model.dispose(); + } + for (final DataModel model in _liveDataModels.values) { + model.dispose(); + } _registry.dispose(); - _store.dispose(); _onSubmit.close(); for (final Timer timer in _pendingUpdateTimers.values) { timer.cancel(); @@ -308,26 +376,15 @@ class _ControllerContext implements SurfaceContext { _controller.registry.watchSurface(surfaceId); @override - DataModel get dataModel => _controller.store.getDataModel(surfaceId); + DataModel get dataModel => _controller._dataModelFor(surfaceId); @override - Catalog? get catalog { - final ValueListenable definitions = _controller.registry - .watchSurface(surfaceId); - final SurfaceDefinition? definition = definitions.value; - final String catalogId = definition?.catalogId ?? basicCatalogId; - return _controller.catalogs.firstWhereOrNull( - (catalog) => catalog.catalogId == catalogId, - ); - } + Catalog? get catalog => _controller._findCatalogForSurface(surfaceId); @override - void handleUiEvent(UiEvent event) { - _controller.handleUiEvent(event); - } + void handleUiEvent(UiEvent event) => _controller.handleUiEvent(event); @override - void reportError(Object error, StackTrace? stack) { - _controller.reportError(error, stack); - } + void reportError(Object error, StackTrace? stack) => + _controller.reportError(error, stack); } diff --git a/packages/genui/lib/src/engine/surface_registry.dart b/packages/genui/lib/src/engine/surface_registry.dart index 72285889c..aaf66246b 100644 --- a/packages/genui/lib/src/engine/surface_registry.dart +++ b/packages/genui/lib/src/engine/surface_registry.dart @@ -4,9 +4,10 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' hide Catalog, DataContext; import 'package:flutter/foundation.dart'; -import '../model/ui_models.dart'; +import '../model/ui_models.dart' as genui_model; import '../primitives/logging.dart'; /// Events emitted by the [SurfaceRegistry]. @@ -14,31 +15,30 @@ sealed class RegistryEvent {} /// An event indicating that a new surface has been added. class SurfaceAdded extends RegistryEvent { - /// Creates a [SurfaceAdded] event. - SurfaceAdded(this.surfaceId, this.definition); + SurfaceAdded(this.surfaceId, this.surface); final String surfaceId; - final SurfaceDefinition definition; + final SurfaceModel surface; } /// An event indicating that a surface has been removed. class SurfaceRemoved extends RegistryEvent { - /// Creates a [SurfaceRemoved] event. SurfaceRemoved(this.surfaceId); final String surfaceId; } -/// An event indicating that a surface has been updated. +/// An event indicating that a surface's components were updated. class SurfaceUpdated extends RegistryEvent { - /// Creates a [SurfaceUpdated] event. - SurfaceUpdated(this.surfaceId, this.definition); + SurfaceUpdated(this.surfaceId, this.surface); final String surfaceId; - final SurfaceDefinition definition; + final SurfaceModel surface; } -/// Manages the lifecycle and storage of [SurfaceDefinition]s. +/// Tracks live [SurfaceModel]s by surface ID and exposes Flutter-friendly +/// [ValueListenable]s for them, plus a registry-event stream. class SurfaceRegistry { - final Map> _surfaces = {}; - // Track creation/update order for cleanup policies + final Map _surfaces = {}; + final Map> + _definitions = {}; final List _surfaceOrder = []; final StreamController _eventController = StreamController.broadcast(); @@ -47,83 +47,95 @@ class SurfaceRegistry { Stream get events => _eventController.stream; /// The list of surface IDs in the order they were created or updated. - /// - /// This is used by cleanup strategies to determine which surfaces to remove. List get surfaceOrder => List.unmodifiable(_surfaceOrder); - /// Returns a [ValueListenable] that tracks the definition of the surface - /// with the given [surfaceId]. - /// - /// If the surface does not exist, a new notifier is created with a null - /// value. - ValueListenable watchSurface(String surfaceId) { - if (!_surfaces.containsKey(surfaceId)) { - genUiLogger.fine('Adding new surface $surfaceId'); - } else { - genUiLogger.fine('Fetching surface notifier for $surfaceId'); - } - return _surfaces.putIfAbsent( + /// Returns a [ValueListenable] tracking the + /// [genui_model.SurfaceDefinition] snapshot for [surfaceId]. The value is + /// `null` until the surface is registered, and becomes `null` again when + /// it is removed. + ValueListenable watchSurface( + String surfaceId, + ) { + return _definitions.putIfAbsent( surfaceId, - () => ValueNotifier(null), + () => ValueNotifier(null), ); } - /// Updates the definition of a surface. - /// - /// If [isNew] is true, a [SurfaceAdded] event is emitted. Otherwise, a - /// [SurfaceUpdated] event is emitted. - void updateSurface( - String surfaceId, - SurfaceDefinition definition, { - bool isNew = false, - }) { - final ValueNotifier notifier = _surfaces.putIfAbsent( - surfaceId, - () => ValueNotifier(null), + /// Registers a new surface, emitting a [SurfaceAdded] event. Intended + /// for GenUI internals; external callers should drive surface lifecycle + /// through `SurfaceController.handleMessage`. + @internal + void addSurface(SurfaceModel surface) { + _surfaces[surface.id] = surface; + _definitions + .putIfAbsent( + surface.id, + () => ValueNotifier(null), + ) + .value = genui_model.SurfaceDefinition.fromCore( + surface, ); - notifier.value = definition; + _surfaceOrder + ..remove(surface.id) + ..add(surface.id); + genUiLogger.info('Created new surface ${surface.id}'); + _eventController.add(SurfaceAdded(surface.id, surface)); + } - _surfaceOrder.remove(surfaceId); - _surfaceOrder.add(surfaceId); - - if (isNew) { - genUiLogger.info('Created new surface $surfaceId'); - _eventController.add(SurfaceAdded(surfaceId, definition)); - } else { - // genUiLogger.info('Updated surface $surfaceId'); // Optional logging - _eventController.add(SurfaceUpdated(surfaceId, definition)); - } + /// Signals that the components of a surface have changed. Intended for + /// GenUI internals. + @internal + void notifyUpdated(SurfaceModel surface) { + _surfaceOrder + ..remove(surface.id) + ..add(surface.id); + _definitions + .putIfAbsent( + surface.id, + () => ValueNotifier(null), + ) + .value = genui_model.SurfaceDefinition.fromCore( + surface, + ); + _eventController.add(SurfaceUpdated(surface.id, surface)); } - /// Removes a surface from the registry. - /// - /// Emits a [SurfaceRemoved] event if the surface existed. + /// Removes a surface from the registry, emitting a [SurfaceRemoved] event. + /// The [SurfaceModel] itself is owned and disposed by + /// `core.SurfaceGroupModel`. void removeSurface(String surfaceId) { - if (_surfaces.containsKey(surfaceId)) { - genUiLogger.info('Deleting surface $surfaceId'); - final ValueNotifier? notifier = _surfaces.remove( - surfaceId, - ); - notifier?.dispose(); - _surfaceOrder.remove(surfaceId); - _eventController.add(SurfaceRemoved(surfaceId)); - } + if (_surfaces.remove(surfaceId) == null) return; + genUiLogger.info('Deleting surface $surfaceId'); + _definitions.remove(surfaceId)?.dispose(); + _surfaceOrder.remove(surfaceId); + _eventController.add(SurfaceRemoved(surfaceId)); } - /// Returns true if the registry contains a surface with the given - /// [surfaceId]. + /// Returns true if the registry has a live surface for [surfaceId]. bool hasSurface(String surfaceId) => _surfaces.containsKey(surfaceId); - /// Returns the current definition of the surface with the given [surfaceId], - /// or null if it doesn't exist. - SurfaceDefinition? getSurface(String surfaceId) => - _surfaces[surfaceId]?.value; + /// Returns the current [genui_model.SurfaceDefinition] snapshot for the + /// given [surfaceId], or `null` if the surface does not exist. + genui_model.SurfaceDefinition? getSurface(String surfaceId) => + _definitions[surfaceId]?.value; + + /// Returns the live core surface model for [surfaceId], or `null` if the + /// surface does not exist. Intended for GenUI internals. + @internal + SurfaceModel? getLiveSurface(String surfaceId) => _surfaces[surfaceId]; - /// Disposes of the registry and all its resources. + /// Disposes of the registry and all per-surface notifiers. The underlying + /// [SurfaceModel]s are owned and disposed by `core.SurfaceGroupModel`, + /// not by this registry. void dispose() { _eventController.close(); - for (final ValueNotifier notifier in _surfaces.values) { + for (final ValueNotifier notifier + in _definitions.values) { notifier.dispose(); } + _surfaces.clear(); + _definitions.clear(); + _surfaceOrder.clear(); } } diff --git a/packages/genui/lib/src/facade/prompt_builder.dart b/packages/genui/lib/src/facade/prompt_builder.dart index 77ecada7c..68a1bba51 100644 --- a/packages/genui/lib/src/facade/prompt_builder.dart +++ b/packages/genui/lib/src/facade/prompt_builder.dart @@ -359,9 +359,7 @@ final class _BasicPromptBuilder extends PromptBuilder { @override Iterable systemPrompt() { - final String a2uiSchema = A2uiMessage.a2uiMessageSchema( - catalog, - ).toJson(indent: ' '); + final String a2uiSchema = a2uiMessageSchema(catalog).toJson(indent: ' '); final fragments = [ ...systemPromptFragments, diff --git a/packages/genui/lib/src/interfaces/a2ui_message_sink.dart b/packages/genui/lib/src/interfaces/a2ui_message_sink.dart index 869aec6c6..c8e71ee7a 100644 --- a/packages/genui/lib/src/interfaces/a2ui_message_sink.dart +++ b/packages/genui/lib/src/interfaces/a2ui_message_sink.dart @@ -2,10 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import '../model/a2ui_message.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; -/// An interface for a message sink that accepts [A2uiMessage]s. +/// An interface for a message sink that accepts [core.A2uiMessage]s. abstract interface class A2uiMessageSink { /// Handles a message from the client. - void handleMessage(A2uiMessage message); + void handleMessage(core.A2uiMessage message); } diff --git a/packages/genui/lib/src/interfaces/surface_context.dart b/packages/genui/lib/src/interfaces/surface_context.dart index dd76909be..333f24542 100644 --- a/packages/genui/lib/src/interfaces/surface_context.dart +++ b/packages/genui/lib/src/interfaces/surface_context.dart @@ -9,13 +9,11 @@ import '../model/data_model.dart'; import '../model/ui_models.dart'; /// An interface for a specific UI surface context. -/// -/// This provides access to the state and definition of a single surface. abstract interface class SurfaceContext { /// The ID of the surface this context is bound to. String get surfaceId; - /// The current definition of the UI for this surface. + /// The current snapshot definition of the UI for this surface. ValueListenable get definition; /// The data model for this surface. diff --git a/packages/genui/lib/src/interfaces/transport.dart b/packages/genui/lib/src/interfaces/transport.dart index ab53ffaed..46e52807a 100644 --- a/packages/genui/lib/src/interfaces/transport.dart +++ b/packages/genui/lib/src/interfaces/transport.dart @@ -4,7 +4,8 @@ import 'dart:async'; -import '../model/a2ui_message.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; + import '../model/chat_message.dart'; /// An interface for transporting messages between GenUI and an AI service. @@ -18,8 +19,8 @@ abstract interface class Transport { /// up over time. Stream get incomingText; - /// A stream of parsed [A2uiMessage]s received from the AI service. - Stream get incomingMessages; + /// A stream of parsed [core.A2uiMessage]s received from the AI service. + Stream get incomingMessages; /// Sends a request to the AI service. Future sendRequest(ChatMessage message); diff --git a/packages/genui/lib/src/model/a2ui_message.dart b/packages/genui/lib/src/model/a2ui_message.dart index 57ef8a169..d6e702e6a 100644 --- a/packages/genui/lib/src/model/a2ui_message.dart +++ b/packages/genui/lib/src/model/a2ui_message.dart @@ -4,256 +4,54 @@ import 'package:json_schema_builder/json_schema_builder.dart'; -import '../primitives/logging.dart'; -import '../primitives/simple_items.dart'; import 'a2ui_schemas.dart'; import 'catalog.dart'; -import 'data_model.dart'; -import 'ui_models.dart'; -/// A sealed class representing a message in the A2UI stream. -sealed class A2uiMessage { - /// Creates an [A2uiMessage]. - const A2uiMessage(); - - /// Creates an [A2uiMessage] from a JSON map. - factory A2uiMessage.fromJson(JsonMap json) { - try { - final Object? version = json['version']; - if (version != 'v0.9') { - throw A2uiValidationException( - 'A2UI message must have version "v0.9"', - json: json, - ); - } - if (json case {'createSurface': JsonMap data}) { - try { - return CreateSurface.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse CreateSurface message', - json: json, - cause: e, - ); - } - } - if (json case {'updateComponents': JsonMap data}) { - try { - return UpdateComponents.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse UpdateComponents message', - json: json, - cause: e, - ); - } - } - if (json case {'updateDataModel': JsonMap data}) { - try { - return UpdateDataModel.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse UpdateDataModel message', - json: json, - cause: e, - ); - } - } - if (json case {'deleteSurface': JsonMap data}) { - try { - return DeleteSurface.fromJson(data); - } catch (e) { - throw A2uiValidationException( - 'Failed to parse DeleteSurface message', - json: json, - cause: e, - ); - } - } - } on A2uiValidationException { - rethrow; - } catch (exception, stackTrace) { - genUiLogger.severe( - 'Failed to parse A2UI message from JSON: $json', - exception, - stackTrace, - ); - rethrow; - } - throw A2uiValidationException( - 'Unknown A2UI message type: ${json.keys}', - json: json, - ); - } - - /// Returns the JSON schema for an A2UI message. - static Schema a2uiMessageSchema(Catalog catalog) { - return S.combined( - title: 'A2UI Message Schema', - description: - 'Describes a JSON payload for an A2UI (Agent to UI) message, ' - 'which is used to dynamically construct and update user interfaces.', - oneOf: [ - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'createSurface': A2uiSchemas.createSurfaceSchema(), - }, - required: ['version', 'createSurface'], - additionalProperties: false, - ), - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'updateComponents': A2uiSchemas.updateComponentsSchema(catalog), - }, - required: ['version', 'updateComponents'], - additionalProperties: false, - ), - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'updateDataModel': A2uiSchemas.updateDataModelSchema(), - }, - required: ['version', 'updateDataModel'], - additionalProperties: false, - ), - S.object( - properties: { - 'version': S.string(constValue: 'v0.9'), - 'deleteSurface': A2uiSchemas.deleteSurfaceSchema(), - }, - required: ['version', 'deleteSurface'], - additionalProperties: false, - ), - ], - ); - } -} - -/// An A2UI message that signals the client to create and show a new surface. -final class CreateSurface extends A2uiMessage { - /// Creates a [CreateSurface] message. - const CreateSurface({ - required this.surfaceId, - required this.catalogId, - this.theme, - this.sendDataModel = false, - }); - - /// Creates a [CreateSurface] message from a JSON map. - factory CreateSurface.fromJson(JsonMap json) { - return CreateSurface( - surfaceId: json[surfaceIdKey] as String, - catalogId: json['catalogId'] as String, - theme: json['theme'] as JsonMap?, - sendDataModel: json['sendDataModel'] as bool? ?? false, - ); - } - - /// The ID of the surface that this message applies to. - final String surfaceId; - - /// The ID of the catalog to use for rendering this surface. - final String catalogId; - - /// The theme parameters for this surface. - final JsonMap? theme; - - /// If true, the client sends the full data model in A2A metadata. - final bool sendDataModel; - - /// Converts this message to a JSON map. - Map toJson() => { - 'version': 'v0.9', - surfaceIdKey: surfaceId, - 'catalogId': catalogId, - 'theme': ?theme, - 'sendDataModel': sendDataModel, - }; -} - -/// An A2UI message that updates a surface with new components. -final class UpdateComponents extends A2uiMessage { - /// Creates a [UpdateComponents] message. - const UpdateComponents({required this.surfaceId, required this.components}); - - /// Creates a [UpdateComponents] message from a JSON map. - factory UpdateComponents.fromJson(JsonMap json) { - return UpdateComponents( - surfaceId: json[surfaceIdKey] as String, - components: (json['components'] as List) - .map((e) => Component.fromJson(e as JsonMap)) - .toList(), - ); - } - - /// The ID of the surface that this message applies to. - final String surfaceId; - - /// The list of components to add or update. - final List components; - - /// Converts this message to a JSON map. - Map toJson() => { - 'version': 'v0.9', - surfaceIdKey: surfaceId, - 'components': components.map((c) => c.toJson()).toList(), - }; -} - -/// An A2UI message that updates the data model. -final class UpdateDataModel extends A2uiMessage { - /// Creates a [UpdateDataModel] message. - const UpdateDataModel({ - required this.surfaceId, - this.path = DataPath.root, - this.value, - }); - - /// Creates a [UpdateDataModel] message from a JSON map. - factory UpdateDataModel.fromJson(JsonMap json) { - return UpdateDataModel( - surfaceId: json[surfaceIdKey] as String, - path: DataPath(json['path'] as String? ?? '/'), - value: json['value'], - ); - } - - /// The ID of the surface that this message applies to. - final String surfaceId; - - /// The path in the data model to update. Defaults to root '/'. - final DataPath path; - - /// The new value to write to the data model. - /// - /// If null (and the key is present in the JSON), it implies deletion of the - /// key at the path. - final Object? value; - - /// Converts this message to a JSON map. - Map toJson() => { - 'version': 'v0.9', - surfaceIdKey: surfaceId, - 'path': path.toString(), - 'value': ?value, - }; -} - -/// An A2UI message that deletes a surface. -final class DeleteSurface extends A2uiMessage { - /// Creates a [DeleteSurface] message. - const DeleteSurface({required this.surfaceId}); - - /// Creates a [DeleteSurface] message from a JSON map. - factory DeleteSurface.fromJson(JsonMap json) { - return DeleteSurface(surfaceId: json[surfaceIdKey] as String); - } - - /// The ID of the surface that this message applies to. - final String surfaceId; - - /// Converts this message to a JSON map. - Map toJson() => {'version': 'v0.9', surfaceIdKey: surfaceId}; +/// Returns the JSON schema for an A2UI message, parameterized by [catalog]. +/// +/// The message types themselves live in `package:a2ui_core`; this schema is +/// GenUI-specific because it is parameterized by the renderer's [Catalog]. +Schema a2uiMessageSchema(Catalog catalog) => _buildA2uiMessageSchema(catalog); + +Schema _buildA2uiMessageSchema(Catalog catalog) { + return S.combined( + title: 'A2UI Message Schema', + description: + 'Describes a JSON payload for an A2UI (Agent to UI) message, ' + 'which is used to dynamically construct and update user interfaces.', + oneOf: [ + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'createSurface': A2uiSchemas.createSurfaceSchema(), + }, + required: ['version', 'createSurface'], + additionalProperties: false, + ), + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'updateComponents': A2uiSchemas.updateComponentsSchema(catalog), + }, + required: ['version', 'updateComponents'], + additionalProperties: false, + ), + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'updateDataModel': A2uiSchemas.updateDataModelSchema(), + }, + required: ['version', 'updateDataModel'], + additionalProperties: false, + ), + S.object( + properties: { + 'version': S.string(constValue: 'v0.9'), + 'deleteSurface': A2uiSchemas.deleteSurfaceSchema(), + }, + required: ['version', 'deleteSurface'], + additionalProperties: false, + ), + ], + ); } diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index 6aab4e2a6..d88cf57ba 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; @@ -141,7 +142,7 @@ interface class Catalog { CatalogItemContext( data: itemContext.data, id: itemContext.id, - type: widgetType, + type: itemContext.type, buildChild: (String childId, [DataContext? childDataContext]) => itemContext.buildChild( childId, @@ -262,3 +263,28 @@ class CatalogItemNotFoundException implements Exception { return buffer.toString(); } } + +class _CatalogItemComponentApi implements core.ComponentApi { + _CatalogItemComponentApi(this._item); + final CatalogItem _item; + + @override + String get name => _item.name; + + @override + Schema get schema => _item.dataSchema; +} + +/// Adapts a genui [Catalog] to the `a2ui_core` [core.Catalog] type. +extension CatalogCoreView on Catalog { + /// Returns a [core.Catalog] populated from this catalog's items, used when + /// constructing a [core.SurfaceModel] so `a2ui_core` lookups see real + /// component metadata instead of an empty stub. + core.Catalog get coreCatalog => + core.Catalog( + id: catalogId ?? 'genui_inline_$hashCode', + components: items + .map(_CatalogItemComponentApi.new) + .toList(growable: false), + ); +} diff --git a/packages/genui/lib/src/model/catalog_item.dart b/packages/genui/lib/src/model/catalog_item.dart index 8bcd0e62c..715f335eb 100644 --- a/packages/genui/lib/src/model/catalog_item.dart +++ b/packages/genui/lib/src/model/catalog_item.dart @@ -18,7 +18,7 @@ typedef ChildBuilderCallback = /// A callback that builds an example of a catalog item. /// /// The returned string must be a valid JSON representation of a list of -/// [Component] objects. One of the components in the list must have the `id` +/// component objects. One of the components in the list must have the `id` /// 'root'. typedef ExampleBuilderCallback = String Function(); @@ -26,11 +26,6 @@ typedef ExampleBuilderCallback = String Function(); typedef CatalogWidgetBuilder = Widget Function(CatalogItemContext itemContext); /// Context provided to a [CatalogItem]'s widget builder. -/// -/// This class encapsulates all the information and callbacks needed to build -/// a catalog widget, including access to the widget's data, its position in -/// the component tree, and mechanisms for building children and dispatching -/// events. final class CatalogItemContext { /// Creates a [CatalogItemContext] with the required parameters. /// @@ -145,7 +140,7 @@ final class CatalogItem { /// example usage of this widget. /// /// Each returned string must be a valid JSON representation of a list of - /// [Component] objects. For the example to be renderable, one of the + /// component objects. For the example to be renderable, one of the /// components in the list must have the `id` 'root', which will be used as /// the entry point for rendering. /// diff --git a/packages/genui/lib/src/model/data_model.dart b/packages/genui/lib/src/model/data_model.dart index cdd031110..c504c9e59 100644 --- a/packages/genui/lib/src/model/data_model.dart +++ b/packages/genui/lib/src/model/data_model.dart @@ -3,8 +3,8 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -12,7 +12,6 @@ import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; import '../utils/stream_extensions.dart'; import 'client_function.dart' as cf; - import 'data_path.dart'; export 'data_path.dart'; @@ -49,7 +48,7 @@ class DataContext implements cf.ExecutionContext { /// Subscribes to a path, resolving it against the current context. @override - ValueNotifier subscribe(DataPath path) { + ValueListenable subscribe(DataPath path) { final DataPath absolutePath = resolvePath(path); return _dataModel.subscribe(absolutePath); } @@ -58,7 +57,7 @@ class DataContext implements cf.ExecutionContext { @override Stream subscribeStream(DataPath path) { late StreamController controller; - ValueNotifier? notifier; + ValueListenable? notifier; void listener() { if (!controller.isClosed) { @@ -73,8 +72,11 @@ class DataContext implements cf.ExecutionContext { notifier!.addListener(listener); }, onCancel: () { - notifier?.removeListener(listener); - notifier?.dispose(); + final currentNotifier = notifier; + currentNotifier?.removeListener(listener); + if (currentNotifier is ChangeNotifier) { + (currentNotifier as ChangeNotifier).dispose(); + } notifier = null; controller.close(); }, @@ -92,8 +94,6 @@ class DataContext implements cf.ExecutionContext { _dataModel.update(resolvePath(path), contents); /// Creates a new, nested DataContext for a child widget. - /// - /// Used by list/template widgets to create a context for their children. @override DataContext nested(DataPath relativePath) => DataContext._(_dataModel, resolvePath(relativePath), _functions); @@ -105,10 +105,6 @@ class DataContext implements cf.ExecutionContext { /// Resolves any dynamic values (bindings or function calls) in the given /// value. - /// - /// String values are treated as literals (no interpolation). - /// Maps with a 'path' key are resolved to the value at that path. - /// Maps with a 'call' key are executed as functions. @override Stream resolve(Object? value) => _evaluateStream(value); @@ -137,7 +133,6 @@ class DataContext implements cf.ExecutionContext { return Stream.value(null); } - // Resolve arguments final Map args = {}; final Object? argsJson = callDefinition['args']; @@ -182,7 +177,6 @@ class DataContext implements cf.ExecutionContext { } /// Resolves a context map definition against a [DataContext]. -/// Future resolveContext( DataContext dataContext, JsonMap? contextDefinition, @@ -229,24 +223,12 @@ class DataModelTypeException implements Exception { abstract interface class DataModel { /// Updates the data model at a specific absolute path and notifies all /// relevant subscribers. - /// - /// If [absolutePath] is root, the entire data model is replaced - /// (if contents is a Map). void update(DataPath absolutePath, Object? contents); /// Subscribes to a specific absolute path in the data model. ValueNotifier subscribe(DataPath absolutePath); /// Binds an external state [source] to a [path] in the DataModel. - /// - /// **Side Effect:** Calling this method immediately performs a synchronous - /// `update()` on the DataModel at the specified [path] using the current - /// value of the [source]. - /// - /// If [twoWay] is true, changes in the DataModel at [path] will also - /// update the [source] (assuming [source] is a [ValueNotifier]). - /// - /// Returns a function that disposes the binding. void Function() bindExternalState({ required DataPath path, required ValueListenable source, @@ -261,62 +243,39 @@ abstract interface class DataModel { T? getValue(DataPath absolutePath); } -/// Standard in-memory implementation of [DataModel]. +/// Standard in-memory implementation of [DataModel]. Facade over +/// `a2ui_core.DataModel`. class InMemoryDataModel implements DataModel { - JsonMap _data = {}; - final Map> _subscriptions = {}; + /// Creates an empty in-memory data model. + InMemoryDataModel() : _core = core.DataModel(), _ownsCore = true; - final List _cleanupCallbacks = []; + /// Wraps an existing core data model. + @internal + InMemoryDataModel.wrap(core.DataModel coreDataModel) + : _core = coreDataModel, + _ownsCore = false; - @override - void update(DataPath absolutePath, Object? contents) { - genUiLogger.info( - 'DataModel.update: path=$absolutePath, contents=' - '${const JsonEncoder.withIndent(' ').convert(contents)}', - ); + final core.DataModel _core; + final bool _ownsCore; + final List _externalSubscriptions = []; - if (absolutePath == DataPath.root) { - if (contents is Map) { - _data = Map.from(contents); - } else { - genUiLogger.warning( - 'DataModel.update: contents for root path is not a Map: $contents', - ); - if (contents == null) { - _data = {}; - } - } - _notifySubscribers(DataPath.root); - return; - } + /// The wrapped core data model. Intended for GenUI internals only. + @internal + core.DataModel get coreDataModel => _core; - _updateValue(_data, absolutePath.segments, contents); - _notifySubscribers(absolutePath); + @override + void update(DataPath absolutePath, Object? contents) { + _core.set(absolutePath.toString(), contents); } @override ValueNotifier subscribe(DataPath absolutePath) { - genUiLogger.finer('DataModel.subscribe: path=$absolutePath'); - if (_subscriptions.containsKey(absolutePath)) { - final notifier = - _subscriptions[absolutePath]! as _RefCountedValueNotifier; - notifier.incrementRef(); - return notifier; - } - - final T? initialValue = getValue(absolutePath); - final notifier = _RefCountedValueNotifier( - initialValue, - onDispose: () { - _subscriptions.remove(absolutePath); - }, + return _SignalNotifier( + _core.watch(absolutePath.toString()), + absolutePath, ); - _subscriptions[absolutePath] = notifier; - return notifier; } - final List _externalSubscriptions = []; - @override void Function() bindExternalState({ required DataPath path, @@ -358,8 +317,6 @@ class InMemoryDataModel implements DataModel { subscription.addListener(onModelChanged); removeModelListener = () { subscription.removeListener(onModelChanged); - // When we are done with the subscription, we should dispose it to - // decrement ref count. subscription.dispose(); }; _externalSubscriptions.add(removeModelListener); @@ -379,168 +336,58 @@ class InMemoryDataModel implements DataModel { @override void dispose() { - for (final VoidCallback callback in _cleanupCallbacks) { - callback(); - } - _cleanupCallbacks.clear(); - - for (final VoidCallback callback in _externalSubscriptions) { + for (final callback in List.of(_externalSubscriptions)) { callback(); } _externalSubscriptions.clear(); - - // The DataModel does not own the refcounts of the returned notifiers. - // They are owned by the subscribers who called subscribe(). - // We only need to clear our cache. Let the subscribers dispose them. - _subscriptions.clear(); + if (_ownsCore) { + _core.dispose(); + } } @override T? getValue(DataPath absolutePath) { - if (absolutePath == DataPath.root) { - _checkType(_data, absolutePath); - return _data as T?; - } - final Object? value = _getValue(_data, absolutePath.segments); - _checkType(value, absolutePath); - return value as T?; - } - - void _checkType(Object? value, DataPath path) { + final Object? value = _core.get(absolutePath.toString()); if (value != null && value is! T) { throw DataModelTypeException( - path: path, + path: absolutePath, expectedType: T, actualType: value.runtimeType, ); } + return value as T?; } +} - Object? _getValue(Object? current, List segments) { - if (segments.isEmpty) { - return current; - } - - final String segment = segments.first; - final List remaining = segments.sublist(1); - - if (current is Map) { - return _getValue(current[segment], remaining); - } else if (current is List) { - final int? index = int.tryParse(segment); - if (index != null && index >= 0 && index < current.length) { - return _getValue(current[index], remaining); - } - } - return null; - } - - void _updateValue(Object? current, List segments, Object? value) { - if (segments.isEmpty) { - return; - } - - final String segment = segments.first; - final List remaining = segments.sublist(1); - - if (current is Map) { - if (remaining.isEmpty) { - if (value == null) { - current.remove(segment); - } else { - current[segment] = value; - } - return; - } - - Object? nextNode = current[segment]; - if (nextNode == null) { - if (value == null) { - return; - } - - final String nextSegment = remaining.first; - final isNextSegmentListIndex = int.tryParse(nextSegment) != null; - nextNode = isNextSegmentListIndex ? [] : {}; - current[segment] = nextNode; - } - _updateValue(nextNode, remaining, value); - } else if (current is List) { - final int? index = int.tryParse(segment); - if (index != null && index >= 0) { - if (remaining.isEmpty) { - if (index < current.length) { - if (value == null) { - current[index] = value; - } else { - current[index] = value; - } - } else if (index == current.length) { - if (value != null) current.add(value); - } - } else { - if (index < current.length) { - _updateValue(current[index], remaining, value); - } else if (index == current.length) { - final String nextSegment = remaining.first; - final isNextSegmentListIndex = int.tryParse(nextSegment) != null; - final Object newItem = isNextSegmentListIndex - ? [] - : {}; - current.add(newItem); - _updateValue(newItem, remaining, value); - } - } - } - } - } - - void _notifySubscribers(DataPath path) { - if (_subscriptions.containsKey(path)) { - _subscriptions[path]!.value = getValue(path); - } - - var parent = path; - while (!parent.isAbsolute || parent.segments.isNotEmpty) { - if (parent == DataPath.root) break; - if (!parent.isAbsolute && parent.segments.isEmpty) break; - parent = parent.dirname; - final _RefCountedValueNotifier? notifier = - _subscriptions[parent]; - if (notifier != null) { - final Object? newValue = getValue(parent); - if (newValue != notifier.value) { - notifier.value = newValue; - } else { - // _updateValue mutates containers in place, which means - // listeners on this ancestor won't get automatically notified by - // the `ValueNotifier.value` setter. - notifier.forceNotify(); - } - } - } - - for (final DataPath p in _subscriptions.keys.toList()) { - if (p.startsWith(path) && p != path) { - _subscriptions[p]!.value = getValue(p); +/// Bridges a preact_signals [core.ReadonlySignal] to a Flutter +/// [ValueNotifier]. +class _SignalNotifier extends ValueNotifier { + _SignalNotifier(this._signal, this._path) + : super(_cast(_signal.peek(), _path)) { + _disposeEffect = core.effect(() { + final T? newValue = _cast(_signal.value, _path); + if (newValue == value) { + notifyListeners(); + } else { + value = newValue; } - } + }); } -} -class _RefCountedValueNotifier extends ValueNotifier { - _RefCountedValueNotifier(super.value, {this.onDispose}); - - final VoidCallback? onDispose; - int _refCount = 1; + final core.ReadonlySignal _signal; + final DataPath _path; + late final void Function() _disposeEffect; bool _isDisposed = false; - void incrementRef() { - _refCount++; - } - - void forceNotify() { - notifyListeners(); + static T? _cast(Object? v, DataPath path) { + if (v != null && v is! T) { + throw DataModelTypeException( + path: path, + expectedType: T, + actualType: v.runtimeType, + ); + } + return v as T?; } @override @@ -548,16 +395,13 @@ class _RefCountedValueNotifier extends ValueNotifier { if (_isDisposed) { genUiLogger.warning( 'Attempt to dispose of already disposed notifier', - '_RefCountedValueNotifier.dispose Error', + '_SignalNotifier.dispose Error', StackTrace.current, ); return; } - _refCount--; - if (_refCount <= 0) { - _isDisposed = true; - onDispose?.call(); - super.dispose(); - } + _isDisposed = true; + _disposeEffect(); + super.dispose(); } } diff --git a/packages/genui/lib/src/model/generation_events.dart b/packages/genui/lib/src/model/generation_events.dart index c441602f7..3894b0616 100644 --- a/packages/genui/lib/src/model/generation_events.dart +++ b/packages/genui/lib/src/model/generation_events.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'a2ui_message.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; /// A base class for events related to the GenUI generation process. sealed class GenerationEvent { @@ -18,11 +18,11 @@ class TextEvent extends GenerationEvent { final String text; } -/// An event containing a parsed [A2uiMessage]. +/// An event containing a parsed [core.A2uiMessage]. class A2uiMessageEvent extends GenerationEvent { /// Creates an [A2uiMessageEvent] with the given [message]. const A2uiMessageEvent(this.message); /// The parsed message. - final A2uiMessage message; + final core.A2uiMessage message; } diff --git a/packages/genui/lib/src/model/schema_validation.dart b/packages/genui/lib/src/model/schema_validation.dart new file mode 100644 index 000000000..70b2acf88 --- /dev/null +++ b/packages/genui/lib/src/model/schema_validation.dart @@ -0,0 +1,174 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:json_schema_builder/json_schema_builder.dart'; + +import '../primitives/a2ui_validation_exception.dart'; +import '../primitives/simple_items.dart'; + +/// Validates a set of A2UI components against a catalog [schema]. +/// +/// Throws [A2uiValidationException] on the first component that fails. State +/// is not rolled back. +void validateComponents({ + required String surfaceId, + required Iterable<({String id, String type, JsonMap json})> components, + required Schema schema, +}) { + final List> allowedSchemas = _extractAllowedSchemas( + jsonDecode(schema.toJson()) as Map, + ); + if (allowedSchemas.isEmpty) return; + + for (final component in components) { + var matched = false; + final errors = []; + + for (final s in allowedSchemas) { + if (_schemaMatchesType(s, component.type)) { + try { + _validateInstance( + component.json, + s, + '/components/${component.id}', + surfaceId, + ); + matched = true; + break; + } catch (e) { + errors.add(e.toString()); + } + } + } + + if (!matched) { + if (errors.isNotEmpty) { + throw A2uiValidationException( + 'Validation failed for component ${component.id} ' + '(${component.type}): ${errors.join("; ")}', + surfaceId: surfaceId, + path: '/components/${component.id}', + ); + } + throw A2uiValidationException( + 'Unknown component type: ${component.type}', + surfaceId: surfaceId, + path: '/components/${component.id}', + ); + } + } +} + +List> _extractAllowedSchemas( + Map schemaMap, +) { + if (schemaMap.containsKey('oneOf')) { + return (schemaMap['oneOf'] as List).cast>(); + } + if (schemaMap.containsKey('properties') && + (schemaMap['properties'] as Map).containsKey('components')) { + final componentsProp = + (schemaMap['properties'] as Map)['components'] as Map; + if (componentsProp.containsKey('items')) { + final items = componentsProp['items'] as Map; + if (items.containsKey('oneOf')) { + return (items['oneOf'] as List).cast>(); + } + return [items]; + } + if (componentsProp.containsKey('properties')) { + return (componentsProp['properties'] as Map).values + .cast>() + .toList(); + } + } + return const []; +} + +bool _schemaMatchesType(Map schema, String type) { + if (schema case { + 'properties': {'component': Map compProp}, + }) { + return switch (compProp) { + {'const': String constType} when constType == type => true, + {'enum': List enums} when enums.contains(type) => true, + _ => false, + }; + } + return false; +} + +void _validateInstance( + Object? instance, + Map schema, + String path, + String surfaceId, +) { + if (instance == null) return; + + if (schema case {'const': Object? constVal} when instance != constVal) { + throw A2uiValidationException( + 'Value mismatch. Expected $constVal, got $instance', + surfaceId: surfaceId, + path: path, + ); + } + if (schema case { + 'enum': List enums, + } when !enums.contains(instance)) { + throw A2uiValidationException( + 'Value not in enum: $instance', + surfaceId: surfaceId, + path: path, + ); + } + if (schema case {'required': List required} when instance is Map) { + for (final String key in required.cast()) { + if (!instance.containsKey(key)) { + throw A2uiValidationException( + 'Missing required property: $key', + surfaceId: surfaceId, + path: path, + ); + } + } + } + if (schema case { + 'properties': Map props, + } when instance is Map) { + for (final MapEntry entry in props.entries) { + final String key = entry.key; + final propSchema = entry.value as Map; + if (instance.containsKey(key)) { + _validateInstance(instance[key], propSchema, '$path/$key', surfaceId); + } + } + } + if (schema case { + 'items': Map itemsSchema, + } when instance is List) { + for (var i = 0; i < instance.length; i++) { + _validateInstance(instance[i], itemsSchema, '$path/$i', surfaceId); + } + } + if (schema case {'oneOf': List oneOfs}) { + var oneMatched = false; + for (final Map s in oneOfs.cast>()) { + try { + _validateInstance(instance, s, path, surfaceId); + oneMatched = true; + break; + } catch (_) {} + } + if (!oneMatched) { + throw A2uiValidationException( + 'Value did not match any oneOf schema', + surfaceId: surfaceId, + path: path, + ); + } + } +} diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 3915740c3..95fccae3b 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -4,11 +4,14 @@ import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import 'package:meta/meta.dart' show internal; import '../primitives/constants.dart'; import '../primitives/simple_items.dart'; +import 'schema_validation.dart' as schema_validation; /// A callback that is called when events are sent. typedef SendEventsCallback = @@ -19,8 +22,8 @@ typedef DispatchEventCallback = void Function(UiEvent event); /// A data object that represents a user interaction event in the UI. /// -/// Used to send information from the app to the AI about user -/// actions, such as tapping a button or entering text. +/// Used to send information from the app to the AI about user actions, +/// such as tapping a button or entering text. extension type UiEvent.fromMap(JsonMap _json) { /// The ID of the surface that this event originated from. String get surfaceId => _json[surfaceIdKey] as String; @@ -32,20 +35,21 @@ extension type UiEvent.fromMap(JsonMap _json) { String get eventType => _json['eventType'] as String; /// The value associated with the event, if any. - /// - /// For example, the text in a `TextField`, or the value of a `Checkbox`. Object? get value => _json['value']; /// The timestamp of when the event occurred. DateTime get timestamp => DateTime.parse(_json['timestamp'] as String); + /// Whether this is a [UserActionEvent]. Extension types are erased to their + /// representation at runtime, so `event is UserActionEvent` is always true + /// for any [UiEvent]; the action's `name` key is the real discriminator. + bool get isUserAction => _json.containsKey('name'); + /// Converts this event to a map, suitable for JSON serialization. JsonMap toMap() => _json; } -/// A UI event that represents a user action. -/// -/// Triggers a submission to the AI, such as tapping a button. +/// A UI event that represents a user action; triggers a submission to the AI. extension type UserActionEvent.fromMap(JsonMap _json) implements UiEvent { /// Creates a [UserActionEvent] from a set of properties. UserActionEvent({ @@ -80,21 +84,9 @@ final class _Json { /// A data object that represents the entire UI definition. /// -/// The root object that defines a complete UI to be rendered. +/// Snapshot facade kept for public-API compatibility; mutation is owned by +/// `a2ui_core.SurfaceModel`. class SurfaceDefinition { - /// The ID of the surface that this UI belongs to. - final String surfaceId; - - /// The ID of the catalog to use for rendering this surface. - final String catalogId; - - /// A map of all widget definitions in the UI, keyed by their ID. - Map get components => UnmodifiableMapView(_components); - final Map _components; - - /// The theme for this surface. - final JsonMap? theme; - /// Creates a [SurfaceDefinition]. SurfaceDefinition({ required this.surfaceId, @@ -117,6 +109,32 @@ class SurfaceDefinition { ); } + /// Creates a snapshot from a live core surface model. + factory SurfaceDefinition.fromCore(core.SurfaceModel surface) { + return SurfaceDefinition( + surfaceId: surface.id, + catalogId: surface.catalog.id, + components: { + for (final core.ComponentModel component in surface.componentsModel.all) + component.id: Component.fromCore(component), + }, + theme: surface.theme.isEmpty ? null : JsonMap.from(surface.theme), + ); + } + + /// The ID of the surface that this UI belongs to. + final String surfaceId; + + /// The ID of the catalog to use for rendering this surface. + final String catalogId; + + /// A map of all widget definitions in the UI, keyed by their ID. + Map get components => UnmodifiableMapView(_components); + final Map _components; + + /// The theme for this surface. + final JsonMap? theme; + /// Creates a copy of this [SurfaceDefinition] with the given fields replaced. SurfaceDefinition copyWith({ String? catalogId, @@ -150,165 +168,14 @@ class SurfaceDefinition { } /// Validates the UI definition against a schema. - /// - /// Throws [A2uiValidationException] if validation fails. void validate(Schema schema) { - final String jsonOutput = schema.toJson(); - final schemaMap = jsonDecode(jsonOutput) as Map; - - List> allowedSchemas = []; - if (schemaMap.containsKey('oneOf')) { - allowedSchemas = (schemaMap['oneOf'] as List) - .cast>(); - } else if (schemaMap.containsKey('properties') && - (schemaMap['properties'] as Map).containsKey('components')) { - final componentsProp = - (schemaMap['properties'] as Map)['components'] - as Map; - if (componentsProp.containsKey('items')) { - final items = componentsProp['items'] as Map; - if (items.containsKey('oneOf')) { - allowedSchemas = (items['oneOf'] as List) - .cast>(); - } else { - allowedSchemas = [items]; - } - } else if (componentsProp.containsKey('properties')) { - allowedSchemas = (componentsProp['properties'] as Map).values - .cast>() - .toList(); - } - } - - if (allowedSchemas.isEmpty) { - return; - } - - for (final Component component in components.values) { - var matched = false; - List errors = []; - final JsonMap instanceJson = component.toJson(); - - for (final s in allowedSchemas) { - if (_schemaMatchesType(s, component.type)) { - try { - _validateInstance(instanceJson, s, '/components/${component.id}'); - matched = true; - break; - } catch (e) { - errors.add(e.toString()); - } - } - } - - if (!matched) { - if (errors.isNotEmpty) { - throw A2uiValidationException( - 'Validation failed for component ${component.id} ' - '(${component.type}): ${errors.join("; ")}', - surfaceId: surfaceId, - path: '/components/${component.id}', - ); - } - throw A2uiValidationException( - 'Unknown component type: ${component.type}', - surfaceId: surfaceId, - path: '/components/${component.id}', - ); - } - } - } - - bool _schemaMatchesType(Map schema, String type) { - if (schema case { - 'properties': {'component': Map compProp}, - }) { - return switch (compProp) { - {'const': String constType} when constType == type => true, - {'enum': List enums} when enums.contains(type) => true, - _ => false, - }; - } - return false; - } - - void _validateInstance( - Object? instance, - Map schema, - String path, - ) { - if (instance == null) { - return; - } - - if (schema case {'const': Object? constVal} when instance != constVal) { - throw A2uiValidationException( - 'Value mismatch. Expected $constVal, got $instance', - surfaceId: surfaceId, - path: path, - ); - } - - if (schema case { - 'enum': List enums, - } when !enums.contains(instance)) { - throw A2uiValidationException( - 'Value not in enum: $instance', - surfaceId: surfaceId, - path: path, - ); - } - - if (schema case {'required': List required} when instance is Map) { - for (final String key in required.cast()) { - if (!instance.containsKey(key)) { - throw A2uiValidationException( - 'Missing required property: $key', - surfaceId: surfaceId, - path: path, - ); - } - } - } - - if (schema case { - 'properties': Map props, - } when instance is Map) { - for (final MapEntry entry in props.entries) { - final String key = entry.key; - final propSchema = entry.value as Map; - if (instance.containsKey(key)) { - _validateInstance(instance[key], propSchema, '$path/$key'); - } - } - } - - if (schema case { - 'items': Map itemsSchema, - } when instance is List) { - for (var i = 0; i < instance.length; i++) { - _validateInstance(instance[i], itemsSchema, '$path/$i'); - } - } - - if (schema case {'oneOf': List oneOfs}) { - var oneMatched = false; - for (final Map s - in oneOfs.cast>()) { - try { - _validateInstance(instance, s, path); - oneMatched = true; - break; - } catch (_) {} - } - if (!oneMatched) { - throw A2uiValidationException( - 'Value did not match any oneOf schema', - surfaceId: surfaceId, - path: path, - ); - } - } + schema_validation.validateComponents( + surfaceId: surfaceId, + components: components.values.map( + (c) => (id: c.id, type: c.type, json: c.toJson()), + ), + schema: schema, + ); } } @@ -336,6 +203,15 @@ final class Component { return Component(id: id, type: rawType, properties: properties); } + /// Creates a snapshot from a live core component model. + factory Component.fromCore(core.ComponentModel component) { + return Component( + id: component.id, + type: component.type, + properties: JsonMap.from(component.properties), + ); + } + /// The unique ID of the component. final String id; @@ -362,76 +238,43 @@ final class Component { Object.hash(id, type, const DeepCollectionEquality().hash(properties)); } -/// Exception thrown when validation fails. -class A2uiValidationException implements Exception { - /// Creates a [A2uiValidationException]. - A2uiValidationException( - this.message, { - this.surfaceId, - this.path, - this.json, - this.cause, - }); - - /// The error message. - final String message; - - /// The ID of the surface where the validation error occurred. - final String? surfaceId; - - /// The path in the data/component model where the error occurred. - final String? path; - - /// The JSON that caused the error. - final Object? json; - - /// The underlying cause of the error. - final Object? cause; - - @override - String toString() { - final buffer = StringBuffer('A2uiValidationException: $message'); - if (surfaceId != null) buffer.write(' (surface: $surfaceId)'); - if (path != null) buffer.write(' (path: $path)'); - if (cause != null) buffer.write('\nCause: $cause'); - if (json != null) buffer.write('\nJSON: $json'); - return buffer.toString(); - } -} - -/// A sealed class representing an update to the UI managed by the system. -/// -/// Subclasses: [SurfaceAdded], [ComponentsUpdated], and [SurfaceRemoved]. +/// Surface lifecycle events emitted by `SurfaceController.surfaceUpdates`. sealed class SurfaceUpdate { - /// Creates a [SurfaceUpdate] for the given [surfaceId]. const SurfaceUpdate(this.surfaceId); - - /// The ID of the surface that was updated. final String surfaceId; } /// Fired when a new surface is created. final class SurfaceAdded extends SurfaceUpdate { - /// Creates a [SurfaceAdded] event for the given [surfaceId] and - /// [definition]. + /// Constructs from a [SurfaceDefinition]. `SurfaceController` uses + /// [SurfaceAdded.fromCore] internally. const SurfaceAdded(super.surfaceId, this.definition); - /// The definition of the new surface. + /// Internal: snapshots the definition from a live core surface. + @internal + SurfaceAdded.fromCore(super.surfaceId, core.SurfaceModel coreSurface) + : definition = SurfaceDefinition.fromCore(coreSurface); + + /// Snapshot definition for this surface. final SurfaceDefinition definition; } -/// Fired when an existing surface is modified. +/// Fired when an existing surface's component set is modified. final class ComponentsUpdated extends SurfaceUpdate { - /// Creates a [ComponentsUpdated] event for the given [surfaceId] and - /// [definition]. + /// Constructs from a [SurfaceDefinition]. `SurfaceController` uses + /// [ComponentsUpdated.fromCore] internally. const ComponentsUpdated(super.surfaceId, this.definition); - /// The new definition of the surface. + /// Internal: snapshots the definition from a live core surface. + @internal + ComponentsUpdated.fromCore(super.surfaceId, core.SurfaceModel coreSurface) + : definition = SurfaceDefinition.fromCore(coreSurface); + + /// Snapshot definition for this surface. final SurfaceDefinition definition; } /// Fired when a surface is deleted. final class SurfaceRemoved extends SurfaceUpdate { - /// Creates a [SurfaceRemoved] event for the given [surfaceId]. const SurfaceRemoved(super.surfaceId); } diff --git a/packages/genui/lib/src/primitives.dart b/packages/genui/lib/src/primitives.dart index b7a9faebe..5e71708f7 100644 --- a/packages/genui/lib/src/primitives.dart +++ b/packages/genui/lib/src/primitives.dart @@ -5,6 +5,7 @@ /// Low-level utilities used by the GenUI framework. library; +export 'primitives/a2ui_validation_exception.dart'; export 'primitives/cancellation.dart'; export 'primitives/constants.dart'; export 'primitives/logging.dart'; diff --git a/packages/genui/lib/src/primitives/a2ui_validation_exception.dart b/packages/genui/lib/src/primitives/a2ui_validation_exception.dart new file mode 100644 index 000000000..c168d84e5 --- /dev/null +++ b/packages/genui/lib/src/primitives/a2ui_validation_exception.dart @@ -0,0 +1,31 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Exception thrown when an A2UI message fails parsing or validation. +class A2uiValidationException implements Exception { + /// Creates an [A2uiValidationException]. + A2uiValidationException( + this.message, { + this.surfaceId, + this.path, + this.json, + this.cause, + }); + + final String message; + final String? surfaceId; + final String? path; + final Object? json; + final Object? cause; + + @override + String toString() { + final buffer = StringBuffer('A2uiValidationException: $message'); + if (surfaceId != null) buffer.write(' (surface: $surfaceId)'); + if (path != null) buffer.write(' (path: $path)'); + if (cause != null) buffer.write('\nCause: $cause'); + if (json != null) buffer.write('\nJSON: $json'); + return buffer.toString(); + } +} diff --git a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart index e5d6ac3b3..50597d30a 100644 --- a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart +++ b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart @@ -5,9 +5,10 @@ import 'dart:async'; import 'dart:convert'; -import '../model/a2ui_message.dart'; +import 'package:a2ui_core/a2ui_core.dart' as core; + import '../model/generation_events.dart'; -import '../model/ui_models.dart'; +import '../primitives/a2ui_validation_exception.dart'; /// Transforms a stream of text chunks into a stream of logical /// [GenerationEvent]s. @@ -181,38 +182,67 @@ class _A2uiParserStream { } } + /// Top-level keys that mark a JSON payload as an attempted A2UI message. + /// If parsing fails on one of these, surface a validation error rather + /// than fall back to plain text. `version` is included so a + /// malformed-but-versioned payload still counts as an attempted message. + static const _a2uiMessageKeys = { + 'version', + 'createSurface', + 'updateComponents', + 'updateDataModel', + 'deleteSurface', + }; + + bool _looksLikeA2uiMessage(Map json) => + json.keys.any(_a2uiMessageKeys.contains); + void _emitMessage(Object json) { if (json is Map) { - try { - _controller.add(A2uiMessageEvent(A2uiMessage.fromJson(json))); - _wasLastEventA2ui = true; - } on A2uiValidationException catch (e) { - _controller.addError(e); - _wasLastEventA2ui = false; - } catch (_) { - // Failed to parse A2UI message structure (e.g. invalid type - // discriminator) - _controller.add(TextEvent(jsonEncode(json))); - _wasLastEventA2ui = false; - } + _tryEmitOne(json); } else if (json is List) { for (final Object? item in json) { if (item is Map) { - try { - _controller.add(A2uiMessageEvent(A2uiMessage.fromJson(item))); - _wasLastEventA2ui = true; - } on A2uiValidationException catch (e) { - _controller.addError(e); - _wasLastEventA2ui = false; - } catch (_) { - _controller.add(TextEvent(jsonEncode(item))); - _wasLastEventA2ui = false; - } + _tryEmitOne(item); } } } } + void _tryEmitOne(Map json) { + try { + _controller.add(A2uiMessageEvent(_parseMessage(json))); + _wasLastEventA2ui = true; + } catch (e) { + if (_looksLikeA2uiMessage(json)) { + _controller.addError( + e is A2uiValidationException + ? e + : A2uiValidationException( + 'Failed to parse A2UI message', + json: json, + cause: e, + ), + ); + } else { + // Not an A2UI message; emit as plain text. + _controller.add(TextEvent(jsonEncode(json))); + } + _wasLastEventA2ui = false; + } + } + + core.A2uiMessage _parseMessage(Map json) { + try { + return core.A2uiMessage.fromJson(json); + } on core.A2uiValidationError catch (e) { + final String message = e.message.contains("'version'") + ? 'A2UI message must have version "v0.9"' + : e.message; + throw A2uiValidationException(message, json: json, cause: e); + } + } + _Match? _findMarkdownJson(String text) { final regex = RegExp(r'```(?:json)?\s*([\s\S]*?)\s*```'); final RegExpMatch? match = regex.firstMatch(text); diff --git a/packages/genui/lib/src/transport/a2ui_transport_adapter.dart b/packages/genui/lib/src/transport/a2ui_transport_adapter.dart index a96bb777c..7eacaa883 100644 --- a/packages/genui/lib/src/transport/a2ui_transport_adapter.dart +++ b/packages/genui/lib/src/transport/a2ui_transport_adapter.dart @@ -4,11 +4,11 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; + import '../interfaces/transport.dart'; -import '../model/a2ui_message.dart'; import '../model/chat_message.dart'; import '../model/generation_events.dart'; - import 'a2ui_parser_transformer.dart'; export '../model/generation_events.dart' @@ -38,7 +38,7 @@ class A2uiTransportAdapter implements Transport { final ManualSendCallback? onSend; final StreamController _inputStream = StreamController(); - final StreamController _messageStream = + final StreamController _messageStream = StreamController.broadcast(); late final Stream _pipeline; StreamSubscription? _pipelineSubscription; @@ -56,7 +56,7 @@ class A2uiTransportAdapter implements Transport { } /// Feeds a raw A2UI message (e.g. from a tool output or separate channel). - void addMessage(A2uiMessage message) { + void addMessage(core.A2uiMessage message) { _messageStream.add(message); } @@ -70,7 +70,7 @@ class A2uiTransportAdapter implements Transport { /// A stream of A2UI messages parsed from the input. @override - Stream get incomingMessages => _messageStream.stream; + Stream get incomingMessages => _messageStream.stream; @override Future sendRequest(ChatMessage message) async { diff --git a/packages/genui/lib/src/widgets/surface.dart b/packages/genui/lib/src/widgets/surface.dart index c1faff9d8..9c0886236 100644 --- a/packages/genui/lib/src/widgets/surface.dart +++ b/packages/genui/lib/src/widgets/surface.dart @@ -10,7 +10,6 @@ import '../model/catalog.dart'; import '../model/catalog_item.dart'; import '../model/data_model.dart'; import '../model/ui_models.dart'; - import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; import 'fallback_widget.dart'; @@ -20,8 +19,8 @@ typedef UiEventCallback = void Function(UiEvent event); /// A widget that renders a dynamic UI surface generated by the AI. /// -/// This widget connects to a [SurfaceContext] and renders the UI defined by the -/// [SurfaceDefinition] for the bound surface. +/// Rebuilds from [SurfaceContext.definition] whenever the surface's component +/// snapshot changes. class Surface extends StatefulWidget { /// Creates a [Surface]. const Surface({ @@ -47,57 +46,51 @@ class Surface extends StatefulWidget { class _SurfaceState extends State { @override Widget build(BuildContext context) { - genUiLogger.fine( - 'Outer Building surface ${widget.surfaceContext.surfaceId}', - ); return ValueListenableBuilder( valueListenable: widget.surfaceContext.definition, - builder: (context, definition, child) { - genUiLogger.fine('Building surface ${widget.surfaceContext.surfaceId}'); - if (definition == null) { - genUiLogger.info( - 'Surface ${widget.surfaceContext.surfaceId} has no definition.', - ); - return widget.defaultBuilder?.call(context) ?? - const SizedBox.shrink(); - } - // Implicit root is "root". - const rootId = 'root'; - if (definition.components.isEmpty || - !definition.components.containsKey(rootId)) { - genUiLogger.warning( - 'Surface ${widget.surfaceContext.surfaceId} has no root component.', - ); - return const SizedBox.shrink(); - } - - final Catalog? catalog = _findCatalogForDefinition(definition); - if (catalog == null) { - final error = Exception( - 'Catalog with id "${definition.catalogId}" not found.', - ); - widget.surfaceContext.reportError(error, StackTrace.current); - return FallbackWidget(error: error); - } - - return _buildWidget( - definition, - catalog, - rootId, - DataContext( - widget.surfaceContext.dataModel, - DataPath.root, - functions: catalog.functions, - ), - ); - }, + builder: (context, definition, _) => _buildDefinitionSurface(definition), + ); + } + + Widget _buildDefinitionSurface(SurfaceDefinition? definition) { + genUiLogger.fine('Building surface ${widget.surfaceContext.surfaceId}'); + if (definition == null) { + genUiLogger.info( + 'Surface ${widget.surfaceContext.surfaceId} has no definition.', + ); + return widget.defaultBuilder?.call(context) ?? const SizedBox.shrink(); + } + + const rootId = 'root'; + if (definition.components.isEmpty || + !definition.components.containsKey(rootId)) { + genUiLogger.warning( + 'Surface ${widget.surfaceContext.surfaceId} has no root component.', + ); + return const SizedBox.shrink(); + } + + final Catalog? catalog = _findCatalogForDefinition(definition); + if (catalog == null) { + final error = Exception( + 'Catalog with id "${definition.catalogId}" not found.', + ); + widget.surfaceContext.reportError(error, StackTrace.current); + return FallbackWidget(error: error); + } + + return _buildWidget( + definition, + catalog, + rootId, + DataContext( + widget.surfaceContext.dataModel, + DataPath.root, + functions: catalog.functions, + ), ); } - /// The main recursive build function. - /// It reads a widget definition and its current state from - /// `widget.definition` - /// and constructs the corresponding Flutter widget. Widget _buildWidget( SurfaceDefinition definition, Catalog catalog, @@ -159,27 +152,23 @@ class _SurfaceState extends State { return; } - // The event comes in without a surfaceId, which we add here. final Map eventMap = { ...event.toMap(), surfaceIdKey: widget.surfaceContext.surfaceId, }; - final UiEvent newEvent = event is UserActionEvent + final UiEvent newEvent = event.isUserAction ? UserActionEvent.fromMap(eventMap) : UiEvent.fromMap(eventMap); widget.surfaceContext.handleUiEvent(newEvent); } Catalog? _findCatalogForDefinition(SurfaceDefinition definition) { - // The surfaceContext is responsible for resolving the catalog based on - // the current definition in the registry. final Catalog? catalog = widget.surfaceContext.catalog; - if (catalog == null) { genUiLogger.severe( 'Catalog with id "${definition.catalogId}" not found for surface ' - '"${widget.surfaceContext.surfaceId}". Ensure the catalog is provided ' - 'to A2uiMessageProcessor.', + '"${widget.surfaceContext.surfaceId}". Ensure the catalog is ' + 'provided to SurfaceController.', ); } return catalog; @@ -187,18 +176,10 @@ class _SurfaceState extends State { } /// A delegate for handling UI actions in [Surface]. -/// -/// Implement this interface to provide custom handling for specific actions, -/// such as showing modals or navigating. abstract interface class ActionDelegate { /// Handles a [UiEvent]. /// /// Returns `true` if the event was handled, `false` otherwise. - /// - /// The [context] is the build context of the [Surface]. - /// The [genUiContext] provides access to the surface state. - /// The [buildWidget] function allows building widgets from the definition, - /// useful for rendering content inside modals or dialogs. bool handleEvent( BuildContext context, UiEvent event, @@ -208,7 +189,7 @@ abstract interface class ActionDelegate { ); } -/// The default action delegate that handles standard actions like 'showModal'. +/// The default action delegate that handles no actions itself. class DefaultActionDelegate implements ActionDelegate { /// Creates a [DefaultActionDelegate]. const DefaultActionDelegate(); diff --git a/packages/genui/lib/src/widgets/widget_utilities.dart b/packages/genui/lib/src/widgets/widget_utilities.dart index e356a785c..fa3dee44a 100644 --- a/packages/genui/lib/src/widgets/widget_utilities.dart +++ b/packages/genui/lib/src/widgets/widget_utilities.dart @@ -14,10 +14,9 @@ export '../model/data_model.dart' show resolveContext; /// A builder widget that simplifies handling of nullable `ValueListenable`s. /// -/// This widget listens to a `ValueListenable` and rebuilds its child -/// whenever the value changes. If the value is `null`, it returns a -/// `SizedBox.shrink()`, effectively hiding the child. If the value is not -/// `null`, it calls the `builder` function with the non-nullable value. +/// Listens to a `ValueListenable` and rebuilds its child whenever the +/// value changes. Returns `SizedBox.shrink()` for null; otherwise calls +/// [builder] with the non-null value. class OptionalValueBuilder extends StatelessWidget { /// The `ValueListenable` to listen to. final ValueListenable listenable; @@ -47,8 +46,7 @@ class OptionalValueBuilder extends StatelessWidget { /// A widget that binds to a value in the [DataContext] and rebuilds when it /// changes. /// -/// This widget handles the lifecycle of the underlying [ValueNotifier], -/// ensuring it is disposed when the widget is unmounted. +/// Subclasses provide type-specific conversion via [BoundValueState.convert]. abstract class BoundValue extends StatefulWidget { /// Creates a [BoundValue]. const BoundValue({ @@ -71,14 +69,18 @@ abstract class BoundValue extends StatefulWidget { State> createState(); } -/// State class for [BoundValue]. +/// Backing state for [BoundValue]. Resolves the value definition to a +/// [ValueListenable] — a data-model subscription for `{path: ...}`, a stream +/// adapter for `{call: ...}`, or a constant otherwise — and rebuilds via +/// [ValueListenableBuilder], applying [convert] to each value. abstract class BoundValueState> extends State { - ValueNotifier? _notifier; + ValueListenable? _listenable; + StreamSubscription? _streamSub; @override void initState() { super.initState(); - _initNotifier(); + _setup(); } @override @@ -86,36 +88,57 @@ abstract class BoundValueState> extends State { super.didUpdateWidget(oldWidget); if (widget.value != oldWidget.value || widget.dataContext != oldWidget.dataContext) { - _disposeNotifier(); - _initNotifier(); + _teardown(); + _setup(); } } @override void dispose() { - _disposeNotifier(); + _teardown(); super.dispose(); } - void _initNotifier() { - _notifier = createNotifier(); + void _setup() { + final Object? raw = widget.value; + if (raw is Map && raw['path'] is String) { + _listenable = widget.dataContext.subscribe( + DataPath(raw['path'] as String), + ); + } else if (raw is Map && raw.containsKey('call')) { + final notifier = ValueNotifier(null); + _streamSub = widget.dataContext + .resolve(raw) + .listen( + (Object? value) => notifier.value = value, + onError: (Object error) { + genUiLogger.warning('Error in Bound stream', error); + }, + ); + _listenable = notifier; + } else { + _listenable = ValueNotifier(raw); + } } - void _disposeNotifier() { - _notifier?.dispose(); - _notifier = null; + void _teardown() { + _streamSub?.cancel(); + _streamSub = null; + final ValueListenable? listenable = _listenable; + if (listenable is ChangeNotifier) { + (listenable as ChangeNotifier).dispose(); + } + _listenable = null; } - /// Subclasses implement this to create the specific notifier type. - ValueNotifier createNotifier(); + /// Converts a raw resolved value into the typed [T?]. + T? convert(Object? value); @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: _notifier!, - builder: (context, value, child) { - return widget.builder(context, value); - }, + return ValueListenableBuilder( + valueListenable: _listenable!, + builder: (context, raw, _) => widget.builder(context, convert(raw)), ); } } @@ -136,17 +159,7 @@ class BoundString extends BoundValue { class _BoundStringState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => _ToStringNotifier( - widget.dataContext.subscribe(DataPath(path)), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value).map((v) => v?.toString()), - ), - Object? value => ValueNotifier(value?.toString()), - }; - } + String? convert(Object? value) => value?.toString(); } /// Binds to a [bool] value. @@ -165,20 +178,14 @@ class BoundBool extends BoundValue { class _BoundBoolState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => _ToBoolNotifier( - widget.dataContext.subscribe(DataPath(path)), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value).map((v) { - if (v is bool) return v; - return v != null; - }), - ), - bool value => ValueNotifier(value), - _ => ValueNotifier(null), - }; + bool? convert(Object? value) { + if (value is bool) return value; + if (value is String) { + if (value.toLowerCase() == 'true') return true; + if (value.toLowerCase() == 'false') return false; + } + if (value is num) return value != 0; + return null; } } @@ -198,21 +205,10 @@ class BoundNumber extends BoundValue { class _BoundNumberState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => _ToNumberNotifier( - widget.dataContext.subscribe(DataPath(path)), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value).map((v) { - if (v is num) return v; - if (v is String) return num.tryParse(v); - return null; - }), - ), - num value => ValueNotifier(value), - _ => ValueNotifier(null), - }; + num? convert(Object? value) { + if (value is num) return value; + if (value is String) return num.tryParse(value); + return null; } } @@ -232,22 +228,9 @@ class BoundList extends BoundValue> { class _BoundListState extends BoundValueState, BoundList> { @override - ValueNotifier?> createNotifier() { - return switch (widget.value) { - {'path': String path} => widget.dataContext.subscribe>( - DataPath(path), - ), - {'call': _} => _StreamToValueNotifier?>( - widget.dataContext.resolve(widget.value).map((v) { - if (v is List) return v.cast(); - return null; - }), - ), - List value => ValueNotifier?>( - value.cast(), - ), - _ => ValueNotifier?>(null), - }; + List? convert(Object? value) { + if (value is List) return value.cast(); + return null; } } @@ -267,113 +250,5 @@ class BoundObject extends BoundValue { class _BoundObjectState extends BoundValueState { @override - ValueNotifier createNotifier() { - return switch (widget.value) { - {'path': String path} => widget.dataContext.subscribe( - DataPath(path), - ), - {'call': _} => _StreamToValueNotifier( - widget.dataContext.resolve(widget.value), - ), - Object? value => ValueNotifier(value), - }; - } -} - -class _StreamToValueNotifier extends ValueNotifier { - _StreamToValueNotifier(Stream stream, [T? initialValue]) - : super(initialValue) { - _subscription = stream.listen( - (value) => this.value = value, - onError: (Object error) { - // We log the error but don't crash. - // ValueNotifier doesn't support error state. - genUiLogger.warning( - 'Error in stream subscription for ValueNotifier', - error, - ); - }, - ); - } - - StreamSubscription? _subscription; - - @override - void dispose() { - _subscription?.cancel(); - super.dispose(); - } -} - -class _ToStringNotifier extends ValueNotifier { - _ToStringNotifier(this._source) : super(_source.value?.toString()) { - _source.addListener(_update); - } - - final ValueNotifier _source; - - void _update() { - super.value = _source.value?.toString(); - } - - @override - void dispose() { - _source.removeListener(_update); - _source.dispose(); - super.dispose(); - } -} - -class _ToBoolNotifier extends ValueNotifier { - _ToBoolNotifier(this._source) : super(_convert(_source.value)) { - _source.addListener(_update); - } - - final ValueNotifier _source; - - static bool? _convert(Object? value) { - if (value is bool) return value; - if (value is String) { - if (value.toLowerCase() == 'true') return true; - if (value.toLowerCase() == 'false') return false; - } - if (value is num) return value != 0; - return null; - } - - void _update() { - super.value = _convert(_source.value); - } - - @override - void dispose() { - _source.removeListener(_update); - _source.dispose(); - super.dispose(); - } -} - -class _ToNumberNotifier extends ValueNotifier { - _ToNumberNotifier(this._source) : super(_convert(_source.value)) { - _source.addListener(_update); - } - - final ValueNotifier _source; - - static num? _convert(Object? value) { - if (value is num) return value; - if (value is String) return num.tryParse(value); - return null; - } - - void _update() { - super.value = _convert(_source.value); - } - - @override - void dispose() { - _source.removeListener(_update); - _source.dispose(); - super.dispose(); - } + Object? convert(Object? value) => value; } diff --git a/packages/genui/lib/test/validation.dart b/packages/genui/lib/test/validation.dart index 1b405b539..4769c8402 100644 --- a/packages/genui/lib/test/validation.dart +++ b/packages/genui/lib/test/validation.dart @@ -4,13 +4,12 @@ import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:json_schema_builder/json_schema_builder.dart'; -import '../src/model/a2ui_message.dart'; import '../src/model/a2ui_schemas.dart'; import '../src/model/catalog.dart'; import '../src/model/catalog_item.dart' show CatalogItem; -import '../src/model/ui_models.dart'; import '../src/primitives/simple_items.dart'; /// A class to represent a validation error in a catalog item example. @@ -63,11 +62,12 @@ Future> validateCatalogItemExamples( continue; } - final List components = exampleData - .map((e) => Component.fromJson(e as JsonMap)) + final List> components = exampleData + .cast() + .map(Map.from) .toList(); - if (components.every((c) => c.id != 'root')) { + if (components.every((c) => c['id'] != 'root')) { errors.add( ExampleValidationError( i, @@ -76,13 +76,16 @@ Future> validateCatalogItemExamples( ); } - final surfaceUpdate = UpdateComponents( + final surfaceUpdate = core.UpdateComponentsMessage( surfaceId: 'test-surface', components: components, ); + // `a2ui_core.UpdateComponentsMessage.toJson()` produces + // `{'version': ..., 'updateComponents': {...}}`; the schema here + // validates the body shape only. final List validationErrors = await schema.validate( - surfaceUpdate.toJson(), + surfaceUpdate.toJson()['updateComponents'], ); if (validationErrors.isNotEmpty) { errors.add( diff --git a/packages/genui/pubspec.yaml b/packages/genui/pubspec.yaml index 8580f3eb2..71506f532 100644 --- a/packages/genui/pubspec.yaml +++ b/packages/genui/pubspec.yaml @@ -16,6 +16,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-dev002 audioplayers: ^6.6.0 collection: ^1.19.1 flutter: diff --git a/packages/genui/test/catalog/core_widgets/audio_player_test.dart b/packages/genui/test/catalog/core_widgets/audio_player_test.dart index 7d115e0d1..3a7b6cfab 100644 --- a/packages/genui/test/catalog/core_widgets/audio_player_test.dart +++ b/packages/genui/test/catalog/core_widgets/audio_player_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('AudioPlayer widget renders and has description in semantics', ( WidgetTester tester, @@ -16,8 +18,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'AudioPlayer', properties: { @@ -27,10 +29,10 @@ void main() { ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index 5df6ef82b..8743a9691 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -10,6 +10,8 @@ import 'package:genui/genui.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Button widget renders and handles taps', ( WidgetTester tester, @@ -25,8 +27,8 @@ void main() { ); surfaceController.onSubmit.listen((event) => message = event); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -36,17 +38,17 @@ void main() { }, }, ), - const Component( + component( id: 'button_text', type: 'Text', properties: {'text': 'Click Me'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -95,8 +97,8 @@ void main() { surfaceController.onSubmit.listen((event) => message = event); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -104,17 +106,17 @@ void main() { 'action': {'call': 'throwError'}, }, ), - const Component( + component( id: 'button_text', type: 'Text', properties: {'text': 'Click Me'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -168,15 +170,15 @@ void main() { const surfaceId = 'validationTest'; // Initialize with a value that fails the check surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/count'), value: 0, ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -198,7 +200,7 @@ void main() { ], }, ), - const Component( + component( id: 'button_text', type: 'Text', properties: {'text': 'Click Me'}, @@ -206,10 +208,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -238,7 +240,7 @@ void main() { // Update data model to pass the check surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/count'), value: 1, diff --git a/packages/genui/test/catalog/core_widgets/card_test.dart b/packages/genui/test/catalog/core_widgets/card_test.dart index bf54d6938..2b54e2a92 100644 --- a/packages/genui/test/catalog/core_widgets/card_test.dart +++ b/packages/genui/test/catalog/core_widgets/card_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Card widget renders child', (WidgetTester tester) async { final surfaceController = SurfaceController( @@ -17,19 +19,19 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component(id: 'root', type: 'Card', properties: {'child': 'text'}), - const Component( + final List components = [ + component(id: 'root', type: 'Card', properties: {'child': 'text'}), + component( id: 'text', type: 'Text', properties: {'text': 'This is a card.'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/check_box_test.dart b/packages/genui/test/catalog/core_widgets/check_box_test.dart index de6aced90..38aea9824 100644 --- a/packages/genui/test/catalog/core_widgets/check_box_test.dart +++ b/packages/genui/test/catalog/core_widgets/check_box_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('CheckBox widget renders and handles changes', ( WidgetTester tester, @@ -16,8 +18,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'CheckBox', properties: { @@ -27,10 +29,10 @@ void main() { ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); surfaceController .contextFor(surfaceId) diff --git a/packages/genui/test/catalog/core_widgets/choice_picker_test.dart b/packages/genui/test/catalog/core_widgets/choice_picker_test.dart index 1f23e4b6a..7bd8820c2 100644 --- a/packages/genui/test/catalog/core_widgets/choice_picker_test.dart +++ b/packages/genui/test/catalog/core_widgets/choice_picker_test.dart @@ -2,10 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { // Test case based on jobApplication.1.sample testWidgets( @@ -17,12 +20,12 @@ void main() { final controller = SurfaceController(catalogs: [catalog]); // Initial message to create surface and components - final createSurface = const CreateSurface( + final core.CreateSurfaceMessage createSurfaceMessage = createSurface( surfaceId: 'test', catalogId: 'test', ); - final updateData = const UpdateDataModel( + final core.UpdateDataModelMessage updateData = updateDataModel( surfaceId: 'test', value: { 'experience': '2-5', // Single string value, not a list @@ -30,29 +33,30 @@ void main() { path: DataPath.root, ); - final updateComponents = const UpdateComponents( - surfaceId: 'test', - components: [ - Component( - id: 'root', - type: 'ChoicePicker', - properties: { - 'label': 'Years of Experience', - 'variant': 'mutuallyExclusive', - 'options': [ - {'label': '0-1', 'value': '0-1'}, - {'label': '2-5', 'value': '2-5'}, - {'label': '5+', 'value': '5+'}, - ], - 'value': {'path': '/experience'}, - }, - ), - ], - ); + final core.UpdateComponentsMessage updateComponentsMessage = + updateComponents( + surfaceId: 'test', + components: [ + component( + id: 'root', + type: 'ChoicePicker', + properties: { + 'label': 'Years of Experience', + 'variant': 'mutuallyExclusive', + 'options': [ + {'label': '0-1', 'value': '0-1'}, + {'label': '2-5', 'value': '2-5'}, + {'label': '5+', 'value': '5+'}, + ], + 'value': {'path': '/experience'}, + }, + ), + ], + ); - controller.handleMessage(createSurface); + controller.handleMessage(createSurfaceMessage); controller.handleMessage(updateData); - controller.handleMessage(updateComponents); + controller.handleMessage(updateComponentsMessage); await tester.pumpWidget( MaterialApp( @@ -90,7 +94,7 @@ void main() { // Update data model to another single string controller.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: 'test', path: DataPath('/experience'), value: '5+', @@ -112,40 +116,41 @@ void main() { final controller = SurfaceController(catalogs: [catalog]); - final createSurface = const CreateSurface( + final core.CreateSurfaceMessage createSurfaceMessage = createSurface( surfaceId: 'test2', catalogId: 'std', ); - final updateData = const UpdateDataModel( + final core.UpdateDataModelMessage updateData = updateDataModel( surfaceId: 'test2', value: { 'selections': ['A', 'B'], }, path: DataPath.root, ); - final updateComponents = const UpdateComponents( - surfaceId: 'test2', - components: [ - Component( - id: 'root', - type: 'ChoicePicker', - properties: { - 'label': 'Multi', - 'variant': 'multipleSelection', - 'options': [ - {'label': 'A', 'value': 'A'}, - {'label': 'B', 'value': 'B'}, - {'label': 'C', 'value': 'C'}, - ], - 'value': {'path': '/selections'}, - }, - ), - ], - ); + final core.UpdateComponentsMessage updateComponentsMessage = + updateComponents( + surfaceId: 'test2', + components: [ + component( + id: 'root', + type: 'ChoicePicker', + properties: { + 'label': 'Multi', + 'variant': 'multipleSelection', + 'options': [ + {'label': 'A', 'value': 'A'}, + {'label': 'B', 'value': 'B'}, + {'label': 'C', 'value': 'C'}, + ], + 'value': {'path': '/selections'}, + }, + ), + ], + ); - controller.handleMessage(createSurface); + controller.handleMessage(createSurfaceMessage); controller.handleMessage(updateData); - controller.handleMessage(updateComponents); + controller.handleMessage(updateComponentsMessage); await tester.pumpWidget( MaterialApp( @@ -175,42 +180,43 @@ void main() { final catalog = Catalog([choicePicker], catalogId: 'std'); final controller = SurfaceController(catalogs: [catalog]); - final createSurface = const CreateSurface( + final core.CreateSurfaceMessage createSurfaceMessage = createSurface( surfaceId: 'chipsTest', catalogId: 'std', ); - final updateData = const UpdateDataModel( + final core.UpdateDataModelMessage updateData = updateDataModel( surfaceId: 'chipsTest', value: { 'tags': ['flutter'], }, path: DataPath.root, ); - final updateComponents = const UpdateComponents( - surfaceId: 'chipsTest', - components: [ - Component( - id: 'root', - type: 'ChoicePicker', - properties: { - 'label': 'Tags', - 'variant': 'multipleSelection', - 'displayStyle': 'chips', - 'filterable': true, - 'options': [ - {'label': 'Flutter', 'value': 'flutter'}, - {'label': 'Dart', 'value': 'dart'}, - {'label': 'GenUI', 'value': 'genui'}, - ], - 'value': {'path': '/tags'}, - }, - ), - ], - ); + final core.UpdateComponentsMessage updateComponentsMessage = + updateComponents( + surfaceId: 'chipsTest', + components: [ + component( + id: 'root', + type: 'ChoicePicker', + properties: { + 'label': 'Tags', + 'variant': 'multipleSelection', + 'displayStyle': 'chips', + 'filterable': true, + 'options': [ + {'label': 'Flutter', 'value': 'flutter'}, + {'label': 'Dart', 'value': 'dart'}, + {'label': 'GenUI', 'value': 'genui'}, + ], + 'value': {'path': '/tags'}, + }, + ), + ], + ); - controller.handleMessage(createSurface); + controller.handleMessage(createSurfaceMessage); controller.handleMessage(updateData); - controller.handleMessage(updateComponents); + controller.handleMessage(updateComponentsMessage); await tester.pumpWidget( MaterialApp( @@ -253,32 +259,33 @@ void main() { final catalog = Catalog([choicePicker], catalogId: 'std'); final controller = SurfaceController(catalogs: [catalog]); - final createSurface = const CreateSurface( + final core.CreateSurfaceMessage createSurfaceMessage = createSurface( surfaceId: 'nullTest', catalogId: 'std', ); // Note: We are NOT sending UpdateDataModel with the value initially. - final updateComponents = const UpdateComponents( - surfaceId: 'nullTest', - components: [ - Component( - id: 'root', - type: 'ChoicePicker', - properties: { - 'label': 'Null Check', - 'variant': 'multipleSelection', - 'options': [ - {'label': 'A', 'value': 'A'}, - ], - // Points to a path that doesn't exist yet - 'value': {'path': '/missing_path'}, - }, - ), - ], - ); + final core.UpdateComponentsMessage updateComponentsMessage = + updateComponents( + surfaceId: 'nullTest', + components: [ + component( + id: 'root', + type: 'ChoicePicker', + properties: { + 'label': 'Null Check', + 'variant': 'multipleSelection', + 'options': [ + {'label': 'A', 'value': 'A'}, + ], + // Points to a path that doesn't exist yet + 'value': {'path': '/missing_path'}, + }, + ), + ], + ); - controller.handleMessage(createSurface); - controller.handleMessage(updateComponents); + controller.handleMessage(createSurfaceMessage); + controller.handleMessage(updateComponentsMessage); await tester.pumpWidget( MaterialApp( @@ -310,15 +317,15 @@ void main() { // Store a non-list string (should be treated as selected option if // it matches). controller.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/choice'), value: 'option1', ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'ChoicePicker', properties: { @@ -333,10 +340,10 @@ void main() { ]; controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'std'), + createSurface(surfaceId: surfaceId, catalogId: 'std'), ); await tester.pumpWidget( @@ -360,7 +367,7 @@ void main() { // Also test number type controller.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/choice'), value: 123, diff --git a/packages/genui/test/catalog/core_widgets/column_test.dart b/packages/genui/test/catalog/core_widgets/column_test.dart index b6366d296..e4431e161 100644 --- a/packages/genui/test/catalog/core_widgets/column_test.dart +++ b/packages/genui/test/catalog/core_widgets/column_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Column widget renders children', (WidgetTester tester) async { final surfaceController = SurfaceController( @@ -17,26 +19,22 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Column', properties: { 'children': ['text1', 'text2'], }, ), - const Component(id: 'text1', type: 'Text', properties: {'text': 'First'}), - const Component( - id: 'text2', - type: 'Text', - properties: {'text': 'Second'}, - ), + component(id: 'text1', type: 'Text', properties: {'text': 'First'}), + component(id: 'text2', type: 'Text', properties: {'text': 'Second'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -65,31 +63,31 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Column', properties: { 'children': ['text1', 'text2', 'text3'], }, ), - const Component( + component( id: 'text1', type: 'Text', properties: {'text': 'First', 'weight': 1}, ), - const Component( + component( id: 'text2', type: 'Text', properties: {'text': 'Second', 'weight': 2}, ), - const Component(id: 'text3', type: 'Text', properties: {'text': 'Third'}), + component(id: 'text3', type: 'Text', properties: {'text': 'Third'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/date_time_input_test.dart b/packages/genui/test/catalog/core_widgets/date_time_input_test.dart index 5e216de70..7387837ad 100644 --- a/packages/genui/test/catalog/core_widgets/date_time_input_test.dart +++ b/packages/genui/test/catalog/core_widgets/date_time_input_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('renders and handles explicit updates', (tester) async { final robot = DateTimeInputRobot(tester); @@ -262,15 +264,15 @@ void main() { final surfaceController = SurfaceController(catalogs: [catalog]); const surfaceId = 'testSurface'; - final components = [ - Component(id: 'root', type: 'DateTimeInput', properties: props), + final List components = [ + component(id: 'root', type: 'DateTimeInput', properties: props), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); return (surfaceController, surfaceId); diff --git a/packages/genui/test/catalog/core_widgets/divider_test.dart b/packages/genui/test/catalog/core_widgets/divider_test.dart index ad0627096..d4b8d07ee 100644 --- a/packages/genui/test/catalog/core_widgets/divider_test.dart +++ b/packages/genui/test/catalog/core_widgets/divider_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Divider widget renders', (WidgetTester tester) async { final surfaceController = SurfaceController( @@ -14,14 +16,14 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component(id: 'root', type: 'Divider', properties: {}), + final List components = [ + component(id: 'root', type: 'Divider', properties: {}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/icon_test.dart b/packages/genui/test/catalog/core_widgets/icon_test.dart index aab63d10f..712d1d9c0 100644 --- a/packages/genui/test/catalog/core_widgets/icon_test.dart +++ b/packages/genui/test/catalog/core_widgets/icon_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Icon widget renders with literal string', ( WidgetTester tester, @@ -16,14 +18,14 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component(id: 'root', type: 'Icon', properties: {'name': 'add'}), + final List components = [ + component(id: 'root', type: 'Icon', properties: {'name': 'add'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -48,8 +50,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Icon', properties: { @@ -58,17 +60,17 @@ void main() { ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: 'testSurface', path: DataPath('/iconName'), value: 'close', ), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/image_test.dart b/packages/genui/test/catalog/core_widgets/image_test.dart index 42f7e00e7..f82e2bd4e 100644 --- a/packages/genui/test/catalog/core_widgets/image_test.dart +++ b/packages/genui/test/catalog/core_widgets/image_test.dart @@ -9,6 +9,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + class _FakeHttpClient extends Fake implements HttpClient { @override bool autoUncompress = true; @@ -32,18 +34,18 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Image', properties: {'url': 'https://example.com/nonexistent.png'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -82,18 +84,18 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Image', properties: {'url': 'https://example.com/image.png'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/list_test.dart b/packages/genui/test/catalog/core_widgets/list_test.dart index 05947aedf..25c0faf75 100644 --- a/packages/genui/test/catalog/core_widgets/list_test.dart +++ b/packages/genui/test/catalog/core_widgets/list_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('List widget renders children', (WidgetTester tester) async { final surfaceController = SurfaceController( @@ -17,26 +19,22 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'List', properties: { 'children': ['text1', 'text2'], }, ), - const Component(id: 'text1', type: 'Text', properties: {'text': 'First'}), - const Component( - id: 'text2', - type: 'Text', - properties: {'text': 'Second'}, - ), + component(id: 'text1', type: 'Text', properties: {'text': 'First'}), + component(id: 'text2', type: 'Text', properties: {'text': 'Second'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -64,8 +62,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'List', properties: { @@ -73,17 +71,13 @@ void main() { 'children': ['text1'], }, ), - const Component( - id: 'text1', - type: 'Text', - properties: {'text': 'Center'}, - ), + component(id: 'text1', type: 'Text', properties: {'text': 'Center'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/modal_test.dart b/packages/genui/test/catalog/core_widgets/modal_test.dart index dec6dc399..6e61cdbdc 100644 --- a/packages/genui/test/catalog/core_widgets/modal_test.dart +++ b/packages/genui/test/catalog/core_widgets/modal_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Modal widget renders and handles taps', ( WidgetTester tester, @@ -20,28 +22,28 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Modal', properties: {'trigger': 'trigger_text', 'content': 'modal_content'}, ), - const Component( + component( id: 'trigger_text', type: 'Text', properties: {'text': 'Open Modal'}, ), - const Component( + component( id: 'modal_content', type: 'Text', properties: {'text': 'This is a modal.'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/row_test.dart b/packages/genui/test/catalog/core_widgets/row_test.dart index 6cadc5896..df5234d6f 100644 --- a/packages/genui/test/catalog/core_widgets/row_test.dart +++ b/packages/genui/test/catalog/core_widgets/row_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Row widget renders children', (WidgetTester tester) async { final surfaceController = SurfaceController( @@ -17,26 +19,22 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Row', properties: { 'children': ['text1', 'text2'], }, ), - const Component(id: 'text1', type: 'Text', properties: {'text': 'First'}), - const Component( - id: 'text2', - type: 'Text', - properties: {'text': 'Second'}, - ), + component(id: 'text1', type: 'Text', properties: {'text': 'First'}), + component(id: 'text2', type: 'Text', properties: {'text': 'Second'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -65,31 +63,31 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Row', properties: { 'children': ['text1', 'text2', 'text3'], }, ), - const Component( + component( id: 'text1', type: 'Text', properties: {'text': 'First', 'weight': 1}, ), - const Component( + component( id: 'text2', type: 'Text', properties: {'text': 'Second', 'weight': 2}, ), - const Component(id: 'text3', type: 'Text', properties: {'text': 'Third'}), + component(id: 'text3', type: 'Text', properties: {'text': 'Third'}), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -137,7 +135,7 @@ void main() { // Initial data with items surfaceController.handleMessage( - const UpdateDataModel( + updateDataModel( surfaceId: surfaceId, value: { 'items': ['Item 1', 'Item 2', 'Item 3'], @@ -145,15 +143,15 @@ void main() { ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Row', properties: { 'children': {'path': '/items', 'componentId': 'textItem'}, }, ), - const Component( + component( id: 'textItem', type: 'Text', properties: { @@ -163,10 +161,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets/slider_test.dart b/packages/genui/test/catalog/core_widgets/slider_test.dart index c8c4bec5d..48e5ec1cb 100644 --- a/packages/genui/test/catalog/core_widgets/slider_test.dart +++ b/packages/genui/test/catalog/core_widgets/slider_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Slider widget renders and handles changes', ( WidgetTester tester, @@ -16,8 +18,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Slider', properties: { @@ -26,10 +28,10 @@ void main() { ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); surfaceController .contextFor(surfaceId) @@ -66,8 +68,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Slider', properties: { @@ -77,10 +79,10 @@ void main() { ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); surfaceController .contextFor(surfaceId) diff --git a/packages/genui/test/catalog/core_widgets/tabs_test.dart b/packages/genui/test/catalog/core_widgets/tabs_test.dart index 57e3385ac..0f00b127d 100644 --- a/packages/genui/test/catalog/core_widgets/tabs_test.dart +++ b/packages/genui/test/catalog/core_widgets/tabs_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('Tabs widget renders and handles taps', ( WidgetTester tester, @@ -19,8 +21,8 @@ void main() { ], ); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Tabs', properties: { @@ -31,22 +33,22 @@ void main() { ], }, ), - const Component( + component( id: 'text1', type: 'Text', properties: {'component': 'Text', 'text': 'This is the first tab.'}, ), - const Component( + component( id: 'text2', type: 'Text', properties: {'component': 'Text', 'text': 'This is the second tab.'}, ), ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -84,15 +86,15 @@ void main() { // Initialize data model with tab 1 (index 1) active surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/'), value: {'currentTab': 1}, ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Tabs', properties: { @@ -104,12 +106,12 @@ void main() { ], }, ), - const Component( + component( id: 'text1', type: 'Text', properties: {'component': 'Text', 'text': 'Content 1'}, ), - const Component( + component( id: 'text2', type: 'Text', properties: {'component': 'Text', 'text': 'Content 2'}, @@ -117,10 +119,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); // Initial build @@ -141,7 +143,7 @@ void main() { // Update data model to switch to Tab 1 (index 0) surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: 'testSurface', path: DataPath('/currentTab'), value: 0, diff --git a/packages/genui/test/catalog/core_widgets/text_field_test.dart b/packages/genui/test/catalog/core_widgets/text_field_test.dart index 9abfc9d82..2b30efba1 100644 --- a/packages/genui/test/catalog/core_widgets/text_field_test.dart +++ b/packages/genui/test/catalog/core_widgets/text_field_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../../test_infra/message_builders.dart'; + void main() { testWidgets('TextField with no weight in Row defaults to weight: 1 ' 'and expands', (WidgetTester tester) async { @@ -14,15 +16,15 @@ void main() { ); addTearDown(surfaceController.dispose); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Row', properties: { 'children': ['text_field'], }, ), - const Component( + component( id: 'text_field', type: 'TextField', properties: {'label': 'Input'}, @@ -31,10 +33,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), ); await tester.pumpWidget( @@ -70,15 +72,15 @@ void main() { ); addTearDown(surfaceController.dispose); const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Row', properties: { 'children': ['text_field'], }, ), - const Component( + component( id: 'text_field', type: 'TextField', properties: {'label': 'Input', 'weight': 1}, @@ -86,10 +88,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), ); await tester.pumpWidget( @@ -125,15 +127,15 @@ void main() { const surfaceId = 'validationTest'; // Initialize with invalid value surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/myValue'), value: 'initial', ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'TextField', properties: { @@ -156,10 +158,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), ); await tester.pumpWidget( @@ -192,11 +194,11 @@ void main() { const surfaceId = 'validationWrapperTest'; // Initialize with invalid value (empty string) surfaceController.handleMessage( - UpdateDataModel(surfaceId: surfaceId, path: DataPath('/name'), value: ''), + updateDataModel(surfaceId: surfaceId, path: DataPath('/name'), value: ''), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'TextField', properties: { @@ -220,10 +222,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), ); await tester.pumpWidget( @@ -257,15 +259,15 @@ void main() { const surfaceId = 'validationTypeTest'; // Initialize with an integer value surfaceController.handleMessage( - UpdateDataModel( + updateDataModel( surfaceId: surfaceId, path: DataPath('/name'), value: 123, ), ); - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'TextField', properties: { @@ -276,10 +278,10 @@ void main() { ]; surfaceController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); surfaceController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: basicCatalogId), + createSurface(surfaceId: surfaceId, catalogId: basicCatalogId), ); await tester.pumpWidget( diff --git a/packages/genui/test/catalog/core_widgets_test.dart b/packages/genui/test/catalog/core_widgets_test.dart index 24c6a7fdb..9a6f69603 100644 --- a/packages/genui/test/catalog/core_widgets_test.dart +++ b/packages/genui/test/catalog/core_widgets_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../test_infra/message_builders.dart'; + void main() { group('Basic Widgets', () { final Catalog testCatalog = BasicCatalogItems.asCatalog(); @@ -16,7 +18,7 @@ void main() { Future pumpWidgetWithDefinition( WidgetTester tester, String rootId, - List components, + List components, ) async { message = null; controller?.dispose(); @@ -24,10 +26,10 @@ void main() { controller!.onSubmit.listen((event) => message = event); const surfaceId = 'testSurface'; controller!.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); controller!.handleMessage( - CreateSurface(surfaceId: surfaceId, catalogId: testCatalog.catalogId!), + createSurface(surfaceId: surfaceId, catalogId: testCatalog.catalogId!), ); await tester.pumpWidget( MaterialApp( @@ -39,8 +41,8 @@ void main() { } testWidgets('Button renders and handles taps', (WidgetTester tester) async { - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -50,11 +52,7 @@ void main() { }, }, ), - const Component( - id: 'text', - type: 'Text', - properties: {'text': 'Click Me'}, - ), + component(id: 'text', type: 'Text', properties: {'text': 'Click Me'}), ]; await pumpWidgetWithDefinition(tester, 'root', components); @@ -67,8 +65,8 @@ void main() { }); testWidgets('Text renders from data model', (WidgetTester tester) async { - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Text', properties: { @@ -78,8 +76,9 @@ void main() { ]; await pumpWidgetWithDefinition(tester, 'root', components); - controller!.store - .getDataModel('testSurface') + controller! + .contextFor('testSurface') + .dataModel .update(DataPath('/myText'), 'Hello from data model'); await tester.pumpAndSettle(); @@ -87,24 +86,16 @@ void main() { }); testWidgets('Column renders children', (WidgetTester tester) async { - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Column', properties: { 'children': ['text1', 'text2'], }, ), - const Component( - id: 'text1', - type: 'Text', - properties: {'text': 'First'}, - ), - const Component( - id: 'text2', - type: 'Text', - properties: {'text': 'Second'}, - ), + component(id: 'text1', type: 'Text', properties: {'text': 'First'}), + component(id: 'text2', type: 'Text', properties: {'text': 'Second'}), ]; await pumpWidgetWithDefinition(tester, 'root', components); @@ -116,8 +107,8 @@ void main() { testWidgets('TextField renders and handles changes/submissions', ( WidgetTester tester, ) async { - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'TextField', properties: { @@ -131,8 +122,9 @@ void main() { ]; await pumpWidgetWithDefinition(tester, 'field', components); - controller!.store - .getDataModel('testSurface') + controller! + .contextFor('testSurface') + .dataModel .update(DataPath('/myValue'), 'initial'); await tester.pumpAndSettle(); @@ -144,8 +136,9 @@ void main() { // Test onChanged await tester.enterText(textFieldFinder, 'new value'); expect( - controller!.store - .getDataModel('testSurface') + controller! + .contextFor('testSurface') + .dataModel .getValue(DataPath('/myValue')), 'new value', ); diff --git a/packages/genui/test/core/a2ui_message_processor_validation_test.dart b/packages/genui/test/core/a2ui_message_processor_validation_test.dart index 1177720a4..f081afe02 100644 --- a/packages/genui/test/core/a2ui_message_processor_validation_test.dart +++ b/packages/genui/test/core/a2ui_message_processor_validation_test.dart @@ -7,6 +7,8 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../test_infra/message_builders.dart'; + void main() { group('SurfaceController Validation', () { test('CreateSurface fails validation with empty surfaceId', () async { @@ -28,7 +30,7 @@ void main() { ); controller.handleMessage( - const CreateSurface(surfaceId: '', catalogId: 'default'), + createSurface(surfaceId: '', catalogId: 'default'), ); await future; @@ -56,14 +58,14 @@ void main() { ); controller.handleMessage( - const CreateSurface(surfaceId: 'surf1', catalogId: basicCatalogId), + createSurface(surfaceId: 'surf1', catalogId: basicCatalogId), ); controller.handleMessage( - const UpdateComponents( + updateComponents( surfaceId: 'surf1', components: [ - Component( + component( id: 'badText', type: 'Text', properties: {}, diff --git a/packages/genui/test/engine/data_model_store_test.dart b/packages/genui/test/engine/data_model_store_test.dart deleted file mode 100644 index f1d76affd..000000000 --- a/packages/genui/test/engine/data_model_store_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/src/engine/data_model_store.dart'; -import 'package:genui/src/model/data_model.dart'; - -void main() { - group('DataModelStore', () { - late DataModelStore store; - - setUp(() { - store = DataModelStore(); - }); - - test('getDataModel creates new model if not exists', () { - final DataModel model = store.getDataModel('s1'); - expect(model, isNotNull); - expect(store.dataModels['s1'], same(model)); - }); - - test('getDataModel returns existing model', () { - final DataModel model1 = store.getDataModel('s1'); - final DataModel model2 = store.getDataModel('s1'); - expect(model1, same(model2)); - }); - - test('removeDataModel removes model and detaches surface', () { - final DataModel model = store.getDataModel('s1'); - store.attachSurface('s1'); - - store.removeDataModel('s1'); - expect(store.dataModels.containsKey('s1'), isFalse); - // We can't directly check `_attachedSurfaces`, but calling detach - // directly shouldn't throw. (Internal state check is implicit via - // coverage) - - // We check that getting it again returns a new one - final DataModel newModel = store.getDataModel('s1'); - expect(model, isNot(same(newModel))); - }); - - test('attachSurface and detachSurface', () { - // These are tested primarily through side-effects in DataModelStore, - // but their execution shouldn't throw errors. - store.attachSurface('s1'); - store.detachSurface('s1'); - expect(true, isTrue); - }); - - test('dataModels returns unmodifiable map', () { - store.getDataModel('s1'); - expect( - () => store.dataModels['s2'] = InMemoryDataModel(), - throwsUnsupportedError, - ); - }); - - test('dispose calls dispose on all data models', () { - store.getDataModel('s1'); - store.getDataModel('s2'); - expect(store.dataModels.length, 2); - - store.dispose(); - // Dispose should not throw - expect(true, isTrue); - }); - }); -} diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index 88e0bc588..64915f73d 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -10,6 +11,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import '../test_infra/message_builders.dart'; + void main() { group('$SurfaceController', () { late SurfaceController controller; @@ -34,16 +37,12 @@ void main() { test('handleMessage adds a new surface and fires SurfaceAdded with ' 'definition', () async { const surfaceId = 's1'; - final components = [ - const Component( - id: 'root', - type: 'Text', - properties: {'text': 'Hello'}, - ), + final List components = [ + component(id: 'root', type: 'Text', properties: {'text': 'Hello'}), ]; controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); final Future> futureUpdates = controller @@ -51,7 +50,7 @@ void main() { .take(2) .toList(); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); final List updates = await futureUpdates; @@ -80,19 +79,11 @@ void main() { 'handleMessage updates an existing surface and fires ComponentsUpdated', () async { const surfaceId = 's1'; - final oldComponents = [ - const Component( - id: 'root', - type: 'Text', - properties: {'text': 'Old'}, - ), + final List oldComponents = [ + component(id: 'root', type: 'Text', properties: {'text': 'Old'}), ]; - final newComponents = [ - const Component( - id: 'root', - type: 'Text', - properties: {'text': 'New'}, - ), + final List newComponents = [ + component(id: 'root', type: 'Text', properties: {'text': 'New'}), ]; final Future expectation = expectLater( @@ -105,13 +96,13 @@ void main() { ); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: oldComponents), + updateComponents(surfaceId: surfaceId, components: oldComponents), ); controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: newComponents), + updateComponents(surfaceId: surfaceId, components: newComponents), ); await expectation; @@ -120,24 +111,20 @@ void main() { test('handleMessage removes a surface and fires SurfaceRemoved', () async { const surfaceId = 's1'; - final components = [ - const Component( - id: 'root', - type: 'Text', - properties: {'text': 'Hello'}, - ), + final List components = [ + component(id: 'root', type: 'Text', properties: {'text': 'Hello'}), ]; controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); final Future futureUpdate = controller.surfaceUpdates.first; - controller.handleMessage(const DeleteSurface(surfaceId: surfaceId)); + controller.handleMessage(deleteSurface(surfaceId: surfaceId)); final SurfaceUpdate update = await futureUpdate; expect(update, isA()); @@ -154,6 +141,105 @@ void main() { expect(notifier1.value, isNull); }); + test('public SurfaceAdded / ComponentsUpdated constructors are ' + 'definition-based', () { + final def = SurfaceDefinition(surfaceId: 's1'); + final added = SurfaceAdded('s1', def); + expect(added.surfaceId, 's1'); + expect(added.definition, same(def)); + + final updated = ComponentsUpdated('s1', def); + expect(updated.surfaceId, 's1'); + expect(updated.definition, same(def)); + }); + + test( + 'registry watchSurface/getSurface expose SurfaceDefinition snapshots', + () { + const surfaceId = 's1'; + final ValueListenable notifier = controller.registry + .watchSurface(surfaceId); + expect(notifier.value, isNull); + expect(controller.registry.getSurface(surfaceId), isNull); + + controller.handleMessage( + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + final SurfaceDefinition? def = controller.registry.getSurface( + surfaceId, + ); + expect(def, isNotNull); + expect(def!.catalogId, 'test_catalog'); + expect(notifier.value, same(def)); + }, + ); + + test('contextFor exposes the live surface data model', () { + const surfaceId = 's1'; + controller.handleMessage( + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + controller.handleMessage( + updateDataModel( + surfaceId: surfaceId, + path: DataPath.root, + value: {'name': 'Alice'}, + ), + ); + + final DataModel model = controller.contextFor(surfaceId).dataModel; + expect(model.getValue(DataPath('/name')), 'Alice'); + expect(controller.contextFor(surfaceId).dataModel, same(model)); + + controller.handleMessage( + updateDataModel( + surfaceId: surfaceId, + path: DataPath('/name'), + value: 'Bob', + ), + ); + expect(model.getValue(DataPath('/name')), 'Bob'); + }); + + test('contextFor(surfaceId).dataModel returns a writable model ' + 'before createSurface', () { + const surfaceId = 'pre_create'; + final DataModel model = controller.contextFor(surfaceId).dataModel; + expect(() => model.update(DataPath('/foo'), 'bar'), returnsNormally); + expect(model.getValue(DataPath('/foo')), 'bar'); + }); + + test('pre-create data written via contextFor is migrated into the live ' + 'surface model when createSurface arrives', () async { + const surfaceId = 'migrate_me'; + controller + .contextFor(surfaceId) + .dataModel + .update(DataPath('/name'), 'Alice'); + + controller.handleMessage( + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + // Post-create, contextFor.dataModel routes through the live core + // surface model, so reading back here proves the migration landed. + final DataModel model = controller.contextFor(surfaceId).dataModel; + expect(model.getValue(DataPath('/name')), 'Alice'); + }); + + test('pre-create root-null write is preserved across createSurface', () { + const surfaceId = 'null_root'; + controller.contextFor(surfaceId).dataModel.update(DataPath.root, null); + + controller.handleMessage( + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + final DataModel model = controller.contextFor(surfaceId).dataModel; + expect(model.getValue(DataPath.root), isNull); + }); + test('dispose() closes the updates stream', () async { var isClosed = false; controller.surfaceUpdates.listen( @@ -170,9 +256,6 @@ void main() { }); test('can handle UI event', () async { - controller.store - .getDataModel('testSurface') - .update(DataPath('/myValue'), 'testValue'); final Future future = controller.onSubmit.first; final now = DateTime.now(); final event = UserActionEvent( @@ -205,13 +288,30 @@ void main() { expect(part.interaction, expectedJson); }); + test('handleUiEvent ignores non-action UiEvents', () async { + var submitted = false; + final StreamSubscription sub = controller.onSubmit.listen( + (_) => submitted = true, + ); + final event = UiEvent.fromMap({ + 'widgetId': 'testWidget', + 'eventType': 'onChanged', + 'value': 'hello', + 'timestamp': DateTime.now().toIso8601String(), + }); + controller.handleUiEvent(event); + await Future.delayed(Duration.zero); + expect(submitted, isFalse); + await sub.cancel(); + }); + test( 'handleMessage reports validation error with correct structure', () async { // Trigger validation error by using an empty surface ID. final Future messageFuture = controller.onSubmit.first; controller.handleMessage( - const CreateSurface(surfaceId: '', catalogId: 'test_catalog'), + createSurface(surfaceId: '', catalogId: 'test_catalog'), ); final ChatMessage message = await messageFuture; @@ -229,6 +329,38 @@ void main() { }, ); + test('rejects empty surfaceId on non-create messages', () async { + final Future messageFuture = controller.onSubmit.first; + controller.handleMessage(updateDataModel(surfaceId: '', value: 1)); + + final ChatMessage message = await messageFuture; + final UiInteractionPart part = message.parts.uiInteractionParts.first; + final errorJson = jsonDecode(part.interaction) as Map; + final errorMap = errorJson['error']! as Map; + expect(errorMap['code'], 'VALIDATION_FAILED'); + expect(errorMap['surfaceId'], ''); + expect(errorMap['path'], 'surfaceId'); + }); + + test( + 'duplicate createSurface for an active surface reports an error', + () async { + controller.handleMessage( + createSurface(surfaceId: 's1', catalogId: 'test_catalog'), + ); + final Future messageFuture = controller.onSubmit.first; + controller.handleMessage( + createSurface(surfaceId: 's1', catalogId: 'test_catalog'), + ); + + final ChatMessage message = await messageFuture; + final UiInteractionPart part = message.parts.uiInteractionParts.first; + final errorJson = jsonDecode(part.interaction) as Map; + final errorMap = errorJson['error']! as Map; + expect(errorMap['surfaceId'], 's1'); + }, + ); + test('drops pending updates after timeout', () async { // Create controller with short timeout final shortTimeoutController = SurfaceController( @@ -238,8 +370,8 @@ void main() { addTearDown(shortTimeoutController.dispose); const surfaceId = 'timedOutSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Text', properties: {'text': 'Should not be seen'}, @@ -248,7 +380,7 @@ void main() { // 1. Send update for non-existent surface (buffered) shortTimeoutController.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); // 2. Wait for timeout @@ -260,7 +392,7 @@ void main() { .take(1) .toList(); shortTimeoutController.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); // 4. Verify surface created but NO update applied @@ -277,7 +409,8 @@ void main() { await Future.delayed(Duration.zero); final SurfaceDefinition? surface = shortTimeoutController.registry - .getSurface(surfaceId); + .watchSurface(surfaceId) + .value; expect(surface, isNotNull); // Updates NOT applied, so components should be empty (or default) expect(surface!.components, isEmpty); @@ -304,20 +437,17 @@ void main() { const surfaceId = 'strictSurface'; strictController.handleMessage( - const CreateSurface( - surfaceId: surfaceId, - catalogId: 'strict_catalog', - ), + createSurface(surfaceId: surfaceId, catalogId: 'strict_catalog'), ); final Future future = strictController.onSubmit.first; // Send invalid component (missing requiredProp) strictController.handleMessage( - const UpdateComponents( + updateComponents( surfaceId: surfaceId, components: [ - Component(id: 'bad', type: 'StrictWidget', properties: {}), + component(id: 'bad', type: 'StrictWidget', properties: {}), ], ), ); diff --git a/packages/genui/test/error_reporting_test.dart b/packages/genui/test/error_reporting_test.dart index 281c03626..fcd30711d 100644 --- a/packages/genui/test/error_reporting_test.dart +++ b/packages/genui/test/error_reporting_test.dart @@ -4,11 +4,11 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; - import 'package:json_schema_builder/json_schema_builder.dart'; import 'package:logging/logging.dart'; @@ -35,9 +35,9 @@ void main() { }; try { - A2uiMessage.fromJson(json); - fail('Should have thrown A2uiValidationException'); - } on A2uiValidationException catch (e) { + core.A2uiMessage.fromJson(json); + fail('Should have thrown A2uiValidationError'); + } on core.A2uiValidationError catch (e) { expect(e.message, contains('Unknown A2UI message type')); } }, diff --git a/packages/genui/test/facade/conversation_test.dart b/packages/genui/test/facade/conversation_test.dart index d666cc1e8..e74886949 100644 --- a/packages/genui/test/facade/conversation_test.dart +++ b/packages/genui/test/facade/conversation_test.dart @@ -7,6 +7,8 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import '../test_infra/message_builders.dart'; + void main() { group('Conversation', () { late A2uiTransportAdapter adapter; @@ -134,9 +136,7 @@ void main() { final events = []; conversation.events.listen(events.add); - adapter.addMessage( - const CreateSurface(surfaceId: 'surf1', catalogId: 'cat'), - ); + adapter.addMessage(createSurface(surfaceId: 'surf1', catalogId: 'cat')); await Future.delayed(Duration.zero); expect(conversation.state.value.surfaces, contains('surf1')); @@ -149,7 +149,7 @@ void main() { // update components adapter.addMessage( - const UpdateComponents(surfaceId: 'surf1', components: []), + updateComponents(surfaceId: 'surf1', components: const []), ); await Future.delayed(Duration.zero); @@ -161,7 +161,7 @@ void main() { ); // remove surface - adapter.addMessage(const DeleteSurface(surfaceId: 'surf1')); + adapter.addMessage(deleteSurface(surfaceId: 'surf1')); await Future.delayed(Duration.zero); expect(conversation.state.value.surfaces, isNot(contains('surf1'))); diff --git a/packages/genui/test/genui_surface_test.dart b/packages/genui/test/genui_surface_test.dart index 6d22d20e9..75959f1b4 100644 --- a/packages/genui/test/genui_surface_test.dart +++ b/packages/genui/test/genui_surface_test.dart @@ -7,6 +7,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; import 'package:logging/logging.dart'; +import 'test_infra/message_builders.dart'; + void main() { late SurfaceController controller; final testCatalog = Catalog([ @@ -26,8 +28,8 @@ void main() { WidgetTester tester, ) async { const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -37,13 +39,13 @@ void main() { }, }, ), - const Component(id: 'text', type: 'Text', properties: {'text': 'Hello'}), + component(id: 'text', type: 'Text', properties: {'text': 'Hello'}), ]; controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -58,8 +60,8 @@ void main() { testWidgets('SurfaceWidget handles events', (WidgetTester tester) async { const surfaceId = 'testSurface'; - final components = [ - const Component( + final List components = [ + component( id: 'root', type: 'Button', properties: { @@ -69,13 +71,13 @@ void main() { }, }, ), - const Component(id: 'text', type: 'Text', properties: {'text': 'Hello'}), + component(id: 'text', type: 'Text', properties: {'text': 'Hello'}), ]; controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); controller.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); await tester.pumpWidget( @@ -92,22 +94,15 @@ void main() { 'SurfaceWidget renders container and logs error on catalog miss', (WidgetTester tester) async { const surfaceId = 'testSurface'; - final components = [ - const Component( - id: 'root', - type: 'Text', - properties: {'text': 'Hello'}, - ), + final List components = [ + component(id: 'root', type: 'Text', properties: {'text': 'Hello'}), ]; controller.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), + updateComponents(surfaceId: surfaceId, components: components), ); // Request a catalogId that doesn't exist in the controller. controller.handleMessage( - const CreateSurface( - surfaceId: surfaceId, - catalogId: 'non_existent_catalog', - ), + createSurface(surfaceId: surfaceId, catalogId: 'non_existent_catalog'), ); final logs = []; @@ -139,4 +134,42 @@ void main() { ); }, ); + + testWidgets('rebuilds when components change after creation', ( + WidgetTester tester, + ) async { + const surfaceId = 'testSurface'; + controller.handleMessage( + updateComponents( + surfaceId: surfaceId, + components: [ + component(id: 'root', type: 'Text', properties: {'text': 'first'}), + ], + ), + ); + controller.handleMessage( + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Surface(surfaceContext: controller.contextFor(surfaceId)), + ), + ); + expect(find.text('first'), findsOneWidget); + + // Updating the live surface should rebuild it in place. + controller.handleMessage( + updateComponents( + surfaceId: surfaceId, + components: [ + component(id: 'root', type: 'Text', properties: {'text': 'second'}), + ], + ), + ); + await tester.pump(); + + expect(find.text('second'), findsOneWidget); + expect(find.text('first'), findsNothing); + }); } diff --git a/packages/genui/test/model/a2ui_message_test.dart b/packages/genui/test/model/a2ui_message_test.dart deleted file mode 100644 index 20022d989..000000000 --- a/packages/genui/test/model/a2ui_message_test.dart +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:convert'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/src/catalog/basic_catalog.dart'; -import 'package:genui/src/model/a2ui_message.dart'; -import 'package:genui/src/model/catalog.dart'; -import 'package:genui/src/model/data_model.dart'; -import 'package:genui/src/model/ui_models.dart'; -import 'package:genui/src/primitives/simple_items.dart'; -import 'package:json_schema_builder/src/schema/schema.dart'; - -void main() { - group('A2uiMessage', () { - // ... existing tests ... - test('CreateSurface.fromJson parses correctly', () { - final Map json = { - 'version': 'v0.9', - 'createSurface': { - surfaceIdKey: 's1', - 'catalogId': 'catalog1', - 'theme': {'color': 'blue'}, - 'sendDataModel': true, - }, - }; - - final message = A2uiMessage.fromJson(json); - expect(message, isA()); - final create = message as CreateSurface; - expect(create.surfaceId, 's1'); - expect(create.catalogId, 'catalog1'); - expect(create.theme, {'color': 'blue'}); - expect(create.sendDataModel, isTrue); - }); - - test('UpdateComponents.fromJson parses correctly', () { - final Map json = { - 'version': 'v0.9', - 'updateComponents': { - surfaceIdKey: 's1', - 'components': [ - {'id': 'c1', 'component': 'Text', 'text': 'Hello'}, - ], - }, - }; - - final message = A2uiMessage.fromJson(json); - expect(message, isA()); - final update = message as UpdateComponents; - expect(update.surfaceId, 's1'); - expect(update.components.length, 1); - expect(update.components.first.id, 'c1'); - expect(update.components.first.type, 'Text'); - }); - - test('UpdateDataModel.fromJson parses correctly', () { - final Map json = { - 'version': 'v0.9', - 'updateDataModel': { - surfaceIdKey: 's1', - 'path': '/user/name', - 'value': 'Alice', - }, - }; - - final message = A2uiMessage.fromJson(json); - expect(message, isA()); - final update = message as UpdateDataModel; - expect(update.surfaceId, 's1'); - expect(update.path, DataPath('/user/name')); - expect(update.value, 'Alice'); - }); - - test('DeleteSurface.fromJson parses correctly', () { - final Map json = { - 'version': 'v0.9', - 'deleteSurface': {surfaceIdKey: 's1'}, - }; - - final message = A2uiMessage.fromJson(json); - expect(message, isA()); - final delete = message as DeleteSurface; - expect(delete.surfaceId, 's1'); - }); - - test('CreateSurface.toJson includes version', () { - const message = CreateSurface(surfaceId: 's1', catalogId: 'c1'); - expect(message.toJson(), containsPair('version', 'v0.9')); - }); - - test('UpdateComponents.toJson includes version', () { - const message = UpdateComponents(surfaceId: 's1', components: []); - expect(message.toJson(), containsPair('version', 'v0.9')); - }); - - test('UpdateDataModel.toJson includes version', () { - const message = UpdateDataModel(surfaceId: 's1'); - expect(message.toJson(), containsPair('version', 'v0.9')); - }); - - test('DeleteSurface.toJson includes version', () { - const message = DeleteSurface(surfaceId: 's1'); - expect(message.toJson(), containsPair('version', 'v0.9')); - }); - - test('fromJson throws on unknown message type', () { - final json = {'version': 'v0.9', 'unknown': {}}; - expect( - () => A2uiMessage.fromJson(json), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('Unknown A2UI message type'), - ), - ), - ); - }); - - test('fromJson throws on missing or invalid version', () { - final json = { - 'createSurface': {surfaceIdKey: 's1', 'catalogId': 'c1'}, - }; - expect( - () => A2uiMessage.fromJson(json), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('A2UI message must have version "v0.9"'), - ), - ), - ); - }); - - test('a2uiMessageSchema requires version field', () { - final Catalog catalog = BasicCatalogItems.asCatalog(); - final Schema schema = A2uiMessage.a2uiMessageSchema(catalog); - final json = jsonDecode(schema.toJson()) as Map; - - // Structure is combined -> oneOf -> [object, ...] - expect(json['oneOf'], isA>()); - final oneOf = json['oneOf'] as List; - expect(oneOf, isNotEmpty); - - // Every variant must require 'version' and enforce 'v0.9' - for (final variant in oneOf) { - final variantMap = variant as Map; - final required = variantMap['required'] as List; - expect(required, contains('version')); - - final properties = variantMap['properties'] as Map; - expect(properties, contains('version')); - final versionSchema = properties['version'] as Map; - expect(versionSchema, containsPair('const', 'v0.9')); - } - }); - }); -} diff --git a/packages/genui/test/model/data_model_edge_cases_test.dart b/packages/genui/test/model/data_model_edge_cases_test.dart index 8e6ca2acb..779476907 100644 --- a/packages/genui/test/model/data_model_edge_cases_test.dart +++ b/packages/genui/test/model/data_model_edge_cases_test.dart @@ -84,18 +84,22 @@ void main() { expect(dataModel.getValue>(DataPath('/list')), ['a', 'b']); }); - test('List Boundaries: Out of bounds (index > length) is ignored', () { + test('List Boundaries: sparse writes fill skipped entries with null', () { dataModel.update(DataPath('/list/0'), 'a'); - // Try to write to index 2 (skipping 1) + // Write to index 2 (skipping 1). a2ui_core auto-vivifies sparse + // list entries with null rather than silently ignoring the write. dataModel.update(DataPath('/list/2'), 'c'); - expect(dataModel.getValue>(DataPath('/list')), ['a']); - // Verify length is still 1 + expect(dataModel.getValue>(DataPath('/list')), [ + 'a', + null, + 'c', + ]); final List? list = dataModel.getValue>( DataPath('/list'), ); - expect(list?.length, 1); + expect(list?.length, 3); }); test('Null Handling: Setting map key to null removes it', () { diff --git a/packages/genui/test/model/ui_models_test.dart b/packages/genui/test/model/ui_models_test.dart index d7ae0642f..3d3a963f4 100644 --- a/packages/genui/test/model/ui_models_test.dart +++ b/packages/genui/test/model/ui_models_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:genui/src/model/ui_models.dart'; +import 'package:genui/src/primitives/a2ui_validation_exception.dart'; import 'package:genui/src/primitives/simple_items.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; diff --git a/packages/genui/test/test_infra/message_builders.dart b/packages/genui/test/test_infra/message_builders.dart new file mode 100644 index 000000000..f864edaf5 --- /dev/null +++ b/packages/genui/test/test_infra/message_builders.dart @@ -0,0 +1,60 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Test-only builders for `a2ui_core` messages and component wire JSON. +// +// `package:genui` consumes the core message types directly; these helpers keep +// test setup terse and mirror the named-argument shape the tests already use. + +import 'package:a2ui_core/a2ui_core.dart' as core; +import 'package:genui/src/model/data_path.dart'; +import 'package:genui/src/primitives/simple_items.dart'; + +/// Builds a component's wire JSON: `{'id': ..., 'component': ..., ...props}`. +JsonMap component({ + required String id, + required String type, + JsonMap properties = const {}, +}) => {'id': id, 'component': type, ...properties}; + +core.CreateSurfaceMessage createSurface({ + String version = 'v0.9', + required String surfaceId, + required String catalogId, + JsonMap? theme, + bool sendDataModel = false, +}) => core.CreateSurfaceMessage( + version: version, + surfaceId: surfaceId, + catalogId: catalogId, + theme: theme, + sendDataModel: sendDataModel, +); + +core.UpdateComponentsMessage updateComponents({ + String version = 'v0.9', + required String surfaceId, + required List components, +}) => core.UpdateComponentsMessage( + version: version, + surfaceId: surfaceId, + components: components, +); + +core.UpdateDataModelMessage updateDataModel({ + String version = 'v0.9', + required String surfaceId, + DataPath path = DataPath.root, + Object? value, +}) => core.UpdateDataModelMessage( + version: version, + surfaceId: surfaceId, + path: path.toString(), + value: value, +); + +core.DeleteSurfaceMessage deleteSurface({ + String version = 'v0.9', + required String surfaceId, +}) => core.DeleteSurfaceMessage(version: version, surfaceId: surfaceId); diff --git a/packages/genui/test/test_infra/validation_test_utils.dart b/packages/genui/test/test_infra/validation_test_utils.dart index 415f75d48..3d8a4031b 100644 --- a/packages/genui/test/test_infra/validation_test_utils.dart +++ b/packages/genui/test/test_infra/validation_test_utils.dart @@ -4,8 +4,8 @@ import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/src/model/a2ui_message.dart'; import 'package:genui/src/model/a2ui_schemas.dart'; import 'package:genui/src/model/catalog.dart'; import 'package:genui/src/model/catalog_item.dart'; @@ -13,6 +13,8 @@ import 'package:genui/src/model/ui_models.dart'; import 'package:genui/src/primitives/simple_items.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import 'message_builders.dart'; + /// Validates the examples in the catalog items in the catalog. void validateCatalogExamples( Catalog catalog, [ @@ -48,9 +50,9 @@ void validateCatalogExamples( reason: 'Example must have a component with id "root"', ); - final surfaceUpdate = UpdateComponents( + final core.UpdateComponentsMessage surfaceUpdate = updateComponents( surfaceId: 'test-surface', - components: components, + components: components.map((c) => c.toJson()).toList(), ); final List validationErrors = await schema.validate( diff --git a/packages/genui/test/transport/a2ui_parser_transformer_test.dart b/packages/genui/test/transport/a2ui_parser_transformer_test.dart index e614a7503..f419dcfda 100644 --- a/packages/genui/test/transport/a2ui_parser_transformer_test.dart +++ b/packages/genui/test/transport/a2ui_parser_transformer_test.dart @@ -4,10 +4,10 @@ import 'dart:async'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:async/async.dart'; -import 'package:genui/src/model/a2ui_message.dart'; import 'package:genui/src/model/generation_events.dart'; -import 'package:genui/src/model/ui_models.dart'; +import 'package:genui/src/primitives/a2ui_validation_exception.dart'; import 'package:genui/src/transport/a2ui_parser_transformer.dart'; import 'package:test/test.dart'; @@ -63,8 +63,8 @@ void main() { ); final msgEvent = (await queue.next) as A2uiMessageEvent; - expect(msgEvent.message, isA()); - expect((msgEvent.message as CreateSurface).surfaceId, 'foo'); + expect(msgEvent.message, isA()); + expect((msgEvent.message as core.CreateSurfaceMessage).surfaceId, 'foo'); // The text after might be just the newline or "End of message." // We accept whatever text comes next, potentially fragmented. @@ -100,8 +100,8 @@ void main() { ); final msgEvent = (await queue.next) as A2uiMessageEvent; - expect(msgEvent.message, isA()); - expect((msgEvent.message as DeleteSurface).surfaceId, 'bar'); + expect(msgEvent.message, isA()); + expect((msgEvent.message as core.DeleteSurfaceMessage).surfaceId, 'bar'); expect( (await queue.next) as TextEvent, @@ -125,8 +125,11 @@ void main() { ); final msgEvent = (await queue.next) as A2uiMessageEvent; - expect(msgEvent.message, isA()); - expect((msgEvent.message as DeleteSurface).surfaceId, '[{]bar[}]'); + expect(msgEvent.message, isA()); + expect( + (msgEvent.message as core.DeleteSurfaceMessage).surfaceId, + '[{]bar[}]', + ); expect( (await queue.next) as TextEvent, @@ -156,7 +159,7 @@ void main() { test('emits error for invalid A2UI message', () async { final StreamQueue queue = StreamQueue(stream); - // Malformed CreateSurface (missing required fields) + // Malformed core.CreateSurfaceMessage (missing required fields) controller.add('{"version": "v0.9", "createSurface": {}}'); // Should emit error @@ -254,12 +257,18 @@ void main() { ); final firstEvent = (await queue.next) as A2uiMessageEvent; - expect(firstEvent.message, isA()); - expect((firstEvent.message as CreateSurface).surfaceId, 'foo'); + expect(firstEvent.message, isA()); + expect( + (firstEvent.message as core.CreateSurfaceMessage).surfaceId, + 'foo', + ); final secondEvent = (await queue.next) as A2uiMessageEvent; - expect(secondEvent.message, isA()); - expect((secondEvent.message as CreateSurface).surfaceId, 'bar'); + expect(secondEvent.message, isA()); + expect( + (secondEvent.message as core.CreateSurfaceMessage).surfaceId, + 'bar', + ); await queue.cancel(); }); diff --git a/packages/genui/test/transport/a2ui_transport_adapter_test.dart b/packages/genui/test/transport/a2ui_transport_adapter_test.dart index 7c58a13ff..3c6b92f08 100644 --- a/packages/genui/test/transport/a2ui_transport_adapter_test.dart +++ b/packages/genui/test/transport/a2ui_transport_adapter_test.dart @@ -4,8 +4,7 @@ import 'dart:async'; -import 'package:genui/src/model/a2ui_message.dart'; - +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:genui/src/transport/a2ui_transport_adapter.dart'; import 'package:test/test.dart'; @@ -39,7 +38,11 @@ void main() { final Future stateFuture = expectLater( transportAdapter.incomingMessages, emits( - isA().having((e) => e.surfaceId, 'id', 'test_chunk'), + isA().having( + (e) => e.surfaceId, + 'id', + 'test_chunk', + ), ), ); @@ -48,7 +51,7 @@ void main() { }); test('addMessage updates state directly', () async { - final msg = const CreateSurface( + final msg = core.CreateSurfaceMessage( surfaceId: 'direct_msg', catalogId: 'direct-cat', ); @@ -56,7 +59,11 @@ void main() { final Future stateFuture = expectLater( transportAdapter.incomingMessages, emits( - isA().having((e) => e.surfaceId, 'id', 'direct_msg'), + isA().having( + (e) => e.surfaceId, + 'id', + 'direct_msg', + ), ), ); @@ -70,10 +77,10 @@ void main() { final Future expectation = expectLater( adapter.incomingMessages, emits( - predicate((m) { - return m is UpdateComponents && + predicate((m) { + return m is core.UpdateComponentsMessage && m.components.length == 1 && - m.components.first.id == 'root'; + m.components.first['id'] == 'root'; }), ), ); diff --git a/packages/genui/test/widgets/widget_utilities_test.dart b/packages/genui/test/widgets/widget_utilities_test.dart new file mode 100644 index 000000000..e0879e1a5 --- /dev/null +++ b/packages/genui/test/widgets/widget_utilities_test.dart @@ -0,0 +1,72 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; + +void main() { + group('BoundObject', () { + testWidgets('rebuilds on in-place map mutation at a parent path', ( + tester, + ) async { + final dataModel = InMemoryDataModel(); + dataModel.update(DataPath('/map'), {'count': 1}); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: BoundObject( + dataContext: DataContext(dataModel, DataPath.root), + value: const {'path': '/map'}, + builder: (context, value) { + if (value is Map) { + return Text('count=${value['count']}'); + } + return const Text('no map'); + }, + ), + ), + ); + expect(find.text('count=1'), findsOneWidget); + + // Mutate a nested key. a2ui_core's DataModel updates the map in place + // and bubble-notifies the parent path /map. BoundObject must rebuild + // even though the resolved value at /map is the same Map instance. + dataModel.update(DataPath('/map/count'), 2); + await tester.pump(); + + expect(find.text('count=2'), findsOneWidget); + }); + }); + + group('BoundList', () { + testWidgets('rebuilds on in-place list mutation at the bound path', ( + tester, + ) async { + final dataModel = InMemoryDataModel(); + dataModel.update(DataPath('/items'), ['a', 'b']); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: BoundList( + dataContext: DataContext(dataModel, DataPath.root), + value: const {'path': '/items'}, + builder: (context, value) { + if (value == null) return const Text('null'); + return Text('count=${value.length}'); + }, + ), + ), + ); + expect(find.text('count=2'), findsOneWidget); + + dataModel.update(DataPath('/items/2'), 'c'); + await tester.pump(); + + expect(find.text('count=3'), findsOneWidget); + }); + }); +} diff --git a/packages/genui_a2a/CHANGELOG.md b/packages/genui_a2a/CHANGELOG.md index bfedd9f4c..8198638f0 100644 --- a/packages/genui_a2a/CHANGELOG.md +++ b/packages/genui_a2a/CHANGELOG.md @@ -1,5 +1,10 @@ # `genui_a2a` Changelog +## 0.10.0 (in progress) + +- **BREAKING**: `A2uiAgentConnector.stream` now emits `package:a2ui_core` + message types. Depend on `a2ui_core` directly to consume them. + ## 0.9.0 - **BREAKING**: `A2uiAgentConnector` constructor now requires exactly one of `url` or `client` (#886). diff --git a/packages/genui_a2a/lib/src/a2ui_agent_connector.dart b/packages/genui_a2a/lib/src/a2ui_agent_connector.dart index f5942d5e1..72f38f442 100644 --- a/packages/genui_a2a/lib/src/a2ui_agent_connector.dart +++ b/packages/genui_a2a/lib/src/a2ui_agent_connector.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; import 'package:genui/genui.dart' as genui; import 'package:logging/logging.dart'; @@ -52,7 +53,7 @@ class A2uiAgentConnector { ); } - final _controller = StreamController.broadcast(); + final _controller = StreamController.broadcast(); final _textController = StreamController.broadcast(); final _errorController = StreamController.broadcast(); @visibleForTesting @@ -68,7 +69,7 @@ class A2uiAgentConnector { String? get contextId => _contextId; /// The stream of A2UI messages. - Stream get stream => _controller.stream; + Stream get stream => _controller.stream; /// The stream of text responses. Stream get textStream => _textController.stream; @@ -338,7 +339,7 @@ class A2uiAgentConnector { data.containsKey('deleteSurface')) { if (!_controller.isClosed) { _log.finest('Adding message to stream: $prettyJson'); - _controller.add(genui.A2uiMessage.fromJson(data)); + _controller.add(core.A2uiMessage.fromJson(data)); } } else { _log.warning('A2A data part did not contain any known A2UI messages.'); diff --git a/packages/genui_a2a/pubspec.yaml b/packages/genui_a2a/pubspec.yaml index 6bd8838be..259dfd9f3 100644 --- a/packages/genui_a2a/pubspec.yaml +++ b/packages/genui_a2a/pubspec.yaml @@ -16,6 +16,7 @@ environment: resolution: workspace dependencies: + a2ui_core: ^0.0.1-wip002 collection: ^1.19.1 flutter: sdk: flutter diff --git a/packages/genui_a2a/test/a2ui_agent_connector_test.dart b/packages/genui_a2a/test/a2ui_agent_connector_test.dart index 8bc41420e..c739b8c8f 100644 --- a/packages/genui_a2a/test/a2ui_agent_connector_test.dart +++ b/packages/genui_a2a/test/a2ui_agent_connector_test.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart' as genui; import 'package:genui_a2a/genui_a2a.dart'; @@ -103,7 +104,7 @@ void main() { ]; fakeClient.messageStreamHandler = (_) => Stream.fromIterable(responses); - final messages = []; + final messages = []; connector.stream.listen(messages.add); final userMessage = genui.ChatMessage.user( @@ -122,7 +123,7 @@ void main() { expect(connector.contextId, 'context1'); expect(fakeClient.messageStreamCalled, 1); expect(messages.length, 1); - expect(messages.first, isA()); + expect(messages.first, isA()); }); test('connectAndSend sends multiple text parts', () async {