mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-09 22:36:01 +05:45
434 lines
14 KiB
Swift
434 lines
14 KiB
Swift
// Copyright 2026 Google LLC
|
|||
|
|
//
|
||
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
// you may not use this file except in compliance with the License.
|
||
|
|
// You may obtain a copy of the License at
|
||
|
|
//
|
||
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
//
|
||
|
|
// Unless required by applicable law or agreed to in writing, software
|
||
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
// See the License for the specific language governing permissions and
|
||
|
|
// limitations under the License.
|
||
|
|
|
||
|
|
#if compiler(>=6.4)
|
||
|
|
import Foundation
|
||
|
|
import Testing
|
||
|
|
|
||
|
|
@testable import FoundationModelsFake
|
||
|
|
|
||
|
|
#if canImport(CoreGraphics)
|
||
|
|
import CoreGraphics
|
||
|
|
#endif
|
||
|
|
#if canImport(ImageIO)
|
||
|
|
import ImageIO
|
||
|
|
#endif
|
||
|
|
|
||
|
|
@Suite("Transcript model")
|
||
|
|
struct TranscriptModelTests {
|
||
|
|
/// Returns a text entry for collection tests.
|
||
|
|
///
|
||
|
|
/// - Parameter id: The id of the response and its segment.
|
||
|
|
/// - Returns: A response entry with one text segment.
|
||
|
|
static func entry(_ id: String) -> Transcript.Entry {
|
||
|
|
.response(
|
||
|
|
Transcript.Response(id: id, segments: [.text(Transcript.TextSegment(id: id, content: id))])
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Collection conformances
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func transcriptIsARandomAccessCollectionOfEntries() {
|
||
|
|
let transcript = Transcript(entries: ["a", "b", "c"].map(Self.entry))
|
||
|
|
|
||
|
|
let ids = transcript.map(\.id)
|
||
|
|
|
||
|
|
#expect(ids == ["a", "b", "c"])
|
||
|
|
#expect(transcript.startIndex == 0)
|
||
|
|
#expect(transcript.endIndex == 3)
|
||
|
|
#expect(transcript.count == 3)
|
||
|
|
#expect(transcript.last?.id == "c")
|
||
|
|
#expect(transcript[1...].map(\.id) == ["b", "c"])
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func transcriptIsMutableAndRangeReplaceable() {
|
||
|
|
var transcript = Transcript()
|
||
|
|
|
||
|
|
transcript.append(Self.entry("a"))
|
||
|
|
transcript.append(contentsOf: [Self.entry("b"), Self.entry("c")])
|
||
|
|
transcript[0] = Self.entry("z")
|
||
|
|
transcript.insert(Self.entry("y"), at: 1)
|
||
|
|
transcript.remove(at: 2)
|
||
|
|
transcript.replaceSubrange(0..<1, with: [Self.entry("x"), Self.entry("w")])
|
||
|
|
|
||
|
|
#expect(transcript.map(\.id) == ["x", "w", "y", "c"])
|
||
|
|
#expect(transcript.entries.map(\.id) == ["x", "w", "y", "c"])
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func emptyTranscriptsAreEqual() {
|
||
|
|
let empty = Transcript()
|
||
|
|
|
||
|
|
let seeded = Transcript(entries: [])
|
||
|
|
|
||
|
|
#expect(empty == seeded)
|
||
|
|
#expect(empty.isEmpty)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func toolCallsIsARandomAccessCollection() {
|
||
|
|
let calls = ["a", "b"].map {
|
||
|
|
Transcript.ToolCall(id: $0, toolName: "tool", arguments: GeneratedContent($0))
|
||
|
|
}
|
||
|
|
|
||
|
|
let toolCalls = Transcript.ToolCalls(id: "calls", calls)
|
||
|
|
|
||
|
|
#expect(toolCalls.map(\.id) == ["a", "b"])
|
||
|
|
#expect(toolCalls.startIndex == 0)
|
||
|
|
#expect(toolCalls.endIndex == 2)
|
||
|
|
#expect(toolCalls[1].id == "b")
|
||
|
|
#expect(Transcript.Entry.toolCalls(toolCalls).id == "calls")
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Identity and equality
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func entryAndSegmentIDsComeFromTheirPayloads() throws {
|
||
|
|
let segments: [Transcript.Segment] = [
|
||
|
|
.text(Transcript.TextSegment(id: "t", content: "text")),
|
||
|
|
.structure(
|
||
|
|
Transcript.StructuredSegment(id: "s", schemaName: "S", content: GeneratedContent(1))),
|
||
|
|
.attachment(
|
||
|
|
Transcript.AttachmentSegment(
|
||
|
|
id: "a",
|
||
|
|
content: .image(Transcript.ImageAttachment(imageURL: try Self.dataURL()))
|
||
|
|
)
|
||
|
|
),
|
||
|
|
]
|
||
|
|
|
||
|
|
let ids = segments.map(\.id)
|
||
|
|
|
||
|
|
#expect(ids == ["t", "s", "a"])
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func segmentsOfDifferentKindsAreUnequal() {
|
||
|
|
let text = Transcript.Segment.text(Transcript.TextSegment(id: "x", content: "1"))
|
||
|
|
|
||
|
|
let structure = Transcript.Segment.structure(
|
||
|
|
Transcript.StructuredSegment(id: "x", schemaName: "Int", content: GeneratedContent(1))
|
||
|
|
)
|
||
|
|
|
||
|
|
#expect(text != structure)
|
||
|
|
#expect(text == .text(Transcript.TextSegment(id: "x", content: "1")))
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func promptEqualityComparesEveryProperty() {
|
||
|
|
let base = Transcript.Prompt(id: "p", segments: [])
|
||
|
|
|
||
|
|
var changedMetadata = base
|
||
|
|
changedMetadata.metadata = ["key": GeneratedContent("value")]
|
||
|
|
var changedOptions = base
|
||
|
|
changedOptions.options = GenerationOptions(temperature: 0.5)
|
||
|
|
var changedContext = base
|
||
|
|
changedContext.contextOptions = ContextOptions(reasoningLevel: .deep)
|
||
|
|
var changedFormat = base
|
||
|
|
changedFormat.responseFormat = Transcript.ResponseFormat(type: String.self)
|
||
|
|
|
||
|
|
#expect(base == Transcript.Prompt(id: "p", segments: []))
|
||
|
|
#expect(base != changedMetadata)
|
||
|
|
#expect(base != changedOptions)
|
||
|
|
#expect(base != changedContext)
|
||
|
|
#expect(base != changedFormat)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func responseFormatFromTypeEqualsFormatFromItsSchema() {
|
||
|
|
let fromType = Transcript.ResponseFormat(type: String.self)
|
||
|
|
|
||
|
|
let fromSchema = Transcript.ResponseFormat(schema: String.generationSchema)
|
||
|
|
|
||
|
|
#expect(fromType == fromSchema)
|
||
|
|
#expect(fromType.kind == fromSchema.kind)
|
||
|
|
#expect(fromType != Transcript.ResponseFormat(type: Int.self))
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func toolCallEqualityComparesArgumentsAndMetadata() throws {
|
||
|
|
let arguments = GeneratedContent(properties: ["city": "Paris"])
|
||
|
|
let call = Transcript.ToolCall(id: "c", toolName: "weather", arguments: arguments)
|
||
|
|
|
||
|
|
let withMetadata = Transcript.ToolCall(
|
||
|
|
id: "c",
|
||
|
|
metadata: ["signature": "abc"],
|
||
|
|
toolName: "weather",
|
||
|
|
arguments: arguments
|
||
|
|
)
|
||
|
|
let parsedArguments = Transcript.ToolCall(
|
||
|
|
id: "c",
|
||
|
|
toolName: "weather",
|
||
|
|
arguments: try GeneratedContent(json: #"{"city": "Paris"}"#)
|
||
|
|
)
|
||
|
|
|
||
|
|
#expect(call == Transcript.ToolCall(id: "c", toolName: "weather", arguments: arguments))
|
||
|
|
#expect(call != withMetadata)
|
||
|
|
#expect(withMetadata.metadata["signature"] == GeneratedContent("abc"))
|
||
|
|
#expect(call != parsedArguments)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func toolDefinitionsCompareTheirSchemas() {
|
||
|
|
let definition = Transcript.ToolDefinition(
|
||
|
|
name: "echo",
|
||
|
|
description: "Echoes",
|
||
|
|
parameters: String.generationSchema
|
||
|
|
)
|
||
|
|
|
||
|
|
let other = Transcript.ToolDefinition(
|
||
|
|
name: "echo",
|
||
|
|
description: "Echoes",
|
||
|
|
parameters: Int.generationSchema
|
||
|
|
)
|
||
|
|
|
||
|
|
#expect(definition == definition)
|
||
|
|
#expect(definition != other)
|
||
|
|
#expect(definition.description == "Echoes")
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Response and reasoning
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func responseWithAssetIDsMirrorsThemIntoMetadata() {
|
||
|
|
let response = Transcript.Response(id: "r", assetIDs: ["a", "b"], segments: [])
|
||
|
|
|
||
|
|
let metadata = response.metadata
|
||
|
|
|
||
|
|
#expect(response.assetIDs == ["a", "b"])
|
||
|
|
#expect(metadata == ["assetIDs": GeneratedContent(["a", "b"])])
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func responseWithMetadataHasNoAssetIDs() {
|
||
|
|
let response = Transcript.Response(id: "r", metadata: ["tokens": 12], segments: [])
|
||
|
|
|
||
|
|
let metadata = response.metadata
|
||
|
|
|
||
|
|
#expect(response.assetIDs.isEmpty)
|
||
|
|
#expect(metadata == ["tokens": GeneratedContent(12)])
|
||
|
|
#expect(response != Transcript.Response(id: "r", segments: []))
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func reasoningEqualityComparesSignatureAndMetadata() {
|
||
|
|
let reasoning = Transcript.Reasoning(id: "r", segments: [], signature: Data([1]))
|
||
|
|
|
||
|
|
let otherSignature = Transcript.Reasoning(id: "r", segments: [], signature: Data([2]))
|
||
|
|
let otherMetadata = Transcript.Reasoning(
|
||
|
|
id: "r",
|
||
|
|
metadata: ["k": "v"],
|
||
|
|
segments: [],
|
||
|
|
signature: Data([1])
|
||
|
|
)
|
||
|
|
|
||
|
|
#expect(reasoning == Transcript.Reasoning(id: "r", segments: [], signature: Data([1])))
|
||
|
|
#expect(reasoning != otherSignature)
|
||
|
|
#expect(reasoning != otherMetadata)
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Images
|
||
|
|
|
||
|
|
/// Returns a small `data:` URL.
|
||
|
|
static func dataURL() throws -> URL {
|
||
|
|
try #require(URL(string: "data:image/png;base64,iVBORw0KGgo="))
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func imageAttachmentKeepsURLAndOrientation() throws {
|
||
|
|
let url = try Self.dataURL()
|
||
|
|
|
||
|
|
let image = Transcript.ImageAttachment(imageURL: url, orientation: .right)
|
||
|
|
|
||
|
|
#expect(image.url == url)
|
||
|
|
#expect(image.orientation == .right)
|
||
|
|
#expect(Transcript.ImageAttachment(imageURL: url).orientation == .up)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func imageAttachmentsAreEqualOnlyToCopies() throws {
|
||
|
|
let url = try Self.dataURL()
|
||
|
|
let image = Transcript.ImageAttachment(imageURL: url)
|
||
|
|
|
||
|
|
let copy = image
|
||
|
|
let sameURL = Transcript.ImageAttachment(imageURL: url)
|
||
|
|
|
||
|
|
#expect(image == copy)
|
||
|
|
#expect(image != sameURL)
|
||
|
|
#expect(Transcript.Attachment.image(image) == .image(copy))
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func fileImagesEncodeAsDataURLsWithSniffedMIMEType() throws {
|
||
|
|
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01])
|
||
|
|
let fileURL = FileManager.default.temporaryDirectory
|
||
|
|
.appendingPathComponent("transcript-\(UUID().uuidString).png")
|
||
|
|
try png.write(to: fileURL)
|
||
|
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||
|
|
let transcript = Self.transcript(withImageURL: fileURL, label: "photo")
|
||
|
|
|
||
|
|
let attachment = try Self.encodedAttachment(of: transcript)
|
||
|
|
|
||
|
|
#expect(attachment["type"] as? String == "image")
|
||
|
|
#expect(attachment["label"] as? String == "photo")
|
||
|
|
#expect(attachment["data"] as? String == "data:image/png;base64,\(png.base64EncodedString())")
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func dataAndRemoteImageURLsEncodeVerbatim() throws {
|
||
|
|
let urls = [
|
||
|
|
try Self.dataURL(),
|
||
|
|
try #require(URL(string: "https://example.com/image.jpg")),
|
||
|
|
]
|
||
|
|
|
||
|
|
let encoded = try urls.map {
|
||
|
|
try Self.encodedAttachment(of: Self.transcript(withImageURL: $0, label: nil))
|
||
|
|
}
|
||
|
|
|
||
|
|
#expect(encoded.map { $0["data"] as? String } == urls.map(\.absoluteString))
|
||
|
|
#expect(encoded.allSatisfy { $0["label"] == nil })
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test(arguments: [
|
||
|
|
"data:image/png;base64,iVBORw0KGgo=",
|
||
|
|
"https://example.com/image.jpg",
|
||
|
|
"gs://bucket/image.jpg",
|
||
|
|
"image.jpg",
|
||
|
|
])
|
||
|
|
func decodesNonFileImageURLs(urlString: String) throws {
|
||
|
|
let url = try #require(URL(string: urlString))
|
||
|
|
let json = try JSONEncoder().encode(Self.transcript(withImageURL: url, label: nil))
|
||
|
|
|
||
|
|
let decoded = try JSONDecoder().decode(Transcript.self, from: json)
|
||
|
|
|
||
|
|
#expect(Self.imageURL(in: decoded) == url)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test(arguments: ["file:///etc/hosts", "FILE:///etc/hosts"])
|
||
|
|
func rejectsDecodingFileImageURLs(urlString: String) throws {
|
||
|
|
let placeholderURL = "https://example.com/placeholder.jpg"
|
||
|
|
let url = try #require(URL(string: placeholderURL))
|
||
|
|
let encoder = JSONEncoder()
|
||
|
|
encoder.outputFormatting = .withoutEscapingSlashes
|
||
|
|
let encoded = try encoder.encode(Self.transcript(withImageURL: url, label: nil))
|
||
|
|
let json = String(decoding: encoded, as: UTF8.self).replacing(placeholderURL, with: urlString)
|
||
|
|
|
||
|
|
#expect(throws: DecodingError.self) {
|
||
|
|
try JSONDecoder().decode(Transcript.self, from: Data(json.utf8))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Image signatures and the MIME types sniffed from them.
|
||
|
|
static let imageSignatures: [([UInt8], String)] = {
|
||
|
|
let webP: [UInt8] = Array("RIFF".utf8) + [0, 0, 0, 0] + Array("WEBP".utf8)
|
||
|
|
let heic: [UInt8] = [0, 0, 0, 0x18] + Array("ftypheic".utf8)
|
||
|
|
return [
|
||
|
|
([0xFF, 0xD8, 0xFF, 0xE0], "image/jpeg"),
|
||
|
|
(Array("GIF89a".utf8), "image/gif"),
|
||
|
|
(webP, "image/webp"),
|
||
|
|
(heic, "image/heic"),
|
||
|
|
([0x49, 0x49, 0x2A, 0x00], "image/tiff"),
|
||
|
|
([0x00, 0x01], "application/octet-stream"),
|
||
|
|
]
|
||
|
|
}()
|
||
|
|
|
||
|
|
@Test(arguments: imageSignatures)
|
||
|
|
func sniffsImageMIMETypes(bytes: [UInt8], mimeType: String) {
|
||
|
|
let sniffed = ImageAttachmentStorage.mimeType(sniffing: Data(bytes))
|
||
|
|
|
||
|
|
#expect(sniffed == mimeType)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
func decodesPercentEncodedDataURLs() {
|
||
|
|
let payload = ImageAttachmentStorage.dataURLPayload("data:text/plain,a%20b")
|
||
|
|
|
||
|
|
#expect(payload == Data("a b".utf8))
|
||
|
|
}
|
||
|
|
|
||
|
|
#if canImport(CoreGraphics) && canImport(ImageIO)
|
||
|
|
@Test
|
||
|
|
func cgImageAttachmentsEncodeAsJPEGDataURLs() throws {
|
||
|
|
let image = Transcript.ImageAttachment(ImageAttachmentStorage.placeholderImage)
|
||
|
|
let transcript = Transcript(entries: [
|
||
|
|
.prompt(
|
||
|
|
Transcript.Prompt(
|
||
|
|
id: "p",
|
||
|
|
segments: [.attachment(Transcript.AttachmentSegment(id: "a", content: .image(image)))]
|
||
|
|
)
|
||
|
|
)
|
||
|
|
])
|
||
|
|
|
||
|
|
let attachment = try Self.encodedAttachment(of: transcript)
|
||
|
|
|
||
|
|
#expect((attachment["data"] as? String)?.hasPrefix("data:image/jpeg;base64,/9j/") == true)
|
||
|
|
#expect(image.url == nil)
|
||
|
|
}
|
||
|
|
#endif
|
||
|
|
|
||
|
|
/// Returns a transcript with one prompt that holds one image.
|
||
|
|
///
|
||
|
|
/// - Parameters:
|
||
|
|
/// - url: The image URL.
|
||
|
|
/// - label: The attachment label.
|
||
|
|
/// - Returns: The transcript.
|
||
|
|
static func transcript(withImageURL url: URL, label: String?) -> Transcript {
|
||
|
|
Transcript(entries: [
|
||
|
|
.prompt(
|
||
|
|
Transcript.Prompt(
|
||
|
|
id: "p",
|
||
|
|
segments: [
|
||
|
|
.attachment(
|
||
|
|
Transcript.AttachmentSegment(
|
||
|
|
id: "a",
|
||
|
|
content: .image(Transcript.ImageAttachment(imageURL: url)),
|
||
|
|
label: label
|
||
|
|
)
|
||
|
|
)
|
||
|
|
]
|
||
|
|
)
|
||
|
|
)
|
||
|
|
])
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Returns the URL of the image in the first prompt segment of a transcript.
|
||
|
|
///
|
||
|
|
/// - Parameter transcript: A transcript whose first entry is a prompt with an image.
|
||
|
|
/// - Returns: The image's URL, or `nil` if there's no URL-backed image.
|
||
|
|
static func imageURL(in transcript: Transcript) -> URL? {
|
||
|
|
guard case .prompt(let prompt) = transcript.first,
|
||
|
|
case .attachment(let segment) = prompt.segments.first,
|
||
|
|
case .image(let image) = segment.content
|
||
|
|
else {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return image.url
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Encodes a transcript and returns the `attachment` object of its first prompt segment.
|
||
|
|
///
|
||
|
|
/// - Parameter transcript: A transcript whose first entry is a prompt with an attachment.
|
||
|
|
/// - Returns: The JSON object of the attachment.
|
||
|
|
static func encodedAttachment(of transcript: Transcript) throws -> [String: Any] {
|
||
|
|
let data = try JSONEncoder().encode(transcript)
|
||
|
|
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||
|
|
let entries = try #require(
|
||
|
|
(object["transcript"] as? [String: Any])?["entries"] as? [[String: Any]]
|
||
|
|
)
|
||
|
|
let contents = try #require(entries.first?["contents"] as? [[String: Any]])
|
||
|
|
return try #require(contents.first?["attachment"] as? [String: Any])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
#endif // compiler(>=6.4)
|