diff --git a/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift b/FirebaseAI/Sources/Types/Public/Generable/ModelOutput.swift index 36d3626dde5..da3c09cc452 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 @@ -146,7 +150,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 +158,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 +172,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 @@ -183,11 +184,35 @@ 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, CustomDebugStringConvertible { + /// 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 context for a decoding error. + public struct Context: Sendable { + /// 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" + } + } + } + /// 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 @@ -212,5 +237,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 365403709ea..a4f863b294c 100644 --- a/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift +++ b/FirebaseAI/Tests/Unit/Types/Generable/GenerableTests.swift @@ -12,14 +12,19 @@ // 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)] + [("firstName", "John"), ("lastName", "Doe"), ("age", 40), ("address", addressModelOutput)] let modelOutput = ModelOutput( properties: properties, uniquingKeysWith: { _, second in second } ) @@ -29,11 +34,142 @@ 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 initializeGenerableWithMissingProperty() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("age", 40)] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + 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)") + } + } + + @Test + func initializeGenerableFromNonStructure() throws { + let modelOutput = ModelOutput("not a structure") + + 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)") + } + } + + @Test + func initializeGenerableWithTypeMismatch() throws { + let properties: [(String, any ConvertibleToModelOutput)] = + [("firstName", "John"), ("lastName", "Doe"), ("age", "forty")] + let modelOutput = ModelOutput( + properties: properties, uniquingKeysWith: { _, second in second } + ) + + do { + _ = try Person(modelOutput) + Issue.record("Did not throw an error.") + } catch let GenerativeModel.GenerationError.decodingFailure(context) { + #expect(context.debugDescription.contains("\"forty\" does not contain Int")) + } catch { + Issue.record("Threw an unexpected error: \(error)") + } + } + + @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 GenerativeModel.GenerationError.decodingFailure(context) { + #expect(context.debugDescription.contains("40.5 does not contain Int.")) + } catch { + Issue.record("Threw an unexpected error: \(error)") + } + } + + @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), + ("address", addressModelOutput), + ("country", "USA"), + ] + 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 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,56 +205,106 @@ 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"]) } -} -// 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 - - 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), - ] + @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 ) - } - 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) - return ModelOutput( - properties: properties, - uniquingKeysWith: { _, second in - second - } - ) - func addProperty(name: String, value: some FirebaseAILogic.Generable) { - properties.append((name, value)) + let modelOutput = person.modelOutput + + guard case let .structure(properties, orderedKeys) = modelOutput.kind else { + Issue.record("Model output is not a structure.") + return } - func addProperty(name: String, value: (some FirebaseAILogic.Generable)?) { - if let value { - properties.append((name, value)) - } + + #expect(properties["middleName"] == nil) + #expect(orderedKeys == ["firstName", "lastName", "age", "address"]) + } + + @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) } } #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 + } + ) + } + } + @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 { @@ -126,16 +312,135 @@ struct Person: Equatable { 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), + ] + ) + } + + 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: FirebaseAILogic.Generable { - nonisolated init(_ content: FirebaseAILogic.ModelOutput) throws { + 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 { + let street: String + let city: String + let zipCode: String +} + +#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 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), + ] + ) + } + + 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