diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index c540b3c1..90338520 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -30,6 +30,9 @@ Entry point for creating schemas. See [Schema Types](../core-concepts/schemas.md - `Ack.anyOf(List schemas)`: Creates an `AnyOfSchema` for union types. - `Ack.any()`: Creates an `AnySchema` that accepts any non-null JSON-safe value. Chain `.nullable()` to allow `null`. +- `Ack.lazy(String name, AckSchema Function() builder)`: Creates a memoized + deferred schema reference for recursive schema graphs. JSON Schema export + renders Draft-7 `definitions` / `$ref` entries using `name`. - `Ack.discriminated(...)`: Creates a discriminated union schema. Branches may be plain `ObjectSchema` or transformed schemas whose base is an `ObjectSchema`. The union owns the discriminator: branches normally @@ -224,6 +227,17 @@ Schema for polymorphic validation based on a string discriminator property. - Generated subtype `parse()` and `safeParse()` methods validate through the union's effective branch. +### `Ack.lazy(...)` + +Schema reference for recursive object graphs. + +- Created using `Ack.lazy(name, builder)`. +- The builder is resolved once and memoized. +- `toJsonSchema()` and `toSchemaModel()` export Draft-7 `definitions` / `$ref` + entries using the lazy `name`. +- Bare or wrapped lazy schemas cannot be used as discriminated-union branches + because the branch discriminator must be analyzable at construction time. + ## Code Generation Annotations Use the [`ack_generator`](https://pub.dev/packages/ack_generator) builder to diff --git a/docs/core-concepts/schemas.mdx b/docs/core-concepts/schemas.mdx index 6652739e..88871a8d 100644 --- a/docs/core-concepts/schemas.mdx +++ b/docs/core-concepts/schemas.mdx @@ -184,6 +184,25 @@ final shapeSchema = Ack.discriminated( The union owns the discriminator and injects the exact branch literal at parse/export boundaries. Branch schemas usually omit the discriminator field. +### Recursive Schemas + +Use `Ack.lazy(...)` when a schema needs to refer to itself: + +```dart +late final ObjectSchema categorySchema; + +categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), +}); +``` + +The lazy builder is resolved once and memoized. JSON Schema export renders the +reference through Draft-7 `definitions` / `$ref`, so recursive children are +referenced instead of inlined forever. + ### Any Accept any non-null JSON-safe value without validation (use sparingly): diff --git a/docs/guides/json-schema-integration.mdx b/docs/guides/json-schema-integration.mdx index 6aeab456..d7297586 100644 --- a/docs/guides/json-schema-integration.mdx +++ b/docs/guides/json-schema-integration.mdx @@ -172,6 +172,7 @@ Ack attempts to map its [built-in constraints](../core-concepts/validation.mdx) | [`Ack.boolean()`](../core-concepts/schemas.mdx#boolean) | `type: boolean` | Type | | [`Ack.list(...)`](../core-concepts/schemas.mdx#list) | `type: array`, `items: {...}` | Type | | [`Ack.object(...)`](../core-concepts/schemas.mdx#object) | `type: object`, `properties: {...}`, `required: [...]` | Type | +| [`Ack.lazy(...)`](../core-concepts/schemas.mdx#recursive-schemas) | `definitions`, `$ref` | Recursive type | ## Shape Stability Notes @@ -186,6 +187,11 @@ nullability rules: branches. Each branch contains the exact required discriminator `const`. - `Ack.discriminated(...).nullable()` wraps that `anyOf` union with a second `{ "type": "null" }` branch. +- `Ack.lazy(name, ...)` is emitted as a Draft-7 `definitions` entry and local + `$ref` values such as `{ "$ref": "#/definitions/Category" }`. +- Non-null lazy refs that carry metadata such as `description` use `allOf` + around the `$ref` so Draft-7 validators do not ignore that metadata as a + `$ref` sibling. Nullable lazy refs keep metadata beside the top-level `anyOf`. This means nullable enums are represented as: @@ -229,6 +235,10 @@ shape should implement that as explicit adapter rendering. - **`Ack.any()`:** Runtime validation accepts non-null JSON-safe values. JSON-like adapter exports represent those JSON-compatible values and attach an `ack_any_json_boundary` warning to the `AckSchemaModel`. +- **`Ack.lazy()` runtime checks:** Recursive structure is exported with + Draft-7 `definitions` / `$ref`. Constraints and refinements added directly + to the lazy reference are runtime-only and are reported as schema-model + warnings rather than emitted beside `$ref`. - **Date/time range constraints:** Draft-7 has no standard `formatMinimum` or `formatMaximum` keywords. ACK validates `.min()` and `.max()` at runtime and records schema-model warnings instead of rendering non-standard keywords. diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 561983bd..27e4a801 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -67,10 +67,9 @@ final class Ack { /// (e.g. adding constraints, copying with new flags). Prefer [enumCodec] /// when downstream code expects every value-shape to be a `CodecSchema`. static CodecSchema enumCodec(List values) => - enumValues(values).codec( - decode: (value) => value, - encode: (value) => value, - ); + enumValues( + values, + ).codec(decode: (value) => value, encode: (value) => value); /// Creates a string schema that only accepts one of the given [values]. static StringSchema enumString(List values) => @@ -86,6 +85,22 @@ final class Ack { /// to accept `null`. static AnySchema any() => const AnySchema(); + /// Creates a schema reference that is resolved lazily on first use. + /// + /// The [builder] is called once and memoized. Two `Ack.lazy` instances are + /// equal only when their `builder` closure is the same reference -- pulling + /// the closure into a `final` variable lets two calls share equality. + /// + /// `toJsonSchema()` and `toSchemaModel()` export lazy references through + /// recursive `definitions`/`$ref` JSON Schema definitions. Bare or wrapped + /// `Ack.lazy` schemas cannot be used as discriminated union branches. + static LazySchema lazy< + Boundary extends Object, + Runtime extends Object + >(String name, AckSchema Function() builder) { + return LazySchema(name, builder); + } + /// Creates a schema for a specific Dart instance type [T], with [T] as /// both boundary and runtime type. static InstanceSchema instance() => InstanceSchema(); diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 6aecfccd..25893fee 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -83,7 +83,13 @@ final class _AckSchemaModelCommon { /// Returns the non-hoistable portion (extensions plus type-specific /// keywords flow through here) to embed inside the inner branch. - Map toEmbeddedJson() => {...extensions}; + Map toEmbeddedJson() { + final embedded = {...extensions}; + embedded.remove('definitions'); + return embedded; + } + + Object? get definitions => extensions['definitions']; } @immutable @@ -239,6 +245,10 @@ sealed class AckSchemaModel { keywords, commonHandled, ), + AckRefSchemaModel schema => schema._withJsonSchemaKeywords( + keywords, + commonHandled, + ), }; } @@ -253,6 +263,7 @@ sealed class AckSchemaModel { // branch so consumers see them next to the `type` they constrain. return { ..._common.toHoistedJson(), + if (_common.definitions != null) 'definitions': _common.definitions, 'anyOf': [ {...typeJson, ..._common.toEmbeddedJson()}, _nullSchemaJson, @@ -272,6 +283,7 @@ sealed class AckSchemaModel { // composed union. return { ..._common.toHoistedJson(), + if (_common.definitions != null) 'definitions': _common.definitions, 'anyOf': [ {..._common.toEmbeddedJson(), keyword: branches}, _nullSchemaJson, @@ -316,6 +328,48 @@ sealed class AckSchemaModel { } } +final class AckRefSchemaModel extends AckSchemaModel { + const AckRefSchemaModel({ + required this.refName, + super.title, + super.description, + super.nullable, + super.defaultValue, + super.warnings, + super.extensions, + }); + + AckRefSchemaModel._(_AckSchemaModelCommon common, {required this.refName}) + : super._(common); + + final String refName; + + @override + Map toJsonSchema() { + final refJson = {r'$ref': '#/definitions/${_jsonPointerToken(refName)}'}; + if (nullable) return finishTypeJson(refJson); + + final commonJson = _common.toJson(); + if (commonJson.isEmpty) return refJson; + + return { + ...commonJson, + 'allOf': [refJson], + }; + } + + @override + AckRefSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckRefSchemaModel._(common, refName: refName); + + AckSchemaModel _withJsonSchemaKeywords( + Map keywords, + Set commonHandled, + ) { + return _withUnhandledKeywords(keywords, commonHandled); + } +} + final class AckStringSchemaModel extends AckSchemaModel { const AckStringSchemaModel({ this.format, @@ -1115,3 +1169,7 @@ num? _readNumKeyword(Map keywords, String key) { final value = keywords[key]; return value is num ? value : null; } + +String _jsonPointerToken(String value) { + return value.replaceAll('~', '~0').replaceAll('/', '~1'); +} diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index 727882e7..1f635fda 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:collection/collection.dart'; + import '../constraints/constraint.dart'; import '../constraints/datetime_constraint.dart'; import '../context.dart'; @@ -13,233 +15,340 @@ extension AckSchemaModelExtension< Runtime extends Object > on AckSchema { - AckSchemaModel toSchemaModel() => _build(this); + AckSchemaModel toSchemaModel() => _SchemaModelBuilder().build(this); } -AckSchemaModel _build(AckSchema schema) { - if (schema is WrapperSchema) { - final base = _build(schema.inner); - // Defaults wrap their inner without transforming the boundary value, so - // they should not advertise themselves as a transformed schema. - final extensions = schema is DefaultSchema - ? base.extensions - : {...base.extensions, 'x-transformed': true}; - final layered = base - .withDescription(schema.description ?? base.description) - .withNullable(schema.isNullable || base.nullable) - .withExtensions(extensions); - // `DefaultSchema.constraints` is a passthrough to `inner.constraints`, - // which `_build(schema.inner)` already applied. Re-running them here - // would emit duplicate warnings (e.g. datetime range under a default). - var wrapped = schema is DefaultSchema - ? layered - : _applyConstraints(layered, schema); - - if (schema is DefaultSchema) { - final exportDefault = _defaultExportValueOrNull(schema); - if (exportDefault != null) { - wrapped = wrapped.withDefaultValue(exportDefault); - } else { - wrapped = wrapped.withWarnings([ - ...wrapped.warnings, - AckSchemaModelWarning( - code: 'default_not_export_safe', - message: - 'Schema default was omitted because it cannot be represented safely in exported JSON-compatible schema models.', - ), - ]); +final class _SchemaModelBuilder { + final _definitions = {}; + final _targets = {}; + + AckSchemaModel build(AckSchema schema) { + final root = _build(schema); + if (_definitions.isEmpty) return root; + + return root.withExtensions({ + ...root.extensions, + 'definitions': _mergeRootDefinitions(root.extensions['definitions']), + }); + } + + Map _mergeRootDefinitions(Object? existingDefinitions) { + final lazyDefinitions = { + for (final entry in _definitions.entries) + if (entry.value case final model?) entry.key: model.toJsonSchema(), + }; + if (existingDefinitions == null) return lazyDefinitions; + if (existingDefinitions is! Map) { + throw ArgumentError( + 'Root JSON Schema definitions must be a map when Ack.lazy definitions ' + 'are exported.', + ); + } + + final merged = {}; + for (final entry in existingDefinitions.entries) { + final key = entry.key; + if (key is! String) { + throw ArgumentError( + 'Root JSON Schema definitions keys must be strings when Ack.lazy ' + 'definitions are exported.', + ); + } + merged[key] = entry.value; + } + + const equality = DeepCollectionEquality(); + for (final entry in lazyDefinitions.entries) { + if (merged.containsKey(entry.key)) { + if (!equality.equals(merged[entry.key], entry.value)) { + throw ArgumentError( + 'Ack.lazy definition "${entry.key}" collides with an existing root ' + 'JSON Schema definition. Use a unique lazy name or rename the ' + 'existing definition.', + ); + } + continue; + } + merged[entry.key] = entry.value; + } + return merged; + } + + AckSchemaModel _build(AckSchema schema) { + if (schema is WrapperSchema) { + final base = _build(schema.inner); + // Defaults wrap their inner without transforming the boundary value, so + // they should not advertise themselves as a transformed schema. + final extensions = schema is DefaultSchema + ? base.extensions + : {...base.extensions, 'x-transformed': true}; + final layered = base + .withDescription(schema.description ?? base.description) + .withNullable(schema.isNullable || base.nullable) + .withExtensions(extensions); + // `DefaultSchema.constraints` is a passthrough to `inner.constraints`, + // which `_build(schema.inner)` already applied. Re-running them here + // would emit duplicate warnings (e.g. datetime range under a default). + var wrapped = schema is DefaultSchema + ? layered + : _applyConstraints(layered, schema); + + if (schema is DefaultSchema) { + final exportDefault = _defaultExportValueOrNull(schema); + if (exportDefault != null) { + wrapped = wrapped.withDefaultValue(exportDefault); + } else { + wrapped = wrapped.withWarnings([ + ...wrapped.warnings, + AckSchemaModelWarning( + code: 'default_not_export_safe', + message: + 'Schema default was omitted because it cannot be represented safely in exported JSON-compatible schema models.', + ), + ]); + } } + + return wrapped; } - return wrapped; + final model = switch (schema) { + StringSchema() => _string(schema), + IntegerSchema() => _integer(schema), + DoubleSchema() => _number( + description: schema.description, + nullable: schema.isNullable, + ), + NumberSchema() => _number( + description: schema.description, + nullable: schema.isNullable, + ), + BooleanSchema() => _boolean(schema), + EnumSchema() => _enum(schema), + ListSchema() => _array(schema), + ObjectSchema() => _object(schema), + AnyOfSchema() => _anyOf(schema), + AnySchema() => _any(schema), + InstanceSchema() => _instance(schema), + DiscriminatedObjectSchema() => _discriminated(schema), + LazySchema() => _lazy(schema), + _ => throw UnsupportedError( + 'Schema type ${schema.runtimeType} is not supported for AckSchemaModel conversion.', + ), + }; + + return schema is LazySchema ? model : _applyConstraints(model, schema); } - final model = switch (schema) { - StringSchema() => _string(schema), - IntegerSchema() => _integer(schema), - DoubleSchema() => _number( + AckSchemaModel _string(StringSchema schema) { + return AckStringSchemaModel( description: schema.description, nullable: schema.isNullable, - ), - NumberSchema() => _number( + ); + } + + AckSchemaModel _integer(IntegerSchema schema) { + return AckIntegerSchemaModel( description: schema.description, nullable: schema.isNullable, - ), - BooleanSchema() => _boolean(schema), - EnumSchema() => _enum(schema), - ListSchema() => _array(schema), - ObjectSchema() => _object(schema), - AnyOfSchema() => _anyOf(schema), - AnySchema() => _any(schema), - InstanceSchema() => _instance(schema), - DiscriminatedObjectSchema() => _discriminated(schema), - _ => throw UnsupportedError( - 'Schema type ${schema.runtimeType} is not supported for AckSchemaModel conversion.', - ), - }; + ); + } - return _applyConstraints(model, schema); -} + AckSchemaModel _number({String? description, required bool nullable}) { + return AckNumberSchemaModel(description: description, nullable: nullable); + } -AckSchemaModel _string(StringSchema schema) { - return AckStringSchemaModel( - description: schema.description, - nullable: schema.isNullable, - ); -} + AckSchemaModel _boolean(BooleanSchema schema) { + return AckBooleanSchemaModel( + description: schema.description, + nullable: schema.isNullable, + ); + } -AckSchemaModel _integer(IntegerSchema schema) { - return AckIntegerSchemaModel( - description: schema.description, - nullable: schema.isNullable, - ); -} + AckSchemaModel _enum(EnumSchema schema) { + return AckStringSchemaModel( + description: schema.description, + enumValues: [for (final value in schema.values) value.name], + nullable: schema.isNullable, + ); + } -AckSchemaModel _number({String? description, required bool nullable}) { - return AckNumberSchemaModel(description: description, nullable: nullable); -} + AckSchemaModel _array(ListSchema schema) { + return AckArraySchemaModel( + description: schema.description, + nullable: schema.isNullable, + items: _build(schema.itemSchema), + ); + } -AckSchemaModel _boolean(BooleanSchema schema) { - return AckBooleanSchemaModel( - description: schema.description, - nullable: schema.isNullable, - ); -} + AckSchemaModel _object(ObjectSchema schema) { + final properties = {}; + final required = []; + final ordering = []; -AckSchemaModel _enum(EnumSchema schema) { - return AckStringSchemaModel( - description: schema.description, - enumValues: [for (final value in schema.values) value.name], - nullable: schema.isNullable, - ); -} + for (final entry in schema.properties.entries) { + ordering.add(entry.key); + properties[entry.key] = wrapPropertyConversion( + entry.key, + () => _build(entry.value), + ); + if (_isRequiredObjectProperty(entry.value)) { + required.add(entry.key); + } + } -AckSchemaModel _array(ListSchema schema) { - return AckArraySchemaModel( - description: schema.description, - nullable: schema.isNullable, - items: _build(schema.itemSchema), - ); -} + return AckObjectSchemaModel( + description: schema.description, + nullable: schema.isNullable, + properties: properties.isEmpty ? null : properties, + required: required.isEmpty ? null : required, + propertyOrdering: ordering.isEmpty ? null : ordering, + additionalProperties: schema.additionalProperties + ? const AckAdditionalPropertiesAllowed() + : const AckAdditionalPropertiesDisallowed(), + ); + } -AckSchemaModel _object(ObjectSchema schema) { - final properties = {}; - final required = []; - final ordering = []; + AckSchemaModel _anyOf(AnyOfSchema schema) { + return AckAnyOfSchemaModel( + schemas: schema.schemas.map(_build).toList(growable: false), + nullable: schema.isNullable, + description: schema.description, + ); + } - for (final entry in schema.properties.entries) { - ordering.add(entry.key); - properties[entry.key] = wrapPropertyConversion( - entry.key, - () => _build(entry.value), + AckSchemaModel _instance(InstanceSchema schema) { + // InstanceSchema accepts arbitrary Dart instances of a runtime type with no + // direct JSON representation. Adapters that flow through a codec see the + // boundary schema instead; this is the fallback for a bare instance. + return AckAnyOfSchemaModel( + schemas: [ + AckStringSchemaModel(description: schema.description), + AckNumberSchemaModel(description: schema.description), + AckIntegerSchemaModel(description: schema.description), + AckBooleanSchemaModel(description: schema.description), + AckObjectSchemaModel(description: schema.description), + AckArraySchemaModel(description: schema.description), + ], + nullable: schema.isNullable, + description: schema.description, + warnings: const [ + AckSchemaModelWarning( + code: 'ack_instance_json_boundary', + message: + 'Ack.instance() accepts arbitrary Dart instances at runtime; JSON-like adapters can only represent JSON-compatible values.', + ), + ], ); - if (_isRequiredObjectProperty(entry.value)) { - required.add(entry.key); - } } - return AckObjectSchemaModel( - description: schema.description, - nullable: schema.isNullable, - properties: properties.isEmpty ? null : properties, - required: required.isEmpty ? null : required, - propertyOrdering: ordering.isEmpty ? null : ordering, - additionalProperties: schema.additionalProperties - ? const AckAdditionalPropertiesAllowed() - : const AckAdditionalPropertiesDisallowed(), - ); -} + AckSchemaModel _any(AnySchema schema) { + final description = schema.description; + final primitiveBranches = [ + AckStringSchemaModel(description: description), + AckNumberSchemaModel(description: description), + AckIntegerSchemaModel(description: description), + AckBooleanSchemaModel(description: description), + AckObjectSchemaModel(description: description), + AckArraySchemaModel(description: description), + ]; + + return AckAnyOfSchemaModel( + schemas: primitiveBranches, + nullable: schema.isNullable, + description: description, + warnings: const [ + AckSchemaModelWarning( + code: 'ack_any_json_boundary', + message: + 'Ack.any() accepts non-null JSON-safe values at runtime, matching the JSON-compatible values adapters can represent.', + ), + ], + ); + } -AckSchemaModel _anyOf(AnyOfSchema schema) { - return AckAnyOfSchemaModel( - schemas: schema.schemas.map(_build).toList(growable: false), - nullable: schema.isNullable, - description: schema.description, - ); -} + AckSchemaModel _discriminated(DiscriminatedObjectSchema schema) { + if (schema.schemas.isEmpty) { + return AckObjectSchemaModel( + properties: const {}, + required: const [], + nullable: schema.isNullable, + description: schema.description, + ); + } -AckSchemaModel _instance(InstanceSchema schema) { - // InstanceSchema accepts arbitrary Dart instances of a runtime type with no - // direct JSON representation. Adapters that flow through a codec see the - // boundary schema instead; this is the fallback for a bare instance. - return AckAnyOfSchemaModel( - schemas: [ - AckStringSchemaModel(description: schema.description), - AckNumberSchemaModel(description: schema.description), - AckIntegerSchemaModel(description: schema.description), - AckBooleanSchemaModel(description: schema.description), - AckObjectSchemaModel(description: schema.description), - AckArraySchemaModel(description: schema.description), - ], - nullable: schema.isNullable, - description: schema.description, - warnings: const [ - AckSchemaModelWarning( - code: 'ack_instance_json_boundary', - message: - 'Ack.instance() accepts arbitrary Dart instances at runtime; JSON-like adapters can only represent JSON-compatible values.', - ), - ], - ); -} + final branches = []; + for (final entry in schema.schemas.entries) { + final converted = _build(schema.effectiveBranch(entry.key)); + if (converted is! AckObjectSchemaModel) { + throw ArgumentError( + 'Discriminated branch "${entry.key}" must export as an object schema model.', + ); + } + branches.add(converted); + } -AckSchemaModel _any(AnySchema schema) { - final description = schema.description; - final primitiveBranches = [ - AckStringSchemaModel(description: description), - AckNumberSchemaModel(description: description), - AckIntegerSchemaModel(description: description), - AckBooleanSchemaModel(description: description), - AckObjectSchemaModel(description: description), - AckArraySchemaModel(description: description), - ]; - - return AckAnyOfSchemaModel( - schemas: primitiveBranches, - nullable: schema.isNullable, - description: description, - warnings: const [ - AckSchemaModelWarning( - code: 'ack_any_json_boundary', - message: - 'Ack.any() accepts non-null JSON-safe values at runtime, matching the JSON-compatible values adapters can represent.', + return AckAnyOfSchemaModel( + schemas: branches, + discriminator: AckSchemaDiscriminatorModel( + propertyName: schema.discriminatorKey, ), - ], - ); -} - -AckSchemaModel _discriminated(DiscriminatedObjectSchema schema) { - if (schema.schemas.isEmpty) { - return AckObjectSchemaModel( - properties: const {}, - required: const [], - nullable: schema.isNullable, description: schema.description, + nullable: schema.isNullable, ); } - final branches = []; - for (final entry in schema.schemas.entries) { - final converted = _build(schema.effectiveBranch(entry.key)); - if (converted is! AckObjectSchemaModel) { - throw ArgumentError( - 'Discriminated branch "${entry.key}" must export as an object schema model.', - ); + AckSchemaModel _lazy(LazySchema schema) { + final name = schema.name; + final target = schema.target; + final priorTarget = _targets[name]; + if (priorTarget != null) { + if (!identical(priorTarget, target)) { + throw ArgumentError( + 'Two Ack.lazy entries share name "$name" but resolve to different ' + 'schemas. Use unique names per recursive target.', + ); + } + return _lazyRef(schema); } - branches.add(converted); + + _targets[name] = target; + _definitions[name] = null; + _definitions[name] = _build(target); + return _lazyRef(schema); } - return AckAnyOfSchemaModel( - schemas: branches, - discriminator: AckSchemaDiscriminatorModel( - propertyName: schema.discriminatorKey, - ), - description: schema.description, - nullable: schema.isNullable, - ); + AckSchemaModel _lazyRef(LazySchema schema) { + var model = AckRefSchemaModel( + refName: schema.name, + description: schema.description, + nullable: schema.isNullable, + ); + final constraintCount = schema.runtimeConstraintCount; + final refinementCount = schema.runtimeRefinementCount; + if (constraintCount == 0 && refinementCount == 0) { + return model; + } + + return model.withWarnings([ + ...model.warnings, + AckSchemaModelWarning( + code: 'lazy_runtime_checks_not_export_safe', + message: + 'Ack.lazy constraints and refinements were omitted because JSON Schema refs cannot safely carry runtime-only validation checks.', + context: { + 'constraintCount': constraintCount, + 'refinementCount': refinementCount, + }, + ), + ]); + } } -AckSchemaModel _applyConstraints(AckSchemaModel model, AckSchema schema) { +AckSchemaModel _applyConstraints( + AckSchemaModel model, + AckSchema schema, +) { var next = model; for (final constraint in schema.constraints) { if (constraint is DateTimeConstraint) { @@ -248,8 +357,7 @@ AckSchemaModel _applyConstraints(AckSchemaModel model, AckSchema schema) { } if (constraint is JsonSchemaSpec) { - final spec = constraint as JsonSchemaSpec; - next = next.withJsonSchemaKeywords(spec.toJsonSchema()); + next = next.withJsonSchemaKeywords(constraint.toJsonSchema()); } } @@ -280,7 +388,7 @@ AckSchemaModel _applyDateTimeConstraint( /// Encodes the runtime default through the wrapped schema so codec /// transformations are applied, then verifies the result is JSON-safe before /// returning it. Returns `null` when no JSON-safe representation is reachable. -Object? _defaultExportValueOrNull(DefaultSchema schema) { +Object? _defaultExportValueOrNull(DefaultSchema schema) { final resolved = schema.resolveDefaultWithContext( _defaultExportContext(schema), ); @@ -295,7 +403,7 @@ Object? _defaultExportValueOrNull(DefaultSchema schema) { return _jsonRoundTripOrNull(encoded.getOrNull()); } -bool _isRequiredObjectProperty(AckSchema schema) { +bool _isRequiredObjectProperty(AckSchema schema) { if (schema.isOptional) return false; if (schema is DefaultSchema && schema.resolveDefaultWithContext(_defaultExportContext(schema)).isOk) { @@ -309,10 +417,10 @@ bool _isRequiredObjectProperty(AckSchema schema) { /// [DefaultSchema.resolveDefaultWithContext]. Errors produced through this /// context are never surfaced — both callers consume only `.isOk` / /// `.getOrNull()` — so the rooted error path is intentional. -SchemaContext _defaultExportContext(DefaultSchema schema) { +SchemaContext _defaultExportContext(DefaultSchema schema) { return SchemaContext( name: schema.schemaTypeName, - schema: schema, + schema: schema as AnyAckSchema, value: null, ); } diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index 186613e8..40360c59 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -44,6 +44,14 @@ final class DiscriminatedObjectSchema for (final entry in schemas.entries) { final label = entry.key; final base = unwrapDiscriminatedBranchSchema(entry.value); + if (base is LazySchema) { + throw ArgumentError.value( + entry.value, + 'schemas["$label"]', + 'Discriminated branches cannot be Ack.lazy(...) - the discriminator ' + 'property cannot be analyzed through a deferred reference.', + ); + } if (base is! ObjectSchema) { throw ArgumentError.value( entry.value, diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart new file mode 100644 index 00000000..ba7617dc --- /dev/null +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -0,0 +1,118 @@ +part of 'schema.dart'; + +/// Defers resolving another schema until parse, validation, encode, or export +/// time. +/// +/// This enables recursive schema graphs where a child schema needs to refer +/// back to an outer schema that is assigned after construction. +@immutable +final class LazySchema + extends AckSchema + with FluentSchema> { + LazySchema( + this.name, + this._builder, { + super.isNullable, + super.isOptional, + super.description, + super.constraints, + super.refinements, + }); + + /// Human-readable name for this deferred schema reference. + final String name; + + final AckSchema Function() _builder; + + late final AckSchema _target = _builder(); + + @internal + AckSchema get target => _target; + + @internal + int get runtimeConstraintCount => _constraints.length; + + @internal + int get runtimeRefinementCount => _refinements.length; + + @override + SchemaType get schemaType => SchemaType.lazy; + + @override + @protected + SchemaResult parseWithContext(Object? value, SchemaContext context) { + final nullResult = handleNullInput(value, context); + if (nullResult != null) return nullResult; + + final result = _target.parseWithContext(value, context); + if (result.isFail) return SchemaResult.fail(result.getError()); + + final runtime = result.getOrNull(); + if (runtime == null) return SchemaResult.ok(null); + return applyConstraintsAndRefinements(runtime, context); + } + + @override + @protected + SchemaResult validateRuntimeWithContext( + Object? value, + SchemaContext context, + ) { + final nullResult = handleNullInput(value, context); + if (nullResult != null) return nullResult; + + final result = _target.validateRuntimeWithContext(value, context); + if (result.isFail) return SchemaResult.fail(result.getError()); + + final runtime = result.getOrNull(); + if (runtime == null) return SchemaResult.ok(null); + return applyConstraintsAndRefinements(runtime, context); + } + + @override + @protected + SchemaResult encodeWithContext( + Runtime value, + SchemaContext context, + ) { + final ownChecked = applyConstraintsAndRefinements(value, context); + if (ownChecked.isFail) return SchemaResult.fail(ownChecked.getError()); + return _target.encodeWithContext(ownChecked.getOrThrow()!, context); + } + + @override + LazySchema copyWith({ + bool? isNullable, + bool? isOptional, + String? description, + List>? constraints, + List>? refinements, + }) { + return LazySchema( + name, + _builder, + isNullable: isNullable ?? this.isNullable, + isOptional: isOptional ?? this.isOptional, + description: description ?? this.description, + constraints: constraints ?? this.constraints, + refinements: refinements ?? this.refinements, + ); + } + + @override + Map toMap() => {...super.toMap(), 'name': name}; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! LazySchema) return false; + return baseFieldsEqual(other) && + name == other.name && + identical(_builder, other._builder); + } + + @override + int get hashCode { + return Object.hash(baseFieldsHashCode, name, identityHashCode(_builder)); + } +} diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 2efee0ed..313c4f83 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -21,6 +21,7 @@ part 'discriminated_object_schema.dart'; part 'enum_schema.dart'; part 'fluent_schema.dart'; part 'instance_schema.dart'; +part 'lazy_schema.dart'; part 'list_schema.dart'; part 'num_schema.dart'; part 'object_schema.dart'; diff --git a/packages/ack/lib/src/schemas/schema_type.dart b/packages/ack/lib/src/schemas/schema_type.dart index 9a6c7f88..5199cd15 100644 --- a/packages/ack/lib/src/schemas/schema_type.dart +++ b/packages/ack/lib/src/schemas/schema_type.dart @@ -16,6 +16,7 @@ enum SchemaType { any('any'), anyOf('anyOf'), enum_('enum'), + lazy('lazy'), discriminated('discriminated'); const SchemaType(this.typeName); diff --git a/packages/ack/test/schemas/discriminated_object_schema_test.dart b/packages/ack/test/schemas/discriminated_object_schema_test.dart index fd7ffb03..693e1437 100644 --- a/packages/ack/test/schemas/discriminated_object_schema_test.dart +++ b/packages/ack/test/schemas/discriminated_object_schema_test.dart @@ -119,6 +119,22 @@ void main() { expect(result.getOrThrow(), {'type': 'dog', 'bark': false}); }); + test('JSON Schema marks the synthesized discriminator required and ' + 'does not export a discriminator default', () { + final jsonSchema = unionOwnedSchema.toJsonSchema(); + final branches = (jsonSchema['anyOf'] as List).cast(); + + for (final branch in branches) { + final required = (branch['required'] as List).cast(); + expect(required, contains('type')); + + final properties = (branch['properties'] as Map) + .cast(); + final typeProp = (properties['type'] as Map).cast(); + expect(typeProp.containsKey('default'), isFalse); + } + }); + test('parse against the wrong branch fails on the literal', () { final result = unionOwnedSchema.safeParse({ 'type': 'cat', @@ -254,6 +270,64 @@ void main() { ); }); + test('rejects lazy branches', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + }); + + expect( + () => Ack.discriminated( + discriminatorKey: 'type', + schemas: { + 'category': Ack.lazy( + 'Category', + () => categorySchema, + ), + }, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Discriminated branches cannot be Ack.lazy(...)'), + ), + ), + ); + }); + + test('rejects wrapped lazy branches with the lazy-specific error', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + }); + + expect( + () => Ack.discriminated( + discriminatorKey: 'type', + schemas: { + 'category': Ack.lazy( + 'Category', + () => categorySchema, + ).withDefault(const {'name': 'root', 'children': []}), + }, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Discriminated branches cannot be Ack.lazy(...)'), + ), + ), + ); + }); + test('defensively copies schemas', () { final schemas = {'cat': catSchema}; final schema = Ack.discriminated>( diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart new file mode 100644 index 00000000..1f11b9eb --- /dev/null +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -0,0 +1,338 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + test('parses and encodes a recursive object graph', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + }); + + final json = { + 'name': 'root', + 'children': [ + { + 'name': 'first', + 'children': [ + {'name': 'leaf', 'children': []}, + ], + }, + ], + }; + + final parsed = categorySchema.parse(json); + expect(parsed, equals(json)); + + final encoded = categorySchema.encode(parsed); + expect(encoded, equals(json)); + expect(categorySchema.encode(categorySchema.parse(json)), equals(json)); + }); + + test('exports recursive object graph via definitions and refs', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + }); + + final jsonSchema = categorySchema.toJsonSchema(); + final properties = jsonSchema['properties']! as Map; + final children = properties['children']! as Map; + final definitions = jsonSchema['definitions']! as Map; + final categoryDef = definitions['Category']! as Map; + final categoryProperties = categoryDef['properties']! as Map; + final nestedChildren = categoryProperties['children']! as Map; + + expect(children['items'], {r'$ref': '#/definitions/Category'}); + expect(nestedChildren['items'], {r'$ref': '#/definitions/Category'}); + expect(definitions.keys, ['Category']); + }); + + test('deduplicates lazies with the same name and same target', () { + final target = Ack.object({'name': Ack.string()}); + final first = Ack.lazy('Category', () => target); + final second = Ack.lazy('Category', () => target); + final schema = Ack.object({'first': first, 'second': second}); + + final jsonSchema = schema.toJsonSchema(); + final properties = jsonSchema['properties']! as Map; + final definitions = jsonSchema['definitions']! as Map; + + expect(definitions.keys, ['Category']); + expect(properties['first'], {r'$ref': '#/definitions/Category'}); + expect(properties['second'], {r'$ref': '#/definitions/Category'}); + }); + + test('escapes lazy names in JSON Pointer refs', () { + final target = Ack.object({'name': Ack.string()}); + final schema = Ack.object({ + 'node': Ack.lazy('Tree/Node~1', () => target), + }); + + final jsonSchema = schema.toJsonSchema(); + final properties = jsonSchema['properties']! as Map; + final definitions = jsonSchema['definitions']! as Map; + + expect(definitions.keys, ['Tree/Node~1']); + expect(properties['node'], {r'$ref': '#/definitions/Tree~1Node~01'}); + }); + + test('merges custom root definitions with lazy definitions', () { + late final AckSchema categorySchema; + categorySchema = + Ack.object({ + 'name': Ack.string(), + 'slug': Ack.string(), + 'child': Ack.lazy('Category', () => categorySchema), + }).withConstraint( + const _TestJsonSchemaKeywordConstraint({ + 'definitions': { + 'Slug': {'type': 'string', 'pattern': r'^[a-z0-9-]+$'}, + }, + }), + ); + + final jsonSchema = categorySchema.toJsonSchema(); + final definitions = jsonSchema['definitions']! as Map; + + expect(definitions['Slug'], {'type': 'string', 'pattern': r'^[a-z0-9-]+$'}); + expect(definitions['Category'], isA()); + }); + + test('rejects custom root definitions that collide with lazy names', () { + late final AckSchema categorySchema; + categorySchema = + Ack.object({ + 'name': Ack.string(), + 'child': Ack.lazy('Category', () => categorySchema), + }).withConstraint( + const _TestJsonSchemaKeywordConstraint({ + 'definitions': { + 'Category': {'type': 'string'}, + }, + }), + ); + + expect( + categorySchema.toJsonSchema, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('collides with an existing root JSON Schema definition'), + ), + ), + ); + }); + + test('rejects lazies with the same name and different targets', () { + final firstTarget = Ack.object({'name': Ack.string()}); + final secondTarget = Ack.object({'title': Ack.string()}); + final schema = Ack.object({ + 'first': Ack.lazy('Category', () => firstTarget), + 'second': Ack.lazy('Category', () => secondTarget), + }); + + expect( + schema.toJsonSchema, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('share name "Category"'), + ), + ), + ); + }); + + test('wraps non-null lazy metadata without ref siblings', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'child': Ack.lazy( + 'Category', + () => categorySchema, + ).describe('Child category'), + }); + + final jsonSchema = categorySchema.toJsonSchema(); + final properties = jsonSchema['properties']! as Map; + + expect(properties['child'], { + 'description': 'Child category', + 'allOf': [ + {r'$ref': '#/definitions/Category'}, + ], + }); + expect(properties['child'], isNot(contains(r'$ref'))); + }); + + test('exports nullable lazy references with metadata', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'parent': Ack.lazy( + 'Category', + () => categorySchema, + ).describe('Parent category').nullable(), + }); + + final jsonSchema = categorySchema.toJsonSchema(); + final properties = jsonSchema['properties']! as Map; + + expect(properties['parent'], { + 'description': 'Parent category', + 'anyOf': [ + {r'$ref': '#/definitions/Category'}, + {'type': 'null'}, + ], + }); + expect(jsonSchema['definitions'], isNotNull); + }); + + test('warns when lazy runtime checks cannot be exported', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'child': Ack.lazy( + 'Category', + () => categorySchema, + ).refine((value) => true), + }); + + final model = categorySchema.toSchemaModel() as AckObjectSchemaModel; + final child = model.properties!['child']!; + + expect(child.toJsonSchema(), {r'$ref': '#/definitions/Category'}); + expect(child.warnings, hasLength(1)); + expect(child.warnings.single.code, 'lazy_runtime_checks_not_export_safe'); + expect(child.warnings.single.context, { + 'constraintCount': 0, + 'refinementCount': 1, + }); + }); + + test('does not add definitions to non-lazy schemas', () { + final schema = Ack.object({ + 'name': Ack.string().minLength(2), + 'children': Ack.list(Ack.string()), + }); + + expect(schema.toJsonSchema(), { + 'type': 'object', + 'properties': { + 'name': {'type': 'string', 'minLength': 2}, + 'children': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + 'required': ['name', 'children'], + 'additionalProperties': false, + }); + expect(schema.toJsonSchema(), isNot(contains('definitions'))); + }); + + test('memoizes the builder result', () { + var calls = 0; + late final ObjectSchema categorySchema; + final lazy = Ack.lazy('Category', () { + calls++; + return categorySchema; + }); + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list(lazy), + }); + + final json = { + 'name': 'root', + 'children': [ + {'name': 'leaf', 'children': []}, + ], + }; + + final parsed = categorySchema.parse(json); + expect(parsed, equals(json)); + expect(categorySchema.encode(parsed), equals(json)); + expect(calls, 1); + }); + + test('uses closure identity for equality', () { + final target = Ack.object({'name': Ack.string()}); + final first = Ack.lazy('Category', () => target); + final second = Ack.lazy('Category', () => target); + + expect(first, equals(first)); + expect(first, isNot(equals(second))); + }); + + test('encode runs lazy refinement once per nested node (no double ' + 'validation)', () { + var calls = 0; + late final ObjectSchema categorySchema; + final lazy = Ack.lazy('Category', () => categorySchema) + .refine((value) { + calls++; + return true; + }); + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list(lazy), + }); + + final json = { + 'name': 'root', + 'children': [ + { + 'name': 'a', + 'children': [ + { + 'name': 'b', + 'children': [ + {'name': 'c', 'children': []}, + ], + }, + ], + }, + ], + }; + + final parsed = categorySchema.parse(json); + calls = 0; + final encoded = categorySchema.encode(parsed); + expect(encoded, equals(json)); + + // Parent ObjectSchema/ListSchema validate-then-encode passes already drive + // a fixed number of refinement runs per lazy edge. The double-validation + // bug added an extra recursive validate inside LazySchema.encodeWithContext + // on top of that, inflating this count. Locks in the fixed call profile. + expect(calls, 15); + }); +} + +final class _TestJsonSchemaKeywordConstraint + extends Constraint + with Validator, JsonSchemaSpec { + const _TestJsonSchemaKeywordConstraint(this.keywords) + : super( + constraintKey: 'test_schema_model_keywords', + description: 'Adds test-only JSON Schema keywords.', + ); + + final Map keywords; + + @override + bool isValid(T value) => true; + + @override + String buildMessage(T value) => 'ok'; + + @override + Map toJsonSchema() => keywords; +} diff --git a/packages/ack_firebase_ai/CHANGELOG.md b/packages/ack_firebase_ai/CHANGELOG.md index 3ca1c20c..bdc35acc 100644 --- a/packages/ack_firebase_ai/CHANGELOG.md +++ b/packages/ack_firebase_ai/CHANGELOG.md @@ -9,6 +9,8 @@ backends, Firebase app credentials, location, and model override. - Added committed Firebase AI `responseJsonSchema` golden fixtures and a fixture generator so converter coverage runs without Firebase credentials. +- Added Firebase SDK `Schema.toJson()` and `JSONSchema.toJson()` native + fixture corpora with adapter capability classifications. ### Changed - Target Firebase AI `^3.12.1` and models that support JSON Schema diff --git a/packages/ack_firebase_ai/README.md b/packages/ack_firebase_ai/README.md index 2f280c78..ccbe9460 100644 --- a/packages/ack_firebase_ai/README.md +++ b/packages/ack_firebase_ai/README.md @@ -178,8 +178,10 @@ FIREBASE_AI_LOCATION=global The committed fixture tests are the normal adapter contract and do not require Firebase credentials. They compare ACK conversion output against JSON fixtures -under `test/fixtures/firebase_ai_response_json_schema/` and verify the same -maps serialize through Firebase AI's `GenerationConfig.responseJsonSchema`. +under `test/fixtures/firebase_ai_response_json_schema/`, capture Firebase SDK +`Schema.toJson()` and `JSONSchema.toJson()` native fixture output, and verify +the same maps serialize through Firebase AI's +`GenerationConfig.responseJsonSchema`. Regenerate the fixtures after an intentional schema-output change: diff --git a/packages/ack_firebase_ai/test/firebase_ai_schema_capability_matrix_test.dart b/packages/ack_firebase_ai/test/firebase_ai_schema_capability_matrix_test.dart new file mode 100644 index 00000000..c7fe4301 --- /dev/null +++ b/packages/ack_firebase_ai/test/firebase_ai_schema_capability_matrix_test.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +import 'support/firebase_ai_native_schema_cases.dart'; +import 'support/firebase_ai_response_json_schema_cases.dart'; + +void main() { + group('Firebase AI schema capability matrix', () { + test('tracks every ACK adapter case for both Firebase native families', () { + final ackCases = firebaseAiResponseJsonSchemaCases(); + + for (final family in FirebaseAiNativeSchemaFixtureFamily.values) { + final nativeCases = firebaseAiNativeSchemaCases(family); + + expect(nativeCases.map((schemaCase) => schemaCase.id), [ + for (final schemaCase in ackCases) schemaCase.id, + ]); + expect( + nativeCases.map((schemaCase) => schemaCase.comparison).toSet(), + containsAll([ + FirebaseAiSchemaComparison.adapterTransformNeeded.jsonValue, + FirebaseAiSchemaComparison.unsupportedByFirebaseSchema.jsonValue, + ]), + ); + } + }); + + test('records known provider-specific classifications', () { + final schemaCases = _casesById( + firebaseAiNativeSchemaCases(FirebaseAiNativeSchemaFixtureFamily.schema), + ); + final jsonSchemaCases = _casesById( + firebaseAiNativeSchemaCases( + FirebaseAiNativeSchemaFixtureFamily.jsonSchema, + ), + ); + + expect( + schemaCases['ack_schema_string_literal']!.comparison, + FirebaseAiSchemaComparison.adapterTransformNeeded.jsonValue, + ); + expect( + schemaCases['ack_schema_recursive_lazy_ref']!.comparison, + FirebaseAiSchemaComparison.unsupportedByFirebaseSchema.jsonValue, + ); + expect( + jsonSchemaCases['ack_schema_recursive_lazy_ref']!.comparison, + FirebaseAiSchemaComparison.backendLimited.jsonValue, + ); + expect( + jsonSchemaCases['schema_model_allof']!.comparison, + FirebaseAiSchemaComparison.unsupportedByFirebaseSchema.jsonValue, + ); + expect( + jsonSchemaCases['schema_model_boolean_const']!.comparison, + FirebaseAiSchemaComparison.unsupportedByFirebaseSchema.jsonValue, + ); + }); + + test( + 'generated native fixtures are intentionally comparable to ACK output', + () { + final ackFixtures = _AckFixtureSet.load(); + + for (final family in FirebaseAiNativeSchemaFixtureFamily.values) { + for (final nativeCase in firebaseAiNativeSchemaCases(family)) { + final ackJson = ackFixtures.byId(nativeCase.id); + + switch (nativeCase.comparisonEnum) { + case FirebaseAiSchemaComparison.exact: + expect(nativeCase.buildJsonSchema(), ackJson); + case FirebaseAiSchemaComparison.equivalent: + case FirebaseAiSchemaComparison.adapterTransformNeeded: + case FirebaseAiSchemaComparison.backendLimited: + if (nativeCase.isGenerated) { + expect(nativeCase.buildJsonSchema(), isNot(ackJson)); + } + case FirebaseAiSchemaComparison.unsupportedByFirebaseSchema: + expect(nativeCase.isGenerated, isFalse); + } + } + } + }, + ); + }); +} + +Map _casesById( + List cases, +) { + return {for (final schemaCase in cases) schemaCase.id: schemaCase}; +} + +final class _AckFixtureSet { + const _AckFixtureSet(this._fixtures); + + final Map> _fixtures; + + Map byId(String id) { + final fixture = _fixtures[id]; + if (fixture == null) fail('Missing ACK fixture for $id.'); + return fixture; + } + + static _AckFixtureSet load() { + final packageRoot = _findPackageRoot(); + final fixtureDir = Directory( + '${packageRoot.path}/test/fixtures/firebase_ai_response_json_schema', + ); + final manifestFile = File('${fixtureDir.path}/manifest.json'); + final manifest = _jsonObject( + jsonDecode(manifestFile.readAsStringSync()), + manifestFile.path, + ); + final fixtures = >{}; + + for (final entry in _jsonList(manifest['fixtures'], 'fixtures')) { + final manifestFixture = _jsonObject(entry, 'fixtures[]'); + final id = _jsonString(manifestFixture['id'], 'fixtures[].id'); + final fixtureName = _jsonString( + manifestFixture['fixture'], + 'fixtures[].fixture', + ); + final fixtureFile = File('${fixtureDir.path}/$fixtureName'); + fixtures[id] = _jsonObject( + jsonDecode(fixtureFile.readAsStringSync()), + fixtureFile.path, + ); + } + + return _AckFixtureSet(fixtures); + } +} + +Directory _findPackageRoot() { + var current = Directory.current.absolute; + while (true) { + final pubspec = File('${current.path}/pubspec.yaml'); + if (pubspec.existsSync() && + pubspec.readAsStringSync().contains('name: ack_firebase_ai')) { + return current; + } + + final nested = Directory('${current.path}/packages/ack_firebase_ai'); + final nestedPubspec = File('${nested.path}/pubspec.yaml'); + if (nestedPubspec.existsSync() && + nestedPubspec.readAsStringSync().contains('name: ack_firebase_ai')) { + return nested; + } + + final parent = current.parent; + if (parent.path == current.path) { + fail( + 'Could not find packages/ack_firebase_ai from ${Directory.current}.', + ); + } + current = parent; + } +} + +Map _jsonObject(Object? value, String path) { + if (value is Map) return value; + if (value is Map) return value.cast(); + fail('Expected $path to be a JSON object, got ${value.runtimeType}.'); +} + +List _jsonList(Object? value, String path) { + if (value is List) return value; + if (value is List) return value.cast(); + fail('Expected $path to be a JSON array, got ${value.runtimeType}.'); +} + +String _jsonString(Object? value, String path) { + if (value is String) return value; + fail('Expected $path to be a string, got ${value.runtimeType}.'); +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_anyof_nullable_composition.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_anyof_nullable_composition.json new file mode 100644 index 00000000..11fbd65b --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_anyof_nullable_composition.json @@ -0,0 +1,16 @@ +{ + "anyOf": [ + { + "type": [ + "string", + "null" + ] + }, + { + "type": [ + "integer", + "null" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_array_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_array_constraints.json new file mode 100644 index 00000000..cda0f957 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_array_constraints.json @@ -0,0 +1,9 @@ +{ + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 3 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_date_transform_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_date_transform_constraints.json new file mode 100644 index 00000000..c1a190b8 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_date_transform_constraints.json @@ -0,0 +1,4 @@ +{ + "type": "string", + "format": "date" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_datetime_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_datetime_transform.json new file mode 100644 index 00000000..fd9a76a4 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_datetime_transform.json @@ -0,0 +1,4 @@ +{ + "type": "string", + "format": "date-time" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_discriminated_union.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_discriminated_union.json new file mode 100644 index 00000000..fc1a674d --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_discriminated_union.json @@ -0,0 +1,44 @@ +{ + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "enum", + "enum": [ + "circle" + ] + }, + "radius": { + "type": "number", + "minimum": 0.0 + } + }, + "required": [ + "type", + "radius" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "enum", + "enum": [ + "square" + ] + }, + "side": { + "type": "number", + "minimum": 0.0 + } + }, + "required": [ + "type", + "side" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_duration_transform_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_duration_transform_constraints.json new file mode 100644 index 00000000..2b34ee71 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_duration_transform_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "integer", + "minimum": 1000.0, + "maximum": 2000.0 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_enum_values_default.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_enum_values_default.json new file mode 100644 index 00000000..3d437a83 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_enum_values_default.json @@ -0,0 +1,8 @@ +{ + "type": "string", + "format": "enum", + "enum": [ + "admin", + "member" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_generic_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_generic_transform.json new file mode 100644 index 00000000..28ac6e86 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_generic_transform.json @@ -0,0 +1,3 @@ +{ + "type": "string" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_integer_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_integer_constraints.json new file mode 100644 index 00000000..d1dca2e6 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_integer_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "integer", + "minimum": 1.0, + "maximum": 10.0 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_nullable_boolean_default.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_nullable_boolean_default.json new file mode 100644 index 00000000..c0575545 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_nullable_boolean_default.json @@ -0,0 +1,6 @@ +{ + "type": [ + "boolean", + "null" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_number_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_number_constraints.json new file mode 100644 index 00000000..44b6d85d --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_number_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "number", + "minimum": 0.5, + "maximum": 9.5 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_object_properties_requiredness.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_object_properties_requiredness.json new file mode 100644 index 00000000..97c26e16 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_object_properties_requiredness.json @@ -0,0 +1,35 @@ +{ + "type": "object", + "description": "User payload", + "properties": { + "name": { + "type": "string", + "description": "Full name" + }, + "age": { + "type": "integer", + "minimum": 0.0, + "maximum": 120.0 + }, + "role": { + "type": "string", + "format": "enum", + "enum": [ + "admin", + "member" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 5 + } + }, + "required": [ + "name", + "role" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_recursive_lazy_ref.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_recursive_lazy_ref.json new file mode 100644 index 00000000..90ee1cec --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_recursive_lazy_ref.json @@ -0,0 +1,46 @@ +{ + "type": "object", + "$defs": { + "Category": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/Category" + } + }, + "featured": { + "$ref": "#/$defs/Category" + } + }, + "required": [ + "name", + "children", + "featured" + ] + } + }, + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/Category" + } + }, + "featured": { + "$ref": "#/$defs/Category" + } + }, + "required": [ + "name", + "children", + "featured" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_constraints.json new file mode 100644 index 00000000..bf021142 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_constraints.json @@ -0,0 +1,4 @@ +{ + "type": "string", + "description": "Code" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_literal.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_literal.json new file mode 100644 index 00000000..3dc153b9 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_string_literal.json @@ -0,0 +1,7 @@ +{ + "type": "string", + "format": "enum", + "enum": [ + "ready" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_uri_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_uri_transform.json new file mode 100644 index 00000000..3caf8ad3 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/ack_schema_uri_transform.json @@ -0,0 +1,4 @@ +{ + "type": "string", + "format": "uri" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json new file mode 100644 index 00000000..7670dec3 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json @@ -0,0 +1,626 @@ +{ + "description": "Firebase AI JSONSchema.toJson() fixtures for native SDK schema comparison.", + "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", + "sourcePackage": "firebase_ai", + "sourceVersion": "3.12.1", + "sourceClass": "JSONSchema", + "fixtureCount": 30, + "featureCoverage": { + "$ref": [ + "ack_schema_recursive_lazy_ref" + ], + "additionalProperties": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness" + ], + "additionalPropertiesSchema": [ + "schema_model_object_schema_additional_properties" + ], + "allOf": [ + "ack_schema_recursive_lazy_ref", + "schema_model_allof" + ], + "any": [ + "ack_schema_any_json_compatible_branches" + ], + "anyOf": [ + "ack_schema_any_json_compatible_branches", + "ack_schema_anyof_nullable_composition", + "ack_schema_discriminated_union", + "ack_schema_nullable_boolean_default", + "ack_schema_object_passthrough", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions" + ], + "array": [ + "ack_schema_array_constraints", + "ack_schema_recursive_lazy_ref", + "schema_model_array_without_item_schema" + ], + "boolean": [ + "ack_schema_nullable_boolean_default", + "schema_model_boolean_const" + ], + "const": [ + "ack_schema_discriminated_union", + "ack_schema_string_literal", + "schema_model_boolean_const", + "schema_model_integer_const_format", + "schema_model_nullable_default_extensions", + "schema_model_number_const_format", + "schema_model_oneof_discriminator", + "schema_model_oneof_nullable_composition", + "schema_model_string_common_options" + ], + "default": [ + "ack_schema_array_constraints", + "ack_schema_enum_values_default", + "ack_schema_integer_constraints", + "ack_schema_nullable_boolean_default", + "ack_schema_number_constraints", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions" + ], + "definitions": [ + "ack_schema_recursive_lazy_ref" + ], + "description": [ + "schema_model_string_common_options" + ], + "enum": [ + "ack_schema_enum_values_default" + ], + "exclusiveMaximum": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "exclusiveMinimum": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "extension": [ + "ack_schema_generic_transform", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions", + "schema_model_string_common_options" + ], + "format": [ + "ack_schema_array_constraints", + "ack_schema_date_transform_constraints", + "ack_schema_datetime_transform", + "ack_schema_uri_transform", + "schema_model_integer_const_format", + "schema_model_number_const_format", + "schema_model_string_common_options" + ], + "integer": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "schema_model_integer_const_format" + ], + "items": [ + "ack_schema_array_constraints" + ], + "maxItems": [ + "ack_schema_array_constraints", + "schema_model_array_without_item_schema" + ], + "maxLength": [ + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "maxProperties": [ + "schema_model_object_schema_additional_properties" + ], + "maximum": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "minItems": [ + "ack_schema_array_constraints", + "schema_model_array_without_item_schema" + ], + "minLength": [ + "ack_schema_generic_transform", + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "minProperties": [ + "schema_model_object_schema_additional_properties" + ], + "minimum": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "multipleOf": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "null": [ + "schema_model_anyof_common_fields_explicit_null", + "schema_model_null", + "schema_model_oneof_nullable_composition" + ], + "nullable": [ + "ack_schema_anyof_nullable_composition", + "ack_schema_nullable_boolean_default", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions", + "schema_model_oneof_nullable_composition" + ], + "number": [ + "ack_schema_number_constraints", + "schema_model_number_const_format" + ], + "object": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness", + "ack_schema_recursive_lazy_ref", + "schema_model_object_schema_additional_properties" + ], + "oneOf": [ + "schema_model_oneof_discriminator", + "schema_model_oneof_nullable_composition" + ], + "optional": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness" + ], + "pattern": [ + "ack_schema_array_constraints", + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "properties": [ + "ack_schema_object_properties_requiredness", + "schema_model_object_schema_additional_properties" + ], + "required": [ + "ack_schema_object_properties_requiredness", + "schema_model_object_schema_additional_properties" + ], + "string": [ + "ack_schema_enum_values_default", + "ack_schema_string_constraints", + "ack_schema_string_literal", + "schema_model_nullable_default_extensions", + "schema_model_string_common_options" + ], + "title": [ + "schema_model_null", + "schema_model_string_common_options" + ], + "transform": [ + "ack_schema_date_transform_constraints", + "ack_schema_datetime_transform", + "ack_schema_duration_transform_constraints", + "ack_schema_generic_transform", + "ack_schema_uri_transform" + ], + "unionOwnedDiscriminator": [ + "ack_schema_discriminated_union" + ], + "uniqueItems": [ + "ack_schema_array_constraints" + ] + }, + "fixtures": [ + { + "id": "ack_schema_string_constraints", + "name": "string constraints", + "source": "ack_schema", + "features": [ + "string", + "minLength", + "maxLength", + "pattern" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_string_constraints.json" + }, + { + "id": "ack_schema_string_literal", + "name": "string literal", + "source": "ack_schema", + "features": [ + "string", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_string_literal.json" + }, + { + "id": "ack_schema_enum_values_default", + "name": "Dart enum values and enum default", + "source": "ack_schema", + "features": [ + "string", + "enum", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_enum_values_default.json" + }, + { + "id": "ack_schema_integer_constraints", + "name": "integer constraints", + "source": "ack_schema", + "features": [ + "integer", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_integer_constraints.json" + }, + { + "id": "ack_schema_number_constraints", + "name": "number constraints", + "source": "ack_schema", + "features": [ + "number", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_number_constraints.json" + }, + { + "id": "ack_schema_nullable_boolean_default", + "name": "nullable boolean default", + "source": "ack_schema", + "features": [ + "boolean", + "nullable", + "anyOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_nullable_boolean_default.json" + }, + { + "id": "ack_schema_array_constraints", + "name": "array constraints", + "source": "ack_schema", + "features": [ + "array", + "items", + "format", + "pattern", + "minItems", + "maxItems", + "uniqueItems", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_array_constraints.json" + }, + { + "id": "ack_schema_object_properties_requiredness", + "name": "object properties and requiredness", + "source": "ack_schema", + "features": [ + "object", + "properties", + "required", + "optional", + "additionalProperties" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_object_properties_requiredness.json" + }, + { + "id": "ack_schema_object_passthrough", + "name": "object passthrough", + "source": "ack_schema", + "features": [ + "object", + "additionalProperties", + "anyOf", + "optional" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema cannot represent Ack.any() or open additional properties." + }, + { + "id": "ack_schema_anyof_nullable_composition", + "name": "anyOf nullable composition", + "source": "ack_schema", + "features": [ + "anyOf", + "nullable" + ], + "status": "generated", + "comparison": "equivalent", + "fixture": "ack_schema_anyof_nullable_composition.json" + }, + { + "id": "ack_schema_generic_transform", + "name": "generic transform", + "source": "ack_schema", + "features": [ + "transform", + "extension", + "minLength" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_generic_transform.json" + }, + { + "id": "ack_schema_any_json_compatible_branches", + "name": "any JSON-compatible branches", + "source": "ack_schema", + "features": [ + "any", + "anyOf" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema has no unconstrained JSON value representation." + }, + { + "id": "ack_schema_date_transform_constraints", + "name": "date transform constraints", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_date_transform_constraints.json" + }, + { + "id": "ack_schema_datetime_transform", + "name": "datetime transform", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_datetime_transform.json" + }, + { + "id": "ack_schema_uri_transform", + "name": "uri transform", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_uri_transform.json" + }, + { + "id": "ack_schema_duration_transform_constraints", + "name": "duration transform constraints", + "source": "ack_schema", + "features": [ + "transform", + "integer", + "minimum", + "maximum" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_duration_transform_constraints.json" + }, + { + "id": "ack_schema_discriminated_union", + "name": "discriminated union", + "source": "ack_schema", + "features": [ + "anyOf", + "const", + "unionOwnedDiscriminator" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_discriminated_union.json" + }, + { + "id": "ack_schema_recursive_lazy_ref", + "name": "recursive lazy reference", + "source": "ack_schema", + "features": [ + "object", + "array", + "definitions", + "$ref", + "allOf" + ], + "status": "generated", + "comparison": "backend_limited", + "fixture": "ack_schema_recursive_lazy_ref.json" + }, + { + "id": "schema_model_string_common_options", + "name": "string model common and string-only options", + "source": "schema_model", + "features": [ + "string", + "title", + "description", + "format", + "const", + "minLength", + "maxLength", + "pattern", + "extension" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_string_common_options.json" + }, + { + "id": "schema_model_integer_const_format", + "name": "integer model const and format", + "source": "schema_model", + "features": [ + "integer", + "format", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_integer_const_format.json" + }, + { + "id": "schema_model_number_const_format", + "name": "number model const and format", + "source": "schema_model", + "features": [ + "number", + "format", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_number_const_format.json" + }, + { + "id": "schema_model_boolean_const", + "name": "boolean model const", + "source": "schema_model", + "features": [ + "boolean", + "const" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema cannot represent a boolean const value." + }, + { + "id": "schema_model_nullable_default_extensions", + "name": "nullable model default and extensions", + "source": "schema_model", + "features": [ + "string", + "nullable", + "anyOf", + "const", + "default", + "extension" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_nullable_default_extensions.json" + }, + { + "id": "schema_model_array_without_item_schema", + "name": "array model without item schema", + "source": "schema_model", + "features": [ + "array", + "minItems", + "maxItems" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema.array requires an item schema." + }, + { + "id": "schema_model_object_schema_additional_properties", + "name": "object model property count and schema additional properties", + "source": "schema_model", + "features": [ + "object", + "properties", + "required", + "minProperties", + "maxProperties", + "additionalPropertiesSchema" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_object_schema_additional_properties.json" + }, + { + "id": "schema_model_null", + "name": "null model", + "source": "schema_model", + "features": [ + "null", + "title" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema has no null-only schema type." + }, + { + "id": "schema_model_anyof_common_fields_explicit_null", + "name": "anyOf model common fields and explicit null branch", + "source": "schema_model", + "features": [ + "anyOf", + "nullable", + "default", + "extension", + "null" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_anyof_common_fields_explicit_null.json" + }, + { + "id": "schema_model_oneof_nullable_composition", + "name": "oneOf model nullable composition", + "source": "schema_model", + "features": [ + "oneOf", + "nullable", + "const", + "null" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_oneof_nullable_composition.json" + }, + { + "id": "schema_model_oneof_discriminator", + "name": "oneOf model discriminator metadata", + "source": "schema_model", + "features": [ + "oneOf", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_oneof_discriminator.json" + }, + { + "id": "schema_model_allof", + "name": "allOf model", + "source": "schema_model", + "features": [ + "allOf" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase JSONSchema has no allOf composition builder." + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_anyof_common_fields_explicit_null.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_anyof_common_fields_explicit_null.json new file mode 100644 index 00000000..11fbd65b --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_anyof_common_fields_explicit_null.json @@ -0,0 +1,16 @@ +{ + "anyOf": [ + { + "type": [ + "string", + "null" + ] + }, + { + "type": [ + "integer", + "null" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_integer_const_format.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_integer_const_format.json new file mode 100644 index 00000000..782f2003 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_integer_const_format.json @@ -0,0 +1,3 @@ +{ + "type": "integer" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_nullable_default_extensions.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_nullable_default_extensions.json new file mode 100644 index 00000000..830edfe5 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_nullable_default_extensions.json @@ -0,0 +1,6 @@ +{ + "type": [ + "string", + "null" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_number_const_format.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_number_const_format.json new file mode 100644 index 00000000..3ab970ec --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_number_const_format.json @@ -0,0 +1,3 @@ +{ + "type": "number" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_object_schema_additional_properties.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_object_schema_additional_properties.json new file mode 100644 index 00000000..b7e92ecd --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_object_schema_additional_properties.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_discriminator.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_discriminator.json new file mode 100644 index 00000000..4d0d056a --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_discriminator.json @@ -0,0 +1,43 @@ +{ + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "enum", + "enum": [ + "email" + ] + }, + "address": { + "type": "string", + "format": "email" + } + }, + "required": [ + "type", + "address" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "enum", + "enum": [ + "sms" + ] + }, + "number": { + "type": "string" + } + }, + "required": [ + "type", + "number" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_nullable_composition.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_nullable_composition.json new file mode 100644 index 00000000..14d5a667 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_oneof_nullable_composition.json @@ -0,0 +1,21 @@ +{ + "anyOf": [ + { + "type": [ + "string", + "null" + ], + "format": "enum", + "enum": [ + "ready" + ] + }, + { + "type": [ + "integer", + "null" + ], + "minimum": 1.0 + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_string_common_options.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_string_common_options.json new file mode 100644 index 00000000..92590a7a --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/schema_model_string_common_options.json @@ -0,0 +1,6 @@ +{ + "type": "string", + "format": "custom-format", + "description": "Current status", + "title": "Status" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_anyof_nullable_composition.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_anyof_nullable_composition.json new file mode 100644 index 00000000..ea66fdad --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_anyof_nullable_composition.json @@ -0,0 +1,12 @@ +{ + "anyOf": [ + { + "type": "STRING", + "nullable": true + }, + { + "type": "INTEGER", + "nullable": true + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_array_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_array_constraints.json new file mode 100644 index 00000000..1245034d --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_array_constraints.json @@ -0,0 +1,9 @@ +{ + "type": "ARRAY", + "items": { + "type": "STRING", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 3 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_date_transform_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_date_transform_constraints.json new file mode 100644 index 00000000..e5415056 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_date_transform_constraints.json @@ -0,0 +1,4 @@ +{ + "type": "STRING", + "format": "date" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_datetime_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_datetime_transform.json new file mode 100644 index 00000000..3d6502cb --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_datetime_transform.json @@ -0,0 +1,4 @@ +{ + "type": "STRING", + "format": "date-time" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_discriminated_union.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_discriminated_union.json new file mode 100644 index 00000000..dcf0ec58 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_discriminated_union.json @@ -0,0 +1,44 @@ +{ + "anyOf": [ + { + "type": "OBJECT", + "properties": { + "type": { + "type": "STRING", + "format": "enum", + "enum": [ + "circle" + ] + }, + "radius": { + "type": "NUMBER", + "minimum": 0.0 + } + }, + "required": [ + "type", + "radius" + ] + }, + { + "type": "OBJECT", + "properties": { + "type": { + "type": "STRING", + "format": "enum", + "enum": [ + "square" + ] + }, + "side": { + "type": "NUMBER", + "minimum": 0.0 + } + }, + "required": [ + "type", + "side" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_duration_transform_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_duration_transform_constraints.json new file mode 100644 index 00000000..90160e21 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_duration_transform_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "INTEGER", + "minimum": 1000.0, + "maximum": 2000.0 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_enum_values_default.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_enum_values_default.json new file mode 100644 index 00000000..4793defb --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_enum_values_default.json @@ -0,0 +1,8 @@ +{ + "type": "STRING", + "format": "enum", + "enum": [ + "admin", + "member" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_generic_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_generic_transform.json new file mode 100644 index 00000000..6cfefe38 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_generic_transform.json @@ -0,0 +1,3 @@ +{ + "type": "STRING" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_integer_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_integer_constraints.json new file mode 100644 index 00000000..16ba093e --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_integer_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "INTEGER", + "minimum": 1.0, + "maximum": 10.0 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_nullable_boolean_default.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_nullable_boolean_default.json new file mode 100644 index 00000000..5e6cf023 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_nullable_boolean_default.json @@ -0,0 +1,4 @@ +{ + "type": "BOOLEAN", + "nullable": true +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_number_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_number_constraints.json new file mode 100644 index 00000000..fb42bac6 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_number_constraints.json @@ -0,0 +1,5 @@ +{ + "type": "NUMBER", + "minimum": 0.5, + "maximum": 9.5 +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_object_properties_requiredness.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_object_properties_requiredness.json new file mode 100644 index 00000000..59781a17 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_object_properties_requiredness.json @@ -0,0 +1,41 @@ +{ + "type": "OBJECT", + "description": "User payload", + "properties": { + "name": { + "type": "STRING", + "description": "Full name" + }, + "age": { + "type": "INTEGER", + "minimum": 0.0, + "maximum": 120.0 + }, + "role": { + "type": "STRING", + "format": "enum", + "enum": [ + "admin", + "member" + ] + }, + "tags": { + "type": "ARRAY", + "items": { + "type": "STRING" + }, + "minItems": 1, + "maxItems": 5 + } + }, + "required": [ + "name", + "role" + ], + "propertyOrdering": [ + "name", + "age", + "role", + "tags" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_constraints.json new file mode 100644 index 00000000..975a1423 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_constraints.json @@ -0,0 +1,4 @@ +{ + "type": "STRING", + "description": "Code" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_literal.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_literal.json new file mode 100644 index 00000000..565738dd --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_string_literal.json @@ -0,0 +1,7 @@ +{ + "type": "STRING", + "format": "enum", + "enum": [ + "ready" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_uri_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_uri_transform.json new file mode 100644 index 00000000..c161c449 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/ack_schema_uri_transform.json @@ -0,0 +1,4 @@ +{ + "type": "STRING", + "format": "uri" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json new file mode 100644 index 00000000..3c8f007a --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/manifest.json @@ -0,0 +1,626 @@ +{ + "description": "Firebase AI Schema.toJson() fixtures for native SDK schema comparison.", + "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", + "sourcePackage": "firebase_ai", + "sourceVersion": "3.12.1", + "sourceClass": "Schema", + "fixtureCount": 30, + "featureCoverage": { + "$ref": [ + "ack_schema_recursive_lazy_ref" + ], + "additionalProperties": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness" + ], + "additionalPropertiesSchema": [ + "schema_model_object_schema_additional_properties" + ], + "allOf": [ + "ack_schema_recursive_lazy_ref", + "schema_model_allof" + ], + "any": [ + "ack_schema_any_json_compatible_branches" + ], + "anyOf": [ + "ack_schema_any_json_compatible_branches", + "ack_schema_anyof_nullable_composition", + "ack_schema_discriminated_union", + "ack_schema_nullable_boolean_default", + "ack_schema_object_passthrough", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions" + ], + "array": [ + "ack_schema_array_constraints", + "ack_schema_recursive_lazy_ref", + "schema_model_array_without_item_schema" + ], + "boolean": [ + "ack_schema_nullable_boolean_default", + "schema_model_boolean_const" + ], + "const": [ + "ack_schema_discriminated_union", + "ack_schema_string_literal", + "schema_model_boolean_const", + "schema_model_integer_const_format", + "schema_model_nullable_default_extensions", + "schema_model_number_const_format", + "schema_model_oneof_discriminator", + "schema_model_oneof_nullable_composition", + "schema_model_string_common_options" + ], + "default": [ + "ack_schema_array_constraints", + "ack_schema_enum_values_default", + "ack_schema_integer_constraints", + "ack_schema_nullable_boolean_default", + "ack_schema_number_constraints", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions" + ], + "definitions": [ + "ack_schema_recursive_lazy_ref" + ], + "description": [ + "schema_model_string_common_options" + ], + "enum": [ + "ack_schema_enum_values_default" + ], + "exclusiveMaximum": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "exclusiveMinimum": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "extension": [ + "ack_schema_generic_transform", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions", + "schema_model_string_common_options" + ], + "format": [ + "ack_schema_array_constraints", + "ack_schema_date_transform_constraints", + "ack_schema_datetime_transform", + "ack_schema_uri_transform", + "schema_model_integer_const_format", + "schema_model_number_const_format", + "schema_model_string_common_options" + ], + "integer": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "schema_model_integer_const_format" + ], + "items": [ + "ack_schema_array_constraints" + ], + "maxItems": [ + "ack_schema_array_constraints", + "schema_model_array_without_item_schema" + ], + "maxLength": [ + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "maxProperties": [ + "schema_model_object_schema_additional_properties" + ], + "maximum": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "minItems": [ + "ack_schema_array_constraints", + "schema_model_array_without_item_schema" + ], + "minLength": [ + "ack_schema_generic_transform", + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "minProperties": [ + "schema_model_object_schema_additional_properties" + ], + "minimum": [ + "ack_schema_duration_transform_constraints", + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "multipleOf": [ + "ack_schema_integer_constraints", + "ack_schema_number_constraints" + ], + "null": [ + "schema_model_anyof_common_fields_explicit_null", + "schema_model_null", + "schema_model_oneof_nullable_composition" + ], + "nullable": [ + "ack_schema_anyof_nullable_composition", + "ack_schema_nullable_boolean_default", + "schema_model_anyof_common_fields_explicit_null", + "schema_model_nullable_default_extensions", + "schema_model_oneof_nullable_composition" + ], + "number": [ + "ack_schema_number_constraints", + "schema_model_number_const_format" + ], + "object": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness", + "ack_schema_recursive_lazy_ref", + "schema_model_object_schema_additional_properties" + ], + "oneOf": [ + "schema_model_oneof_discriminator", + "schema_model_oneof_nullable_composition" + ], + "optional": [ + "ack_schema_object_passthrough", + "ack_schema_object_properties_requiredness" + ], + "pattern": [ + "ack_schema_array_constraints", + "ack_schema_string_constraints", + "schema_model_string_common_options" + ], + "properties": [ + "ack_schema_object_properties_requiredness", + "schema_model_object_schema_additional_properties" + ], + "required": [ + "ack_schema_object_properties_requiredness", + "schema_model_object_schema_additional_properties" + ], + "string": [ + "ack_schema_enum_values_default", + "ack_schema_string_constraints", + "ack_schema_string_literal", + "schema_model_nullable_default_extensions", + "schema_model_string_common_options" + ], + "title": [ + "schema_model_null", + "schema_model_string_common_options" + ], + "transform": [ + "ack_schema_date_transform_constraints", + "ack_schema_datetime_transform", + "ack_schema_duration_transform_constraints", + "ack_schema_generic_transform", + "ack_schema_uri_transform" + ], + "unionOwnedDiscriminator": [ + "ack_schema_discriminated_union" + ], + "uniqueItems": [ + "ack_schema_array_constraints" + ] + }, + "fixtures": [ + { + "id": "ack_schema_string_constraints", + "name": "string constraints", + "source": "ack_schema", + "features": [ + "string", + "minLength", + "maxLength", + "pattern" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_string_constraints.json" + }, + { + "id": "ack_schema_string_literal", + "name": "string literal", + "source": "ack_schema", + "features": [ + "string", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_string_literal.json" + }, + { + "id": "ack_schema_enum_values_default", + "name": "Dart enum values and enum default", + "source": "ack_schema", + "features": [ + "string", + "enum", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_enum_values_default.json" + }, + { + "id": "ack_schema_integer_constraints", + "name": "integer constraints", + "source": "ack_schema", + "features": [ + "integer", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_integer_constraints.json" + }, + { + "id": "ack_schema_number_constraints", + "name": "number constraints", + "source": "ack_schema", + "features": [ + "number", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_number_constraints.json" + }, + { + "id": "ack_schema_nullable_boolean_default", + "name": "nullable boolean default", + "source": "ack_schema", + "features": [ + "boolean", + "nullable", + "anyOf", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_nullable_boolean_default.json" + }, + { + "id": "ack_schema_array_constraints", + "name": "array constraints", + "source": "ack_schema", + "features": [ + "array", + "items", + "format", + "pattern", + "minItems", + "maxItems", + "uniqueItems", + "default" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_array_constraints.json" + }, + { + "id": "ack_schema_object_properties_requiredness", + "name": "object properties and requiredness", + "source": "ack_schema", + "features": [ + "object", + "properties", + "required", + "optional", + "additionalProperties" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_object_properties_requiredness.json" + }, + { + "id": "ack_schema_object_passthrough", + "name": "object passthrough", + "source": "ack_schema", + "features": [ + "object", + "additionalProperties", + "anyOf", + "optional" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema cannot represent Ack.any() or open additional properties." + }, + { + "id": "ack_schema_anyof_nullable_composition", + "name": "anyOf nullable composition", + "source": "ack_schema", + "features": [ + "anyOf", + "nullable" + ], + "status": "generated", + "comparison": "equivalent", + "fixture": "ack_schema_anyof_nullable_composition.json" + }, + { + "id": "ack_schema_generic_transform", + "name": "generic transform", + "source": "ack_schema", + "features": [ + "transform", + "extension", + "minLength" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_generic_transform.json" + }, + { + "id": "ack_schema_any_json_compatible_branches", + "name": "any JSON-compatible branches", + "source": "ack_schema", + "features": [ + "any", + "anyOf" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema has no unconstrained JSON value representation." + }, + { + "id": "ack_schema_date_transform_constraints", + "name": "date transform constraints", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_date_transform_constraints.json" + }, + { + "id": "ack_schema_datetime_transform", + "name": "datetime transform", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_datetime_transform.json" + }, + { + "id": "ack_schema_uri_transform", + "name": "uri transform", + "source": "ack_schema", + "features": [ + "transform", + "format" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_uri_transform.json" + }, + { + "id": "ack_schema_duration_transform_constraints", + "name": "duration transform constraints", + "source": "ack_schema", + "features": [ + "transform", + "integer", + "minimum", + "maximum" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_duration_transform_constraints.json" + }, + { + "id": "ack_schema_discriminated_union", + "name": "discriminated union", + "source": "ack_schema", + "features": [ + "anyOf", + "const", + "unionOwnedDiscriminator" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "ack_schema_discriminated_union.json" + }, + { + "id": "ack_schema_recursive_lazy_ref", + "name": "recursive lazy reference", + "source": "ack_schema", + "features": [ + "object", + "array", + "definitions", + "$ref", + "allOf" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema does not support reusable definitions or references." + }, + { + "id": "schema_model_string_common_options", + "name": "string model common and string-only options", + "source": "schema_model", + "features": [ + "string", + "title", + "description", + "format", + "const", + "minLength", + "maxLength", + "pattern", + "extension" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_string_common_options.json" + }, + { + "id": "schema_model_integer_const_format", + "name": "integer model const and format", + "source": "schema_model", + "features": [ + "integer", + "format", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_integer_const_format.json" + }, + { + "id": "schema_model_number_const_format", + "name": "number model const and format", + "source": "schema_model", + "features": [ + "number", + "format", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_number_const_format.json" + }, + { + "id": "schema_model_boolean_const", + "name": "boolean model const", + "source": "schema_model", + "features": [ + "boolean", + "const" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema cannot represent a boolean const value." + }, + { + "id": "schema_model_nullable_default_extensions", + "name": "nullable model default and extensions", + "source": "schema_model", + "features": [ + "string", + "nullable", + "anyOf", + "const", + "default", + "extension" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_nullable_default_extensions.json" + }, + { + "id": "schema_model_array_without_item_schema", + "name": "array model without item schema", + "source": "schema_model", + "features": [ + "array", + "minItems", + "maxItems" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema.array requires an item schema." + }, + { + "id": "schema_model_object_schema_additional_properties", + "name": "object model property count and schema additional properties", + "source": "schema_model", + "features": [ + "object", + "properties", + "required", + "minProperties", + "maxProperties", + "additionalPropertiesSchema" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_object_schema_additional_properties.json" + }, + { + "id": "schema_model_null", + "name": "null model", + "source": "schema_model", + "features": [ + "null", + "title" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema has no null-only schema type." + }, + { + "id": "schema_model_anyof_common_fields_explicit_null", + "name": "anyOf model common fields and explicit null branch", + "source": "schema_model", + "features": [ + "anyOf", + "nullable", + "default", + "extension", + "null" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_anyof_common_fields_explicit_null.json" + }, + { + "id": "schema_model_oneof_nullable_composition", + "name": "oneOf model nullable composition", + "source": "schema_model", + "features": [ + "oneOf", + "nullable", + "const", + "null" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_oneof_nullable_composition.json" + }, + { + "id": "schema_model_oneof_discriminator", + "name": "oneOf model discriminator metadata", + "source": "schema_model", + "features": [ + "oneOf", + "const" + ], + "status": "generated", + "comparison": "adapter_transform_needed", + "fixture": "schema_model_oneof_discriminator.json" + }, + { + "id": "schema_model_allof", + "name": "allOf model", + "source": "schema_model", + "features": [ + "allOf" + ], + "status": "unsupported", + "comparison": "unsupported_by_firebase_schema", + "unsupportedReason": "Firebase Schema has no allOf composition builder." + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_anyof_common_fields_explicit_null.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_anyof_common_fields_explicit_null.json new file mode 100644 index 00000000..ea66fdad --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_anyof_common_fields_explicit_null.json @@ -0,0 +1,12 @@ +{ + "anyOf": [ + { + "type": "STRING", + "nullable": true + }, + { + "type": "INTEGER", + "nullable": true + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_integer_const_format.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_integer_const_format.json new file mode 100644 index 00000000..2a68b7e4 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_integer_const_format.json @@ -0,0 +1,4 @@ +{ + "type": "INTEGER", + "format": "int32" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_nullable_default_extensions.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_nullable_default_extensions.json new file mode 100644 index 00000000..87ebad2e --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_nullable_default_extensions.json @@ -0,0 +1,4 @@ +{ + "type": "STRING", + "nullable": true +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_number_const_format.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_number_const_format.json new file mode 100644 index 00000000..dc87eb16 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_number_const_format.json @@ -0,0 +1,4 @@ +{ + "type": "NUMBER", + "format": "double" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_object_schema_additional_properties.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_object_schema_additional_properties.json new file mode 100644 index 00000000..9eef8183 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_object_schema_additional_properties.json @@ -0,0 +1,14 @@ +{ + "type": "OBJECT", + "properties": { + "id": { + "type": "STRING" + } + }, + "required": [ + "id" + ], + "propertyOrdering": [ + "id" + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_discriminator.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_discriminator.json new file mode 100644 index 00000000..23959eb1 --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_discriminator.json @@ -0,0 +1,43 @@ +{ + "anyOf": [ + { + "type": "OBJECT", + "properties": { + "type": { + "type": "STRING", + "format": "enum", + "enum": [ + "email" + ] + }, + "address": { + "type": "STRING", + "format": "email" + } + }, + "required": [ + "type", + "address" + ] + }, + { + "type": "OBJECT", + "properties": { + "type": { + "type": "STRING", + "format": "enum", + "enum": [ + "sms" + ] + }, + "number": { + "type": "STRING" + } + }, + "required": [ + "type", + "number" + ] + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_nullable_composition.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_nullable_composition.json new file mode 100644 index 00000000..2208d94e --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_oneof_nullable_composition.json @@ -0,0 +1,17 @@ +{ + "anyOf": [ + { + "type": "STRING", + "format": "enum", + "nullable": true, + "enum": [ + "ready" + ] + }, + { + "type": "INTEGER", + "nullable": true, + "minimum": 1.0 + } + ] +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_string_common_options.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_string_common_options.json new file mode 100644 index 00000000..f4560bdf --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_schema/schema_model_string_common_options.json @@ -0,0 +1,6 @@ +{ + "type": "STRING", + "format": "custom-format", + "description": "Current status", + "title": "Status" +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_recursive_lazy_ref.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_recursive_lazy_ref.json new file mode 100644 index 00000000..bb4d73cb --- /dev/null +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_recursive_lazy_ref.json @@ -0,0 +1,58 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/Category" + } + }, + "featured": { + "description": "Featured category", + "allOf": [ + { + "$ref": "#/definitions/Category" + } + ] + } + }, + "required": [ + "name", + "children", + "featured" + ], + "additionalProperties": false, + "definitions": { + "Category": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/Category" + } + }, + "featured": { + "description": "Featured category", + "allOf": [ + { + "$ref": "#/definitions/Category" + } + ] + } + }, + "required": [ + "name", + "children", + "featured" + ], + "additionalProperties": false + } + } +} diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/manifest.json index 5d975684..1ab14e6c 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/manifest.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/manifest.json @@ -1,8 +1,11 @@ { "description": "Golden fixtures for ACK to Firebase AI responseJsonSchema conversion.", "generatedBy": "dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart", - "fixtureCount": 29, + "fixtureCount": 30, "featureCoverage": { + "$ref": [ + "ack_schema_recursive_lazy_ref" + ], "additionalProperties": [ "ack_schema_object_passthrough", "ack_schema_object_properties_requiredness" @@ -11,6 +14,7 @@ "schema_model_object_schema_additional_properties" ], "allOf": [ + "ack_schema_recursive_lazy_ref", "schema_model_allof" ], "any": [ @@ -27,6 +31,7 @@ ], "array": [ "ack_schema_array_constraints", + "ack_schema_recursive_lazy_ref", "schema_model_array_without_item_schema" ], "boolean": [ @@ -53,6 +58,9 @@ "schema_model_anyof_common_fields_explicit_null", "schema_model_nullable_default_extensions" ], + "definitions": [ + "ack_schema_recursive_lazy_ref" + ], "description": [ "schema_model_string_common_options" ], @@ -146,6 +154,7 @@ "object": [ "ack_schema_object_passthrough", "ack_schema_object_properties_requiredness", + "ack_schema_recursive_lazy_ref", "schema_model_object_schema_additional_properties" ], "oneOf": [ @@ -395,6 +404,19 @@ ], "fixture": "ack_schema_discriminated_union.json" }, + { + "id": "ack_schema_recursive_lazy_ref", + "name": "recursive lazy reference", + "source": "ack_schema", + "features": [ + "object", + "array", + "definitions", + "$ref", + "allOf" + ], + "fixture": "ack_schema_recursive_lazy_ref.json" + }, { "id": "schema_model_string_common_options", "name": "string model common and string-only options", diff --git a/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart b/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart new file mode 100644 index 00000000..e66a89d2 --- /dev/null +++ b/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart @@ -0,0 +1,692 @@ +import 'dart:io'; + +// The public firebase_ai library pulls in Flutter-only runtime libraries. +// The fixture generator is a `dart run` tool, so it imports the SDK schema +// definitions directly to snapshot their toJson() output. +// ignore: implementation_imports +import 'package:firebase_ai/src/schema.dart' as firebase_ai; + +import 'firebase_ai_response_json_schema_cases.dart'; + +enum FirebaseAiNativeSchemaFixtureFamily { + schema, + jsonSchema; + + String get fixtureDirectoryName => switch (this) { + schema => 'firebase_ai_native_schema', + jsonSchema => 'firebase_ai_native_json_schema', + }; + + String get sourceClass => switch (this) { + schema => 'Schema', + jsonSchema => 'JSONSchema', + }; +} + +enum FirebaseAiSchemaComparison { + exact('exact'), + equivalent('equivalent'), + adapterTransformNeeded('adapter_transform_needed'), + unsupportedByFirebaseSchema('unsupported_by_firebase_schema'), + backendLimited('backend_limited'); + + const FirebaseAiSchemaComparison(this.jsonValue); + + final String jsonValue; +} + +enum FirebaseAiNativeSchemaStatus { + generated('generated'), + unsupported('unsupported'); + + const FirebaseAiNativeSchemaStatus(this.jsonValue); + + final String jsonValue; +} + +final class FirebaseAiNativeSchemaCase { + const FirebaseAiNativeSchemaCase.generated({ + required this.id, + required this.name, + required this.source, + required this.features, + required this.comparisonEnum, + required Map Function() buildJsonSchema, + }) : statusEnum = FirebaseAiNativeSchemaStatus.generated, + unsupportedReason = null, + _buildJsonSchema = buildJsonSchema; + + const FirebaseAiNativeSchemaCase.unsupported({ + required this.id, + required this.name, + required this.source, + required this.features, + required this.comparisonEnum, + required this.unsupportedReason, + }) : statusEnum = FirebaseAiNativeSchemaStatus.unsupported, + _buildJsonSchema = null; + + final String id; + final String name; + final String source; + final List features; + final FirebaseAiNativeSchemaStatus statusEnum; + final FirebaseAiSchemaComparison comparisonEnum; + final String? unsupportedReason; + final Map Function()? _buildJsonSchema; + + String get status => statusEnum.jsonValue; + + String get comparison => comparisonEnum.jsonValue; + + bool get isGenerated => statusEnum == FirebaseAiNativeSchemaStatus.generated; + + Map buildJsonSchema() { + final build = _buildJsonSchema; + if (build == null) { + throw StateError('Native Firebase schema case $id is unsupported.'); + } + return build(); + } +} + +List firebaseAiNativeSchemaCases( + FirebaseAiNativeSchemaFixtureFamily family, +) { + return [ + for (final schemaCase in firebaseAiResponseJsonSchemaCases()) + switch (family) { + FirebaseAiNativeSchemaFixtureFamily.schema => _nativeSchemaCase( + schemaCase, + ), + FirebaseAiNativeSchemaFixtureFamily.jsonSchema => _nativeJsonSchemaCase( + schemaCase, + ), + }, + ]; +} + +String firebaseAiPackageVersion() { + final lockFile = _findPubspecLock(); + final lines = lockFile.readAsLinesSync(); + for (var index = 0; index < lines.length; index += 1) { + if (lines[index].trim() != 'firebase_ai:') continue; + + for ( + var versionIndex = index + 1; + versionIndex < lines.length; + versionIndex += 1 + ) { + final line = lines[versionIndex]; + if (line.startsWith(' ') && + !line.startsWith(' ') && + line.trim().endsWith(':')) { + break; + } + + final match = RegExp( + r'^\s+version:\s+"?([^"\s]+)"?\s*$', + ).firstMatch(line); + if (match != null) return match.group(1)!; + } + } + + throw StateError('Could not find firebase_ai version in ${lockFile.path}.'); +} + +FirebaseAiNativeSchemaCase _nativeSchemaCase( + FirebaseAiResponseJsonSchemaCase schemaCase, +) { + return switch (schemaCase.id) { + 'ack_schema_string_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(description: 'Code'), + ), + 'ack_schema_string_literal' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.enumString(enumValues: ['ready']), + ), + 'ack_schema_enum_values_default' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.enumString(enumValues: ['admin', 'member']), + ), + 'ack_schema_integer_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.integer(minimum: 1, maximum: 10), + ), + 'ack_schema_number_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.number(minimum: 0.5, maximum: 9.5), + ), + 'ack_schema_nullable_boolean_default' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.boolean(nullable: true), + ), + 'ack_schema_array_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.array( + items: firebase_ai.Schema.string(format: 'uuid'), + minItems: 1, + maxItems: 3, + ), + ), + 'ack_schema_object_properties_requiredness' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.object( + description: 'User payload', + properties: { + 'name': firebase_ai.Schema.string(description: 'Full name'), + 'age': firebase_ai.Schema.integer(minimum: 0, maximum: 120), + 'role': firebase_ai.Schema.enumString( + enumValues: ['admin', 'member'], + ), + 'tags': firebase_ai.Schema.array( + items: firebase_ai.Schema.string(), + minItems: 1, + maxItems: 5, + ), + }, + optionalProperties: ['age', 'tags'], + propertyOrdering: ['name', 'age', 'role', 'tags'], + ), + ), + 'ack_schema_object_passthrough' => _unsupported( + schemaCase, + 'Firebase Schema cannot represent Ack.any() or open additional properties.', + ), + 'ack_schema_anyof_nullable_composition' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.equivalent, + firebase_ai.Schema.anyOf( + schemas: [ + firebase_ai.Schema.string(nullable: true), + firebase_ai.Schema.integer(nullable: true), + ], + ), + ), + 'ack_schema_generic_transform' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(), + ), + 'ack_schema_any_json_compatible_branches' => _unsupported( + schemaCase, + 'Firebase Schema has no unconstrained JSON value representation.', + ), + 'ack_schema_date_transform_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(format: 'date'), + ), + 'ack_schema_datetime_transform' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(format: 'date-time'), + ), + 'ack_schema_uri_transform' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(format: 'uri'), + ), + 'ack_schema_duration_transform_constraints' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.integer(minimum: 1000, maximum: 2000), + ), + 'ack_schema_discriminated_union' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + _nativeSchemaDiscriminatedUnion(), + ), + 'ack_schema_recursive_lazy_ref' => _unsupported( + schemaCase, + 'Firebase Schema does not support reusable definitions or references.', + ), + 'schema_model_string_common_options' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string( + title: 'Status', + description: 'Current status', + format: 'custom-format', + ), + ), + 'schema_model_integer_const_format' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.integer(format: 'int32'), + ), + 'schema_model_number_const_format' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.number(format: 'double'), + ), + 'schema_model_boolean_const' => _unsupported( + schemaCase, + 'Firebase Schema cannot represent a boolean const value.', + ), + 'schema_model_nullable_default_extensions' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.string(nullable: true), + ), + 'schema_model_array_without_item_schema' => _unsupported( + schemaCase, + 'Firebase Schema.array requires an item schema.', + ), + 'schema_model_object_schema_additional_properties' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.object( + properties: {'id': firebase_ai.Schema.string()}, + propertyOrdering: ['id'], + ), + ), + 'schema_model_null' => _unsupported( + schemaCase, + 'Firebase Schema has no null-only schema type.', + ), + 'schema_model_anyof_common_fields_explicit_null' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.anyOf( + schemas: [ + firebase_ai.Schema.string(nullable: true), + firebase_ai.Schema.integer(nullable: true), + ], + ), + ), + 'schema_model_oneof_nullable_composition' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.Schema.anyOf( + schemas: [ + firebase_ai.Schema.enumString(enumValues: ['ready'], nullable: true), + firebase_ai.Schema.integer(minimum: 1, nullable: true), + ], + ), + ), + 'schema_model_oneof_discriminator' => _generatedSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + _nativeSchemaDiscriminatorUnion(), + ), + 'schema_model_allof' => _unsupported( + schemaCase, + 'Firebase Schema has no allOf composition builder.', + ), + _ => _unsupported( + schemaCase, + 'No Firebase Schema fixture mapping has been defined for ${schemaCase.id}.', + ), + }; +} + +FirebaseAiNativeSchemaCase _nativeJsonSchemaCase( + FirebaseAiResponseJsonSchemaCase schemaCase, +) { + return switch (schemaCase.id) { + 'ack_schema_string_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(description: 'Code'), + ), + 'ack_schema_string_literal' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.enumString(enumValues: ['ready']), + ), + 'ack_schema_enum_values_default' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.enumString(enumValues: ['admin', 'member']), + ), + 'ack_schema_integer_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.integer(minimum: 1, maximum: 10), + ), + 'ack_schema_number_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.number(minimum: 0.5, maximum: 9.5), + ), + 'ack_schema_nullable_boolean_default' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.boolean(nullable: true), + ), + 'ack_schema_array_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.array( + items: firebase_ai.JSONSchema.string(format: 'uuid'), + minItems: 1, + maxItems: 3, + ), + ), + 'ack_schema_object_properties_requiredness' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.object( + description: 'User payload', + properties: { + 'name': firebase_ai.JSONSchema.string(description: 'Full name'), + 'age': firebase_ai.JSONSchema.integer(minimum: 0, maximum: 120), + 'role': firebase_ai.JSONSchema.enumString( + enumValues: ['admin', 'member'], + ), + 'tags': firebase_ai.JSONSchema.array( + items: firebase_ai.JSONSchema.string(), + minItems: 1, + maxItems: 5, + ), + }, + optionalProperties: ['age', 'tags'], + ), + ), + 'ack_schema_object_passthrough' => _unsupported( + schemaCase, + 'Firebase JSONSchema cannot represent Ack.any() or open additional properties.', + ), + 'ack_schema_anyof_nullable_composition' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.equivalent, + firebase_ai.JSONSchema.anyOf( + schemas: [ + firebase_ai.JSONSchema.string(nullable: true), + firebase_ai.JSONSchema.integer(nullable: true), + ], + ), + ), + 'ack_schema_generic_transform' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(), + ), + 'ack_schema_any_json_compatible_branches' => _unsupported( + schemaCase, + 'Firebase JSONSchema has no unconstrained JSON value representation.', + ), + 'ack_schema_date_transform_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(format: 'date'), + ), + 'ack_schema_datetime_transform' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(format: 'date-time'), + ), + 'ack_schema_uri_transform' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(format: 'uri'), + ), + 'ack_schema_duration_transform_constraints' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.integer(minimum: 1000, maximum: 2000), + ), + 'ack_schema_discriminated_union' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + _nativeJsonSchemaDiscriminatedUnion(), + ), + 'ack_schema_recursive_lazy_ref' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.backendLimited, + _nativeJsonSchemaRecursiveCategory(), + ), + 'schema_model_string_common_options' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string( + title: 'Status', + description: 'Current status', + format: 'custom-format', + ), + ), + 'schema_model_integer_const_format' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.integer(), + ), + 'schema_model_number_const_format' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.number(), + ), + 'schema_model_boolean_const' => _unsupported( + schemaCase, + 'Firebase JSONSchema cannot represent a boolean const value.', + ), + 'schema_model_nullable_default_extensions' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.string(nullable: true), + ), + 'schema_model_array_without_item_schema' => _unsupported( + schemaCase, + 'Firebase JSONSchema.array requires an item schema.', + ), + 'schema_model_object_schema_additional_properties' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.object( + properties: {'id': firebase_ai.JSONSchema.string()}, + ), + ), + 'schema_model_null' => _unsupported( + schemaCase, + 'Firebase JSONSchema has no null-only schema type.', + ), + 'schema_model_anyof_common_fields_explicit_null' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.anyOf( + schemas: [ + firebase_ai.JSONSchema.string(nullable: true), + firebase_ai.JSONSchema.integer(nullable: true), + ], + ), + ), + 'schema_model_oneof_nullable_composition' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + firebase_ai.JSONSchema.anyOf( + schemas: [ + firebase_ai.JSONSchema.enumString( + enumValues: ['ready'], + nullable: true, + ), + firebase_ai.JSONSchema.integer(minimum: 1, nullable: true), + ], + ), + ), + 'schema_model_oneof_discriminator' => _generatedJsonSchema( + schemaCase, + FirebaseAiSchemaComparison.adapterTransformNeeded, + _nativeJsonSchemaDiscriminatorUnion(), + ), + 'schema_model_allof' => _unsupported( + schemaCase, + 'Firebase JSONSchema has no allOf composition builder.', + ), + _ => _unsupported( + schemaCase, + 'No Firebase JSONSchema fixture mapping has been defined for ${schemaCase.id}.', + ), + }; +} + +FirebaseAiNativeSchemaCase _generatedSchema( + FirebaseAiResponseJsonSchemaCase schemaCase, + FirebaseAiSchemaComparison comparison, + firebase_ai.Schema schema, +) { + return FirebaseAiNativeSchemaCase.generated( + id: schemaCase.id, + name: schemaCase.name, + source: schemaCase.source, + features: schemaCase.features, + comparisonEnum: comparison, + buildJsonSchema: () => _jsonObject(schema.toJson()), + ); +} + +FirebaseAiNativeSchemaCase _generatedJsonSchema( + FirebaseAiResponseJsonSchemaCase schemaCase, + FirebaseAiSchemaComparison comparison, + firebase_ai.JSONSchema schema, +) { + return FirebaseAiNativeSchemaCase.generated( + id: schemaCase.id, + name: schemaCase.name, + source: schemaCase.source, + features: schemaCase.features, + comparisonEnum: comparison, + buildJsonSchema: () => _jsonObject(schema.toJson()), + ); +} + +FirebaseAiNativeSchemaCase _unsupported( + FirebaseAiResponseJsonSchemaCase schemaCase, + String reason, +) { + return FirebaseAiNativeSchemaCase.unsupported( + id: schemaCase.id, + name: schemaCase.name, + source: schemaCase.source, + features: schemaCase.features, + comparisonEnum: FirebaseAiSchemaComparison.unsupportedByFirebaseSchema, + unsupportedReason: reason, + ); +} + +firebase_ai.Schema _nativeSchemaDiscriminatedUnion() { + return firebase_ai.Schema.anyOf( + schemas: [ + firebase_ai.Schema.object( + properties: { + 'type': firebase_ai.Schema.enumString(enumValues: ['circle']), + 'radius': firebase_ai.Schema.number(minimum: 0), + }, + ), + firebase_ai.Schema.object( + properties: { + 'type': firebase_ai.Schema.enumString(enumValues: ['square']), + 'side': firebase_ai.Schema.number(minimum: 0), + }, + ), + ], + ); +} + +firebase_ai.Schema _nativeSchemaDiscriminatorUnion() { + return firebase_ai.Schema.anyOf( + schemas: [ + firebase_ai.Schema.object( + properties: { + 'type': firebase_ai.Schema.enumString(enumValues: ['email']), + 'address': firebase_ai.Schema.string(format: 'email'), + }, + ), + firebase_ai.Schema.object( + properties: { + 'type': firebase_ai.Schema.enumString(enumValues: ['sms']), + 'number': firebase_ai.Schema.string(), + }, + ), + ], + ); +} + +firebase_ai.JSONSchema _nativeJsonSchemaDiscriminatedUnion() { + return firebase_ai.JSONSchema.anyOf( + schemas: [ + firebase_ai.JSONSchema.object( + properties: { + 'type': firebase_ai.JSONSchema.enumString(enumValues: ['circle']), + 'radius': firebase_ai.JSONSchema.number(minimum: 0), + }, + ), + firebase_ai.JSONSchema.object( + properties: { + 'type': firebase_ai.JSONSchema.enumString(enumValues: ['square']), + 'side': firebase_ai.JSONSchema.number(minimum: 0), + }, + ), + ], + ); +} + +firebase_ai.JSONSchema _nativeJsonSchemaDiscriminatorUnion() { + return firebase_ai.JSONSchema.anyOf( + schemas: [ + firebase_ai.JSONSchema.object( + properties: { + 'type': firebase_ai.JSONSchema.enumString(enumValues: ['email']), + 'address': firebase_ai.JSONSchema.string(format: 'email'), + }, + ), + firebase_ai.JSONSchema.object( + properties: { + 'type': firebase_ai.JSONSchema.enumString(enumValues: ['sms']), + 'number': firebase_ai.JSONSchema.string(), + }, + ), + ], + ); +} + +firebase_ai.JSONSchema _nativeJsonSchemaRecursiveCategory() { + firebase_ai.JSONSchema categoryDefinition() => firebase_ai.JSONSchema.object( + properties: { + 'name': firebase_ai.JSONSchema.string(), + 'children': firebase_ai.JSONSchema.array( + items: firebase_ai.JSONSchema.ref(r'#/$defs/Category'), + ), + 'featured': firebase_ai.JSONSchema.ref(r'#/$defs/Category'), + }, + ); + + return firebase_ai.JSONSchema.object( + properties: { + 'name': firebase_ai.JSONSchema.string(), + 'children': firebase_ai.JSONSchema.array( + items: firebase_ai.JSONSchema.ref(r'#/$defs/Category'), + ), + 'featured': firebase_ai.JSONSchema.ref(r'#/$defs/Category'), + }, + defs: {'Category': categoryDefinition()}, + ); +} + +Map _jsonObject(Map value) { + return value.cast(); +} + +File _findPubspecLock() { + var current = Directory.current.absolute; + while (true) { + final lock = File('${current.path}/pubspec.lock'); + if (lock.existsSync()) return lock; + + final parent = current.parent; + if (parent.path == current.path) { + throw StateError( + 'Could not find pubspec.lock from ${Directory.current}.', + ); + } + current = parent; + } +} diff --git a/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart b/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart index e9cd43f7..2edc2bf1 100644 --- a/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart +++ b/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart @@ -240,6 +240,12 @@ List firebaseAiResponseJsonSchemaCases() => [ }, ), ), + AckSchemaResponseJsonSchemaCase( + id: 'ack_schema_recursive_lazy_ref', + name: 'recursive lazy reference', + features: const ['object', 'array', 'definitions', r'$ref', 'allOf'], + schema: _recursiveCategorySchema(), + ), const SchemaModelResponseJsonSchemaCase( id: 'schema_model_string_common_options', name: 'string model common and string-only options', @@ -400,3 +406,18 @@ List firebaseAiResponseJsonSchemaCases() => [ ), ), ]; + +ObjectSchema _recursiveCategorySchema() { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + 'featured': Ack.lazy( + 'Category', + () => categorySchema, + ).describe('Featured category'), + }); + return categorySchema; +} diff --git a/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart b/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart new file mode 100644 index 00000000..56e9480f --- /dev/null +++ b/packages/ack_firebase_ai/test/to_firebase_ai_native_schema_fixture_test.dart @@ -0,0 +1,308 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +import 'support/firebase_ai_native_schema_cases.dart'; +import 'support/firebase_ai_response_json_schema_cases.dart'; + +void main() { + group('Firebase AI native schema fixtures', () { + for (final family in FirebaseAiNativeSchemaFixtureFamily.values) { + group(family.fixtureDirectoryName, () { + final fixtures = _FirebaseAiNativeSchemaFixtures.load(family); + + test('manifest tracks every case and feature', () { + final cases = firebaseAiNativeSchemaCases(family); + final sourceCases = firebaseAiResponseJsonSchemaCases(); + + expect(fixtures.sourcePackage, 'firebase_ai'); + expect(fixtures.sourceVersion, firebaseAiPackageVersion()); + expect(fixtures.sourceClass, family.sourceClass); + expect(fixtures.fixtureCount, cases.length); + expect(fixtures.ids, sourceCases.map((schemaCase) => schemaCase.id)); + + for (final schemaCase in cases) { + final fixture = fixtures.byId(schemaCase.id); + expect(fixture.name, schemaCase.name); + expect(fixture.source, schemaCase.source); + expect(fixture.features, schemaCase.features); + expect(fixture.status, schemaCase.status); + expect(fixture.comparison, schemaCase.comparison); + + if (schemaCase.isGenerated) { + expect(fixture.fixture, '${schemaCase.id}.json'); + expect(fixture.unsupportedReason, isNull); + } else { + expect(fixture.fixture, isNull); + expect(fixture.unsupportedReason, schemaCase.unsupportedReason); + } + } + + expect(fixtures.featureCoverage, _expectedFeatureCoverage(cases)); + }); + + for (final schemaCase in firebaseAiNativeSchemaCases(family)) { + test('${schemaCase.source} ${schemaCase.name}', () { + final fixture = fixtures.byId(schemaCase.id); + + if (schemaCase.isGenerated) { + final nativeSchema = schemaCase.buildJsonSchema(); + expect(fixture.jsonSchema, nativeSchema); + _expectJsonValue(nativeSchema); + expect(() => jsonEncode(nativeSchema), returnsNormally); + } else { + expect(fixture.jsonSchema, isNull); + final fixtureFile = File( + '${fixtures.fixtureDirectory.path}/${schemaCase.id}.json', + ); + expect(fixtureFile.existsSync(), isFalse); + } + }); + } + }); + } + }); +} + +Map> _expectedFeatureCoverage( + List cases, +) { + final expected = >{}; + for (final schemaCase in cases) { + for (final feature in schemaCase.features) { + expected.putIfAbsent(feature, () => []).add(schemaCase.id); + } + } + for (final ids in expected.values) { + ids.sort(); + } + return expected; +} + +void _expectJsonValue(Object? value, [String path = r'$']) { + if (value == null || value is String || value is num || value is bool) { + return; + } + + if (value is List) { + for (var index = 0; index < value.length; index += 1) { + _expectJsonValue(value[index], '$path[$index]'); + } + return; + } + + if (value is Map) { + for (final entry in value.entries) { + expect(entry.key, isA(), reason: '$path keys must be strings'); + _expectJsonValue(entry.value, '$path.${entry.key}'); + } + return; + } + + fail('Expected $path to be JSON-compatible, got ${value.runtimeType}.'); +} + +final class _FirebaseAiNativeSchemaFixtures { + const _FirebaseAiNativeSchemaFixtures({ + required this.sourcePackage, + required this.sourceVersion, + required this.sourceClass, + required this.ids, + required this.fixtureCount, + required this.featureCoverage, + required this.fixtureDirectory, + required Map fixtures, + }) : _fixtures = fixtures; + + final String sourcePackage; + final String sourceVersion; + final String sourceClass; + final List ids; + final int fixtureCount; + final Map> featureCoverage; + final Directory fixtureDirectory; + final Map _fixtures; + + _FirebaseAiNativeSchemaFixture byId(String id) { + final fixture = _fixtures[id]; + if (fixture == null) { + fail('Missing native Firebase AI fixture metadata for $id.'); + } + return fixture; + } + + static _FirebaseAiNativeSchemaFixtures load( + FirebaseAiNativeSchemaFixtureFamily family, + ) { + final packageRoot = _findPackageRoot(); + final fixtureDir = Directory( + '${packageRoot.path}/test/fixtures/${family.fixtureDirectoryName}', + ); + final manifestFile = File('${fixtureDir.path}/manifest.json'); + if (!manifestFile.existsSync()) { + fail( + 'Missing Firebase AI native fixture manifest. Run ' + 'dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart ' + 'from packages/ack_firebase_ai.', + ); + } + + final manifestJson = _decodeJsonObject(manifestFile); + final manifestFixtures = _jsonList(manifestJson['fixtures'], 'fixtures'); + final fixtures = {}; + final ids = []; + + for (final entry in manifestFixtures) { + final manifestFixture = _jsonObject(entry, 'fixtures[]'); + final id = _jsonString(manifestFixture['id'], 'fixtures[].id'); + final fixtureName = _jsonOptionalString( + manifestFixture['fixture'], + 'fixtures[].fixture', + ); + final fixtureFile = fixtureName == null + ? null + : File('${fixtureDir.path}/$fixtureName'); + + ids.add(id); + fixtures[id] = _FirebaseAiNativeSchemaFixture( + id: id, + name: _jsonString(manifestFixture['name'], 'fixtures[].name'), + source: _jsonString(manifestFixture['source'], 'fixtures[].source'), + features: _jsonStringList( + manifestFixture['features'], + 'fixtures[].features', + ), + status: _jsonString(manifestFixture['status'], 'fixtures[].status'), + comparison: _jsonString( + manifestFixture['comparison'], + 'fixtures[].comparison', + ), + unsupportedReason: _jsonOptionalString( + manifestFixture['unsupportedReason'], + 'fixtures[].unsupportedReason', + ), + fixture: fixtureName, + jsonSchema: fixtureFile == null ? null : _decodeJsonObject(fixtureFile), + ); + } + + return _FirebaseAiNativeSchemaFixtures( + sourcePackage: _jsonString( + manifestJson['sourcePackage'], + 'sourcePackage', + ), + sourceVersion: _jsonString( + manifestJson['sourceVersion'], + 'sourceVersion', + ), + sourceClass: _jsonString(manifestJson['sourceClass'], 'sourceClass'), + ids: ids, + fixtureCount: _jsonInt(manifestJson['fixtureCount'], 'fixtureCount'), + featureCoverage: _featureCoverageFromManifest(manifestJson), + fixtureDirectory: fixtureDir, + fixtures: fixtures, + ); + } +} + +final class _FirebaseAiNativeSchemaFixture { + const _FirebaseAiNativeSchemaFixture({ + required this.id, + required this.name, + required this.source, + required this.features, + required this.status, + required this.comparison, + required this.unsupportedReason, + required this.fixture, + required this.jsonSchema, + }); + + final String id; + final String name; + final String source; + final List features; + final String status; + final String comparison; + final String? unsupportedReason; + final String? fixture; + final Map? jsonSchema; +} + +Directory _findPackageRoot() { + var current = Directory.current.absolute; + while (true) { + final pubspec = File('${current.path}/pubspec.yaml'); + if (pubspec.existsSync() && + pubspec.readAsStringSync().contains('name: ack_firebase_ai')) { + return current; + } + + final nested = Directory('${current.path}/packages/ack_firebase_ai'); + final nestedPubspec = File('${nested.path}/pubspec.yaml'); + if (nestedPubspec.existsSync() && + nestedPubspec.readAsStringSync().contains('name: ack_firebase_ai')) { + return nested; + } + + final parent = current.parent; + if (parent.path == current.path) { + fail( + 'Could not find packages/ack_firebase_ai from ${Directory.current}.', + ); + } + current = parent; + } +} + +Map> _featureCoverageFromManifest( + Map manifestJson, +) { + final featureCoverage = _jsonObject( + manifestJson['featureCoverage'], + 'featureCoverage', + ); + return { + for (final entry in featureCoverage.entries) + entry.key: _jsonStringList(entry.value, 'featureCoverage.${entry.key}'), + }; +} + +Map _decodeJsonObject(File file) { + if (!file.existsSync()) { + fail('Missing fixture file: ${file.path}'); + } + return _jsonObject(jsonDecode(file.readAsStringSync()), file.path); +} + +Map _jsonObject(Object? value, String path) { + if (value is Map) return value; + if (value is Map) return value.cast(); + fail('Expected $path to be a JSON object, got ${value.runtimeType}.'); +} + +List _jsonList(Object? value, String path) { + if (value is List) return value; + if (value is List) return value.cast(); + fail('Expected $path to be a JSON array, got ${value.runtimeType}.'); +} + +String _jsonString(Object? value, String path) { + if (value is String) return value; + fail('Expected $path to be a string, got ${value.runtimeType}.'); +} + +String? _jsonOptionalString(Object? value, String path) { + if (value == null) return null; + return _jsonString(value, path); +} + +int _jsonInt(Object? value, String path) { + if (value is int) return value; + fail('Expected $path to be an integer, got ${value.runtimeType}.'); +} + +List _jsonStringList(Object? value, String path) { + return [for (final item in _jsonList(value, path)) _jsonString(item, path)]; +} diff --git a/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart b/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart index 3d386691..05fd11ce 100644 --- a/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart +++ b/packages/ack_firebase_ai/tool/generate_firebase_ai_response_json_schema_fixtures.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import '../test/support/firebase_ai_native_schema_cases.dart'; import '../test/support/firebase_ai_response_json_schema_cases.dart'; const _fixtureDirectory = 'test/fixtures/firebase_ai_response_json_schema'; @@ -8,7 +9,14 @@ const _manifestPath = '$_fixtureDirectory/manifest.json'; const _generatorCommand = 'dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart'; -Future main() async { +void main() { + _writeAckAdapterFixtures(); + for (final family in FirebaseAiNativeSchemaFixtureFamily.values) { + _writeNativeFixtures(family); + } +} + +void _writeAckAdapterFixtures() { final fixtureDir = Directory(_fixtureDirectory); if (!fixtureDir.existsSync()) { fixtureDir.createSync(recursive: true); @@ -60,6 +68,71 @@ Future main() async { ); } +void _writeNativeFixtures(FirebaseAiNativeSchemaFixtureFamily family) { + final fixtureDirectory = 'test/fixtures/${family.fixtureDirectoryName}'; + final manifestPath = '$fixtureDirectory/manifest.json'; + final fixtureDir = Directory(fixtureDirectory); + if (!fixtureDir.existsSync()) { + fixtureDir.createSync(recursive: true); + } + + _deleteStaleFixtures(fixtureDir); + + final cases = firebaseAiNativeSchemaCases(family); + final featureCoverage = >{}; + final manifestFixtures = >[]; + + for (final schemaCase in cases) { + String? fixtureName; + if (schemaCase.isGenerated) { + fixtureName = '${schemaCase.id}.json'; + final fixturePath = '$fixtureDirectory/$fixtureName'; + File( + fixturePath, + ).writeAsStringSync('${_prettyJson(schemaCase.buildJsonSchema())}\n'); + } + + manifestFixtures.add({ + 'id': schemaCase.id, + 'name': schemaCase.name, + 'source': schemaCase.source, + 'features': schemaCase.features, + 'status': schemaCase.status, + 'comparison': schemaCase.comparison, + if (fixtureName != null) 'fixture': fixtureName, + if (schemaCase.unsupportedReason != null) + 'unsupportedReason': schemaCase.unsupportedReason, + }); + + for (final feature in schemaCase.features) { + featureCoverage.putIfAbsent(feature, () => []).add(schemaCase.id); + } + } + + final manifest = { + 'description': + 'Firebase AI ${family.sourceClass}.toJson() fixtures for native SDK ' + 'schema comparison.', + 'generatedBy': _generatorCommand, + 'sourcePackage': 'firebase_ai', + 'sourceVersion': firebaseAiPackageVersion(), + 'sourceClass': family.sourceClass, + 'fixtureCount': cases.length, + 'featureCoverage': { + for (final feature in featureCoverage.keys.toList()..sort()) + feature: featureCoverage[feature]!..sort(), + }, + 'fixtures': manifestFixtures, + }; + + File(manifestPath).writeAsStringSync('${_prettyJson(manifest)}\n'); + + stdout.writeln( + 'Wrote ${cases.length} Firebase AI native ${family.sourceClass} fixture ' + 'records to $fixtureDirectory.', + ); +} + void _deleteStaleFixtures(Directory fixtureDir) { for (final entry in fixtureDir.listSync()) { if (entry is File && entry.path.endsWith('.json')) { diff --git a/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart b/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart index 9218dcfa..4f019276 100644 --- a/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart +++ b/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart @@ -150,6 +150,36 @@ void main() { final result = schema.toJsonSchemaBuilder(); expect(result, isNotNull); }); + + test('preserves recursive lazy refs and metadata refs', () { + late final ObjectSchema categorySchema; + categorySchema = Ack.object({ + 'name': Ack.string(), + 'children': Ack.list( + Ack.lazy('Category', () => categorySchema), + ), + 'featured': Ack.lazy( + 'Category', + () => categorySchema, + ).describe('Featured category'), + }); + + final result = categorySchema.toJsonSchemaBuilder(); + final definitions = result.value['definitions'] as Map; + final properties = result.value['properties'] as Map; + final children = _schemaFrom(properties['children']); + final childItems = _schemaFrom(children.value['items']); + final featured = _schemaFrom(properties['featured']); + final featuredAllOf = (featured.value['allOf'] as List) + .map(_schemaFrom) + .toList(growable: false); + + expect(definitions.keys, contains('Category')); + expect(childItems.value[r'$ref'], '#/definitions/Category'); + expect(featured.value['description'], 'Featured category'); + expect(featured.value, isNot(contains(r'$ref'))); + expect(featuredAllOf.single.value[r'$ref'], '#/definitions/Category'); + }); }); group('Metadata preservation', () { diff --git a/pubspec.yaml b/pubspec.yaml index a7851dbf..b25e295c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -147,7 +147,7 @@ melos: firebase-ai-fixtures: run: cd packages/ack_firebase_ai && dart run tool/generate_firebase_ai_response_json_schema_fixtures.dart - description: Regenerate Firebase AI responseJsonSchema golden fixtures + description: Regenerate Firebase AI adapter and native schema fixtures test:golden: run: melos exec -c 1 -- dart test test/golden_test.dart