From 0b24f5ac2240b43b3a7057e0d1684a278349684d Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Mon, 24 Nov 2025 18:11:47 -0800 Subject: [PATCH 01/10] Implement TODOs and add tests --- .../Types/Public/Generable/ModelOutput.swift | 26 +- .../Unit/Types/Generable/GenerableTests.swift | 267 ++++++++++++++++-- 2 files changed, 258 insertions(+), 35 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 36d3626dde5..25f2cd86cb6 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -146,7 +146,7 @@ public struct ModelOutput: Sendable, Generable { /// Reads a top level, concrete partially `Generable` type from a named property. public func value(_ type: Value.Type = Value.self) throws -> Value where Value: ConvertibleFromModelOutput { - fatalError("`ModelOutput.value(_:)` is not implemented.") + return try Value(self) } /// Reads a concrete `Generable` type from named property. @@ -154,12 +154,10 @@ public struct ModelOutput: Sendable, Generable { forProperty property: String) throws -> Value where Value: ConvertibleFromModelOutput { guard case let .structure(properties, _) = kind else { - // TODO: Throw an error instead - fatalError("Attempting to access a property on a non-object ModelOutput.") + throw DecodingError.notAStructure } guard let value = properties[property] else { - // TODO: Throw an error instead - fatalError("Property '\(property)' not found in model output.") + throw DecodingError.missingProperty(name: property) } return try Value(value) @@ -170,8 +168,7 @@ public struct ModelOutput: Sendable, Generable { forProperty property: String) throws -> Value? where Value: ConvertibleFromModelOutput { guard case let .structure(properties, _) = kind else { - // TODO: Throw an error instead - fatalError("Attempting to access a property on a non-object ModelOutput.") + throw DecodingError.notAStructure } guard let value = properties[property] else { return nil @@ -181,6 +178,21 @@ public struct ModelOutput: Sendable, Generable { } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public extension ModelOutput { + /// An error that occurs when decoding a value from `ModelOutput`. + enum DecodingError: Error { + /// A required property was not found in the `ModelOutput`. + case missingProperty(name: String) + + /// A property was accessed on a `ModelOutput` that is not a structure. + case notAStructure + + /// The type of a property in the `ModelOutput` did not match the expected type. + case typeMismatch(expected: Any.Type, actual: Any.Type) + } +} + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public extension ModelOutput { /// A representation of the different types of content that can be stored in `ModelOutput`. diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index 365403709ea..f72cc988044 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -12,14 +12,83 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAILogic +@testable import FirebaseAILogic import Testing struct GenerableTests { - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - @Test func initializeGenerableTypeFromModelOutput() throws { + @Test + func initializeGenerableTypeFromModelOutput() throws { + let addressProperties: [(String, any ConvertibleToModelOutput)] = + [("street", "123 Main St"), ("city", "Anytown"), ("zipCode", "12345")] + let addressModelOutput = ModelOutput( + properties: addressProperties, uniquingKeysWith: { _, second in second } + ) + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", 40), ("address", addressModelOutput)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + let person = try Person(modelOutput) + + #expect(person.firstName == "John") + #expect(person.lastName == "Doe") + #expect(person.age == 40) + #expect(person.address.street == "123 Main St") + #expect(person.address.city == "Anytown") + #expect(person.address.zipCode == "12345") + } + + @Test + func initializeGenerableWithMissingProperty() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("age", 40)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + #expect(throws: ModelOutput.DecodingError.self) { + _ = try Person(modelOutput) + } + } + + @Test + func initializeGenerableFromNonStructure() throws { + let modelOutput = ModelOutput("not a structure") + + #expect(throws: ModelOutput.DecodingError.self) { + _ = try Person(modelOutput) + } + } + + @Test + func initializeGenerableWithTypeMismatch() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", "forty")] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + #expect(throws: ModelOutput.DecodingError.self) { + _ = try Person(modelOutput) + } + } + + @Test + func initializeGenerableWithExtraProperties() throws { + let addressProperties: [(String, any ConvertibleToModelOutput)] = + [("street", "123 Main St"), ("city", "Anytown"), ("zipCode", "12345")] + let addressModelOutput = ModelOutput( + properties: addressProperties, uniquingKeysWith: { _, second in second } + ) let properties: [(String, any ConvertibleToModelOutput)] = - [("firstName", "John"), ("lastName", "Doe"), ("age", 40)] + [ + ("firstName", "John"), + ("lastName", "Doe"), + ("age", 40), + ("address", addressModelOutput), + ("country", "USA"), + ] let modelOutput = ModelOutput( properties: properties, uniquingKeysWith: { _, second in second } ) @@ -29,11 +98,45 @@ struct GenerableTests { #expect(person.firstName == "John") #expect(person.lastName == "Doe") #expect(person.age == 40) + #expect(person.address.street == "123 Main St") + #expect(person.address.city == "Anytown") + #expect(person.address.zipCode == "12345") } - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - @Test func convertGenerableTypeToModelOutput() throws { - let person = Person(firstName: "Jane", middleName: "Marie", lastName: "Smith", age: 32) + @Test + func initializeGenerableWithMissingOptionalProperty() throws { + let addressProperties: [(String, any ConvertibleToModelOutput)] = + [("street", "123 Main St"), ("city", "Anytown"), ("zipCode", "12345")] + let addressModelOutput = ModelOutput( + properties: addressProperties, uniquingKeysWith: { _, second in second } + ) + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", 40), ("address", addressModelOutput)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + let person = try Person(modelOutput) + + #expect(person.firstName == "John") + #expect(person.lastName == "Doe") + #expect(person.age == 40) + #expect(person.middleName == nil) + #expect(person.address.street == "123 Main St") + #expect(person.address.city == "Anytown") + #expect(person.address.zipCode == "12345") + } + + @Test + func convertGenerableTypeToModelOutput() throws { + let address = Address(street: "456 Oak Ave", city: "Someplace", zipCode: "54321") + let person = Person( + firstName: "Jane", + middleName: "Marie", + lastName: "Smith", + age: 32, + address: address + ) let modelOutput = person.modelOutput @@ -69,9 +172,82 @@ struct GenerableTests { } #expect(Int(age) == person.age) #expect(try modelOutput.value(forProperty: "age") == person.age) - // TODO: Implement `ModelOutput.value(_:)` and uncomment - // #expect(try modelOutput.value() == person) - #expect(orderedKeys == ["firstName", "middleName", "lastName", "age"]) + let addressProperty: Address = try modelOutput.value(forProperty: "address") + #expect(addressProperty == person.address) + #expect(try modelOutput.value() == person) + #expect(orderedKeys == ["firstName", "middleName", "lastName", "age", "address"]) + } + + @Test + func convertGenerableWithNilOptionalPropertyToModelOutput() throws { + let address = Address(street: "789 Pine Ln", city: "Nowhere", zipCode: "00000") + let person = Person( + firstName: "Jane", + middleName: nil, + lastName: "Smith", + age: 32, + address: address + ) + + let modelOutput = person.modelOutput + + guard case let .structure(properties, orderedKeys) = modelOutput.kind else { + Issue.record("Model output is not a structure.") + return + } + + #expect(properties["middleName"] == nil) + #expect(orderedKeys == ["firstName", "lastName", "age", "address"]) + } + + @Test + func initializeWithNestedGenerable() throws { + let addressProperties: [(String, any ConvertibleToModelOutput)] = + [("street", "123 Main St"), ("city", "Anytown"), ("zipCode", "12345")] + let addressModelOutput = ModelOutput( + properties: addressProperties, uniquingKeysWith: { _, second in second } + ) + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", 40), ("address", addressModelOutput)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + let person = try Person(modelOutput) + + #expect(person.address.street == "123 Main St") + #expect(person.address.city == "Anytown") + #expect(person.address.zipCode == "12345") + } + + @Test + func testPersonJSONSchema() throws { + let schema = Person.jsonSchema + guard case let .object(_, _, properties) = schema.kind else { + Issue.record("Schema kind is not an object.") + return + } + #expect(properties.count == 5) + + let firstName = try #require(properties.first { $0.name == "firstName" }) + #expect(ObjectIdentifier(firstName.type) == ObjectIdentifier(String.self)) + #expect(firstName.isOptional == false) + + let middleName = try #require(properties.first { $0.name == "middleName" }) + #expect(ObjectIdentifier(middleName.type) == ObjectIdentifier(String.self)) + #expect(middleName.isOptional == true) + + let lastName = try #require(properties.first { $0.name == "lastName" }) + #expect(ObjectIdentifier(lastName.type) == ObjectIdentifier(String.self)) + #expect(lastName.isOptional == false) + + let age = try #require(properties.first { $0.name == "age" }) + #expect(ObjectIdentifier(age.type) == ObjectIdentifier(Int.self)) + #expect(age.isOptional == false) + + let address = try #require(properties.first { $0.name == "address" }) + #expect(ObjectIdentifier(address.type) == ObjectIdentifier(Address.self)) + #expect(address.isOptional == false) } } @@ -82,6 +258,7 @@ struct Person: Equatable { let middleName: String? let lastName: String let age: Int + let address: Address nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { FirebaseAILogic.JSONSchema( @@ -91,6 +268,7 @@ struct Person: Equatable { FirebaseAILogic.JSONSchema.Property(name: "middleName", type: String?.self), FirebaseAILogic.JSONSchema.Property(name: "lastName", type: String.self), FirebaseAILogic.JSONSchema.Property(name: "age", type: Int.self), + FirebaseAILogic.JSONSchema.Property(name: "address", type: Address.self), ] ) } @@ -101,6 +279,7 @@ struct Person: Equatable { addProperty(name: "middleName", value: middleName) addProperty(name: "lastName", value: lastName) addProperty(name: "age", value: age) + addProperty(name: "address", value: address) return ModelOutput( properties: properties, uniquingKeysWith: { _, second in @@ -118,24 +297,56 @@ struct Person: Equatable { } } -#if compiler(>=6.2) - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - extension Person: nonisolated FirebaseAILogic.Generable { - nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { - firstName = try content.value(forProperty: "firstName") - middleName = try content.value(forProperty: "middleName") - lastName = try content.value(forProperty: "lastName") - age = try content.value(forProperty: "age") - } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension Person: nonisolated FirebaseAILogic.Generable { + nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { + firstName = try content.value(forProperty: "firstName") + middleName = try content.value(forProperty: "middleName") + lastName = try content.value(forProperty: "lastName") + age = try content.value(forProperty: "age") + address = try content.value(forProperty: "address") } -#else - @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) - extension Person: FirebaseAILogic.Generable { - nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { - firstName = try content.value(forProperty: "firstName") - middleName = try content.value(forProperty: "middleName") - lastName = try content.value(forProperty: "lastName") - age = try content.value(forProperty: "age") +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct Address: Equatable { + let street: String + let city: String + let zipCode: String +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +extension Address: nonisolated FirebaseAILogic.Generable { + nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "street", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "city", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "zipCode", type: String.self), + ] + ) + } + + nonisolated var modelOutput: FirebaseAILogic.ModelOutput { + var properties = [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)]() + addProperty(name: "street", value: street) + addProperty(name: "city", value: city) + addProperty(name: "zipCode", value: zipCode) + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) + func addProperty(name: String, value: some FirebaseAILogic.Generable) { + properties.append((name, value)) } } -#endif + + nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { + street = try content.value(forProperty: "street") + city = try content.value(forProperty: "city") + zipCode = try content.value(forProperty: "zipCode") + } +} From a6d4a83862aa412ecc555e2731b1ab27348e0964 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Mon, 24 Nov 2025 18:50:04 -0800 Subject: [PATCH 02/10] review --- .../Types/Public/Generable/ModelOutput.swift | 3 -- .../Unit/Types/Generable/GenerableTests.swift | 54 +++++-------------- 2 files changed, 13 insertions(+), 44 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 25f2cd86cb6..f48e2f82491 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -191,10 +191,7 @@ public extension ModelOutput { /// The type of a property in the `ModelOutput` did not match the expected type. case typeMismatch(expected: Any.Type, actual: Any.Type) } -} -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -public extension ModelOutput { /// A representation of the different types of content that can be stored in `ModelOutput`. /// /// `Kind` represents the various types of JSON-compatible data that can be held within a diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index f72cc988044..e85ca355e06 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -200,26 +200,6 @@ struct GenerableTests { #expect(orderedKeys == ["firstName", "lastName", "age", "address"]) } - @Test - func initializeWithNestedGenerable() throws { - let addressProperties: [(String, any ConvertibleToModelOutput)] = - [("street", "123 Main St"), ("city", "Anytown"), ("zipCode", "12345")] - let addressModelOutput = ModelOutput( - properties: addressProperties, uniquingKeysWith: { _, second in second } - ) - let properties: [(String, any ConvertibleToModelOutput)] = - [("firstName", "John"), ("lastName", "Doe"), ("age", 40), ("address", addressModelOutput)] - let modelOutput = ModelOutput( - properties: properties, uniquingKeysWith: { _, second in second } - ) - - let person = try Person(modelOutput) - - #expect(person.address.street == "123 Main St") - #expect(person.address.city == "Anytown") - #expect(person.address.zipCode == "12345") - } - @Test func testPersonJSONSchema() throws { let schema = Person.jsonSchema @@ -274,26 +254,20 @@ struct Person: Equatable { } nonisolated var modelOutput: FirebaseAILogic.ModelOutput { - var properties = [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)]() - addProperty(name: "firstName", value: firstName) - addProperty(name: "middleName", value: middleName) - addProperty(name: "lastName", value: lastName) - addProperty(name: "age", value: age) - addProperty(name: "address", value: address) + var properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [] + properties.append(("firstName", firstName)) + if let middleName { + properties.append(("middleName", middleName)) + } + properties.append(("lastName", lastName)) + properties.append(("age", age)) + properties.append(("address", address)) return ModelOutput( properties: properties, uniquingKeysWith: { _, second in second } ) - func addProperty(name: String, value: some FirebaseAILogic.Generable) { - properties.append((name, value)) - } - func addProperty(name: String, value: (some FirebaseAILogic.Generable)?) { - if let value { - properties.append((name, value)) - } - } } } @@ -329,19 +303,17 @@ extension Address: nonisolated FirebaseAILogic.Generable { } nonisolated var modelOutput: FirebaseAILogic.ModelOutput { - var properties = [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)]() - addProperty(name: "street", value: street) - addProperty(name: "city", value: city) - addProperty(name: "zipCode", value: zipCode) + let properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [ + ("street", street), + ("city", city), + ("zipCode", zipCode), + ] return ModelOutput( properties: properties, uniquingKeysWith: { _, second in second } ) - func addProperty(name: String, value: some FirebaseAILogic.Generable) { - properties.append((name, value)) - } } nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { From ef9f72c1cc1e1d8dff97a1edfe0392c71078e204 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Tue, 25 Nov 2025 09:29:06 -0800 Subject: [PATCH 03/10] error context --- .../Types/Public/Generable/ModelOutput.swift | 51 +++++++++++++++++-- .../Unit/Types/Generable/GenerableTests.swift | 21 ++++++-- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index f48e2f82491..ac249ede6b9 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -16,7 +16,7 @@ /// /// Model output may contain a single value, an array, or key-value pairs with unique keys. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -public struct ModelOutput: Sendable, Generable { +public struct ModelOutput: Sendable, Generable, CustomDebugStringConvertible { /// The kind representation of this model output. /// /// This property provides access to the content in a strongly-typed enum representation, @@ -70,6 +70,10 @@ public struct ModelOutput: Sendable, Generable { /// A representation of this instance. public var modelOutput: ModelOutput { self } + public var debugDescription: String { + return kind.debugDescription + } + /// Creates model output representing a structure with the properties you specify. /// /// The order of properties is important. For ``Generable`` types, the order must match the order @@ -181,7 +185,7 @@ public struct ModelOutput: Sendable, Generable { @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public extension ModelOutput { /// An error that occurs when decoding a value from `ModelOutput`. - enum DecodingError: Error { + enum DecodingError: Error, CustomDebugStringConvertible { /// A required property was not found in the `ModelOutput`. case missingProperty(name: String) @@ -189,14 +193,31 @@ public extension ModelOutput { case notAStructure /// The type of a property in the `ModelOutput` did not match the expected type. - case typeMismatch(expected: Any.Type, actual: Any.Type) + case typeMismatch(context: Context) + + /// The context for a decoding error. + public struct Context { + /// A description of the error. + public let debugDescription: String + } + + public var debugDescription: String { + switch self { + case let .missingProperty(name): + return "Missing property: \(name)" + case .notAStructure: + return "Not a structure" + case let .typeMismatch(context): + return context.debugDescription + } + } } /// A representation of the different types of content that can be stored in `ModelOutput`. /// /// `Kind` represents the various types of JSON-compatible data that can be held within a /// ``ModelOutput`` instance, including primitive types, arrays, and structured objects. - enum Kind: Sendable { + enum Kind: Sendable, CustomDebugStringConvertible { /// Represents a null value. case null @@ -221,5 +242,27 @@ public extension ModelOutput { /// - properties: A dictionary mapping string keys to ``ModelOutput`` values. /// - orderedKeys: An array of keys that specifies the order of properties. case structure(properties: [String: ModelOutput], orderedKeys: [String]) + + public var debugDescription: String { + switch self { + case .null: + return "null" + case let .bool(value): + return String(describing: value) + case let .number(value): + return String(describing: value) + case let .string(value): + return #""\#(value)""# + case let .array(elements): + let descriptions = elements.map { $0.debugDescription } + return "[\(descriptions.joined(separator: ", "))]" + case let .structure(properties, orderedKeys): + let descriptions = orderedKeys.compactMap { key -> String? in + guard let value = properties[key] else { return nil } + return #""\#(key)": \#(value.debugDescription)"# + } + return "{\(descriptions.joined(separator: ", "))}" + } + } } } diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index e85ca355e06..e3960688e20 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -47,8 +47,13 @@ struct GenerableTests { properties: properties, uniquingKeysWith: { _, second in second } ) - #expect(throws: ModelOutput.DecodingError.self) { + do { _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch let ModelOutput.DecodingError.missingProperty(name) { + #expect(name == "lastName") + } catch { + Issue.record("Threw an unexpected error: \(error)") } } @@ -56,8 +61,13 @@ struct GenerableTests { func initializeGenerableFromNonStructure() throws { let modelOutput = ModelOutput("not a structure") - #expect(throws: ModelOutput.DecodingError.self) { + do { _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch ModelOutput.DecodingError.notAStructure { + // Expected error + } catch { + Issue.record("Threw an unexpected error: \(error)") } } @@ -69,8 +79,13 @@ struct GenerableTests { properties: properties, uniquingKeysWith: { _, second in second } ) - #expect(throws: ModelOutput.DecodingError.self) { + do { _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch let ModelOutput.DecodingError.typeMismatch(context) { + #expect(context.debugDescription.contains("ModelOutput does not contain Int.")) + } catch { + Issue.record("Threw an unexpected error: \(error)") } } From 9b2d5e04b51b02233ab799d320d615911e421ef4 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Tue, 25 Nov 2025 09:37:10 -0800 Subject: [PATCH 04/10] data corrupted errors --- .../Types/Public/Generable/ModelOutput.swift | 5 +++++ .../Unit/Types/Generable/GenerableTests.swift | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index ac249ede6b9..153407c8eb6 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -195,6 +195,9 @@ public extension ModelOutput { /// The type of a property in the `ModelOutput` did not match the expected type. case typeMismatch(context: Context) + /// The data is corrupted or invalid. + case dataCorrupted(context: Context) + /// The context for a decoding error. public struct Context { /// A description of the error. @@ -209,6 +212,8 @@ public extension ModelOutput { return "Not a structure" case let .typeMismatch(context): return context.debugDescription + case let .dataCorrupted(context): + return context.debugDescription } } } diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index e3960688e20..dd83cbd474b 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -89,6 +89,24 @@ struct GenerableTests { } } + @Test + func initializeGenerableWithLossyNumericConversion() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", 40.5)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + do { + _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch let ModelOutput.DecodingError.dataCorrupted(context) { + #expect(context.debugDescription.contains("ModelOutput cannot be represented as Int.")) + } catch { + Issue.record("Threw an unexpected error: \(error)") + } + } + @Test func initializeGenerableWithExtraProperties() throws { let addressProperties: [(String, any ConvertibleToModelOutput)] = From 46fd850a44d9419d08b5be7f96b47b6168af0afd Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Tue, 25 Nov 2025 11:45:59 -0800 Subject: [PATCH 05/10] Fix tests after rebase --- FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index dd83cbd474b..8c633fbf321 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -82,8 +82,9 @@ struct GenerableTests { do { _ = try Person(modelOutput) Issue.record("Did not throw an error.") - } catch let ModelOutput.DecodingError.typeMismatch(context) { - #expect(context.debugDescription.contains("ModelOutput does not contain Int.")) + } catch let GenerativeModel.GenerationError.decodingFailure(context) { + print(context.debugDescription) + #expect(context.debugDescription.contains("\"forty\" does not contain Int")) } catch { Issue.record("Threw an unexpected error: \(error)") } From 2c30bc1f1304f24c5e65b4af378846e7f2831e59 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Tue, 25 Nov 2025 12:16:23 -0800 Subject: [PATCH 06/10] remove log --- FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index 8c633fbf321..7c83bb55930 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -83,7 +83,6 @@ struct GenerableTests { _ = try Person(modelOutput) Issue.record("Did not throw an error.") } catch let GenerativeModel.GenerationError.decodingFailure(context) { - print(context.debugDescription) #expect(context.debugDescription.contains("\"forty\" does not contain Int")) } catch { Issue.record("Threw an unexpected error: \(error)") From 390840a62326fd9882d07f7242178eff735ba40c Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Tue, 25 Nov 2025 13:23:04 -0800 Subject: [PATCH 07/10] Fix Xcode 16.4 build error --- FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 153407c8eb6..5c3c54f6517 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -199,7 +199,7 @@ public extension ModelOutput { case dataCorrupted(context: Context) /// The context for a decoding error. - public struct Context { + public struct Context: Sendable { /// A description of the error. public let debugDescription: String } From 9a4f2ff04f46cf19232ac7333476e7fd77850fa3 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Tue, 25 Nov 2025 14:44:21 -0800 Subject: [PATCH 08/10] cleanup --- FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 5c3c54f6517..5bedc091028 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -192,9 +192,6 @@ public extension ModelOutput { /// A property was accessed on a `ModelOutput` that is not a structure. case notAStructure - /// The type of a property in the `ModelOutput` did not match the expected type. - case typeMismatch(context: Context) - /// The data is corrupted or invalid. case dataCorrupted(context: Context) @@ -210,8 +207,6 @@ public extension ModelOutput { return "Missing property: \(name)" case .notAStructure: return "Not a structure" - case let .typeMismatch(context): - return context.debugDescription case let .dataCorrupted(context): return context.debugDescription } From 609f11a0990d08f7d805dbe46edfb68aadfff99e Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Tue, 25 Nov 2025 14:52:35 -0800 Subject: [PATCH 09/10] Xcode 16 fixes --- .../Unit/Types/Generable/GenerableTests.swift | 231 ++++++++++++------ 1 file changed, 160 insertions(+), 71 deletions(-) diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index 7c83bb55930..be3b3e6c985 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -264,56 +264,109 @@ struct GenerableTests { } } -// An example of the expected output from the `@FirebaseAILogic.Generable` macro. -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -struct Person: Equatable { - let firstName: String - let middleName: String? - let lastName: String - let age: Int - let address: Address - - nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { - FirebaseAILogic.JSONSchema( - type: Self.self, - properties: [ - FirebaseAILogic.JSONSchema.Property(name: "firstName", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "middleName", type: String?.self), - FirebaseAILogic.JSONSchema.Property(name: "lastName", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "age", type: Int.self), - FirebaseAILogic.JSONSchema.Property(name: "address", type: Address.self), - ] - ) +#if compiler(>=6.2) + // An example of the expected output from the `@FirebaseAILogic.Generable` macro. + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + struct Person: Equatable { + let firstName: String + let middleName: String? + let lastName: String + let age: Int + let address: Address + + nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "firstName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "middleName", type: String?.self), + FirebaseAILogic.JSONSchema.Property(name: "lastName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "age", type: Int.self), + FirebaseAILogic.JSONSchema.Property(name: "address", type: Address.self), + ] + ) + } + + nonisolated var modelOutput: FirebaseAILogic.ModelOutput { + var properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [] + properties.append(("firstName", firstName)) + if let middleName { + properties.append(("middleName", middleName)) + } + properties.append(("lastName", lastName)) + properties.append(("age", age)) + properties.append(("address", address)) + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) + } } - nonisolated var modelOutput: FirebaseAILogic.ModelOutput { - var properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [] - properties.append(("firstName", firstName)) - if let middleName { - properties.append(("middleName", middleName)) + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + extension Person: nonisolated FirebaseAILogic.Generable { + nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { + firstName = try content.value(forProperty: "firstName") + middleName = try content.value(forProperty: "middleName") + lastName = try content.value(forProperty: "lastName") + age = try content.value(forProperty: "age") + address = try content.value(forProperty: "address") + } + } +#else + // An example of the expected output from the `@FirebaseAILogic.Generable` macro. + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + struct Person: Equatable { + let firstName: String + let middleName: String? + let lastName: String + let age: Int + let address: Address + + static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "firstName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "middleName", type: String?.self), + FirebaseAILogic.JSONSchema.Property(name: "lastName", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "age", type: Int.self), + FirebaseAILogic.JSONSchema.Property(name: "address", type: Address.self), + ] + ) } - properties.append(("lastName", lastName)) - properties.append(("age", age)) - properties.append(("address", address)) - return ModelOutput( - properties: properties, - uniquingKeysWith: { _, second in - second + + var modelOutput: FirebaseAILogic.ModelOutput { + var properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [] + properties.append(("firstName", firstName)) + if let middleName { + properties.append(("middleName", middleName)) } - ) + properties.append(("lastName", lastName)) + properties.append(("age", age)) + properties.append(("address", address)) + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) + } } -} -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension Person: nonisolated FirebaseAILogic.Generable { - nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { - firstName = try content.value(forProperty: "firstName") - middleName = try content.value(forProperty: "middleName") - lastName = try content.value(forProperty: "lastName") - age = try content.value(forProperty: "age") - address = try content.value(forProperty: "address") + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + extension Person: FirebaseAILogic.Generable { + init(_ content: FirebaseAILogic.ModelOutput) throws { + firstName = try content.value(forProperty: "firstName") + middleName = try content.value(forProperty: "middleName") + lastName = try content.value(forProperty: "lastName") + age = try content.value(forProperty: "age") + address = try content.value(forProperty: "address") + } } -} +#endif @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) struct Address: Equatable { @@ -322,36 +375,72 @@ struct Address: Equatable { let zipCode: String } -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -extension Address: nonisolated FirebaseAILogic.Generable { - nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { - FirebaseAILogic.JSONSchema( - type: Self.self, - properties: [ - FirebaseAILogic.JSONSchema.Property(name: "street", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "city", type: String.self), - FirebaseAILogic.JSONSchema.Property(name: "zipCode", type: String.self), +#if compiler(>=6.2) + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + extension Address: nonisolated FirebaseAILogic.Generable { + nonisolated static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "street", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "city", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "zipCode", type: String.self), + ] + ) + } + + nonisolated var modelOutput: FirebaseAILogic.ModelOutput { + let properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [ + ("street", street), + ("city", city), + ("zipCode", zipCode), ] - ) - } + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) + } - nonisolated var modelOutput: FirebaseAILogic.ModelOutput { - let properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [ - ("street", street), - ("city", city), - ("zipCode", zipCode), - ] - return ModelOutput( - properties: properties, - uniquingKeysWith: { _, second in - second - } - ) + nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { + street = try content.value(forProperty: "street") + city = try content.value(forProperty: "city") + zipCode = try content.value(forProperty: "zipCode") + } } +#else + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) + extension Address: FirebaseAILogic.Generable { + static var jsonSchema: FirebaseAILogic.JSONSchema { + FirebaseAILogic.JSONSchema( + type: Self.self, + properties: [ + FirebaseAILogic.JSONSchema.Property(name: "street", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "city", type: String.self), + FirebaseAILogic.JSONSchema.Property(name: "zipCode", type: String.self), + ] + ) + } - nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { - street = try content.value(forProperty: "street") - city = try content.value(forProperty: "city") - zipCode = try content.value(forProperty: "zipCode") + var modelOutput: FirebaseAILogic.ModelOutput { + let properties: [(name: String, value: any FirebaseAILogic.ConvertibleToModelOutput)] = [ + ("street", street), + ("city", city), + ("zipCode", zipCode), + ] + return ModelOutput( + properties: properties, + uniquingKeysWith: { _, second in + second + } + ) + } + + init(_ content: FirebaseAILogic.ModelOutput) throws { + street = try content.value(forProperty: "street") + city = try content.value(forProperty: "city") + zipCode = try content.value(forProperty: "zipCode") + } } -} +#endif From 0db7eda804ce2123187e7d0bfb52bd96cb5d16a2 Mon Sep 17 00:00:00 2001 From: Paul Beusterien Date: Tue, 25 Nov 2025 15:57:35 -0800 Subject: [PATCH 10/10] Updates after rebase --- FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift | 5 ----- FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 5bedc091028..da3c09cc452 100644 --- a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift +++ b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift @@ -192,9 +192,6 @@ public extension ModelOutput { /// A property was accessed on a `ModelOutput` that is not a structure. case notAStructure - /// The data is corrupted or invalid. - case dataCorrupted(context: Context) - /// The context for a decoding error. public struct Context: Sendable { /// A description of the error. @@ -207,8 +204,6 @@ public extension ModelOutput { return "Missing property: \(name)" case .notAStructure: return "Not a structure" - case let .dataCorrupted(context): - return context.debugDescription } } } diff --git a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift index be3b3e6c985..a4f863b294c 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -100,8 +100,8 @@ struct GenerableTests { do { _ = try Person(modelOutput) Issue.record("Did not throw an error.") - } catch let ModelOutput.DecodingError.dataCorrupted(context) { - #expect(context.debugDescription.contains("ModelOutput cannot be represented as Int.")) + } catch let GenerativeModel.GenerationError.decodingFailure(context) { + #expect(context.debugDescription.contains("40.5 does not contain Int.")) } catch { Issue.record("Threw an unexpected error: \(error)") }