diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 0ce448fd6..55762280c 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.9.2 - **Feature**: Updated example/README.md. +- **Fix**: `SurfaceDefinition.validate` now strictly enforces primitive data types, correctly identifying and rejecting malformed components (like numbers passed for strings) before they can cause fatal rendering crashes. ## 0.9.0 diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 3915740c3..05a9bb733 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -237,6 +237,36 @@ class SurfaceDefinition { Map schema, String path, ) { + if (schema case {'type': Object expectedType}) { + final List types = expectedType is List + ? expectedType.map((e) => e.toString()).toList() + : [expectedType.toString()]; + + final bool isValid = types.any( + (t) => switch (t) { + 'string' => instance is String, + 'number' => instance is num, + // A whole-valued double such as 42.0 is a valid integer. + 'integer' => + instance is num && (instance is int || instance.remainder(1) == 0), + 'boolean' => instance is bool, + 'object' => instance is Map, + 'array' => instance is List, + 'null' => instance == null, + _ => false, + }, + ); + + if (!isValid) { + final Object actualType = instance?.runtimeType ?? 'null'; + throw A2uiValidationException( + 'Type mismatch. Expected $types, got $actualType', + surfaceId: surfaceId, + path: path, + ); + } + } + if (instance == null) { return; } diff --git a/packages/genui/test/model/ui_models_test.dart b/packages/genui/test/model/ui_models_test.dart index d7ae0642f..8c0e7383c 100644 --- a/packages/genui/test/model/ui_models_test.dart +++ b/packages/genui/test/model/ui_models_test.dart @@ -75,7 +75,6 @@ void main() { components: {'test': component}, ); - // Schema invalidating the component (e.g., expecting type "Button") final schema = S.object( properties: { 'components': S.list( @@ -116,7 +115,214 @@ void main() { }, ); - surfaceDefinition.validate(schema); // Should not throw + surfaceDefinition.validate(schema); // Should not throw. + }); + + test('validate enforces primitive types: string', () { + final component = const Component( + id: 'test', + type: 'Text', + // int instead of string. + properties: {'text': 42}, + ); + final surfaceDefinition = SurfaceDefinition( + surfaceId: 's1', + components: {'test': component}, + ); + + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'Text'), + 'text': S.string(), + }, + ), + ), + }, + ); + + expect( + () => surfaceDefinition.validate(schema), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Type mismatch'), + ), + ), + ); + }); + + test('validate enforces primitive types: boolean', () { + final component = const Component( + id: 'test', + type: 'Checkbox', + properties: {'checked': 'true'}, // string instead of bool. + ); + final surfaceDefinition = SurfaceDefinition( + surfaceId: 's1', + components: {'test': component}, + ); + + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'Checkbox'), + 'checked': S.boolean(), + }, + ), + ), + }, + ); + + expect( + () => surfaceDefinition.validate(schema), + throwsA(isA()), + ); + }); + + test('validate enforces primitive types: ' + 'number vs integer', () { + final componentInt = const Component( + id: 'test1', + type: 'Slider', + properties: {'value': 42}, // Valid integer. + ); + final componentDouble = const Component( + id: 'test2', + type: 'Slider', + properties: {'value': 42.5}, // Valid number, invalid integer. + ); + // JSON decodes whole numbers like 42.0 to a Dart double; per JSON + // Schema a double with a zero fractional part is still an integer. + final componentWholeDouble = const Component( + id: 'test3', + type: 'Slider', + properties: {'value': 42.0}, + ); + + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'Slider'), + 'value': S.integer(), + }, + ), + ), + }, + ); + + SurfaceDefinition( + surfaceId: 's1', + components: {'test1': componentInt}, + ).validate(schema); + + SurfaceDefinition( + surfaceId: 's3', + components: {'test3': componentWholeDouble}, + ).validate(schema); + + expect( + () => SurfaceDefinition( + surfaceId: 's2', + components: {'test2': componentDouble}, + ).validate(schema), + throwsA(isA()), + ); + }); + + test('validate accepts both int and double for number type', () { + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'Slider'), + 'value': S.number(), + }, + ), + ), + }, + ); + + for (final value in const [42, 42.5]) { + SurfaceDefinition( + surfaceId: 's1', + components: { + 'test': Component( + id: 'test', + type: 'Slider', + properties: {'value': value}, + ), + }, + ).validate(schema); + } + }); + + test('validate rejects explicit null for a non-nullable type', () { + final component = const Component( + id: 'test', + type: 'Text', + properties: {'text': null}, // null where a string is required. + ); + final surfaceDefinition = SurfaceDefinition( + surfaceId: 's1', + components: {'test': component}, + ); + + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'Text'), + 'text': S.string(), + }, + ), + ), + }, + ); + + expect( + () => surfaceDefinition.validate(schema), + throwsA(isA()), + ); + }); + + test('validate enforces primitive types: array and object', () { + final component = const Component( + id: 'test', + type: 'List', + properties: {'items': 42}, // int instead of array. + ); + final surfaceDefinition = SurfaceDefinition( + surfaceId: 's1', + components: {'test': component}, + ); + + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'List'), + 'items': S.list(items: S.string()), + }, + ), + ), + }, + ); + + expect( + () => surfaceDefinition.validate(schema), + throwsA(isA()), + ); }); });