Skip to content

Commit e0c6d91

Browse files
Increase Firebase AI Logic unit test coverage (#15126)
Co-authored-by: Andrew Heard <[email protected]>
1 parent 12582dd commit e0c6d91

File tree

4 files changed

+342
-0
lines changed

4 files changed

+342
-0
lines changed

FirebaseAI/Tests/Unit/ChatTests.swift

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,104 @@ final class ChatTests: XCTestCase {
9494
XCTAssertEqual(chat.history[1], assembledExpectation)
9595
#endif // os(watchOS)
9696
}
97+
98+
func testSendMessage_unary_appendsHistory() async throws {
99+
let expectedInput = "Test input"
100+
MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler(
101+
forResource: "unary-success-basic-reply-short",
102+
withExtension: "json",
103+
subdirectory: "mock-responses/googleai"
104+
)
105+
let model = GenerativeModel(
106+
modelName: modelName,
107+
modelResourceName: modelResourceName,
108+
firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(),
109+
apiConfig: FirebaseAI.defaultVertexAIAPIConfig,
110+
tools: nil,
111+
requestOptions: RequestOptions(),
112+
urlSession: urlSession
113+
)
114+
let chat = model.startChat()
115+
116+
// Pre-condition: History should be empty.
117+
XCTAssertTrue(chat.history.isEmpty)
118+
119+
let response = try await chat.sendMessage(expectedInput)
120+
121+
XCTAssertNotNil(response.text)
122+
let text = try XCTUnwrap(response.text)
123+
XCTAssertFalse(text.isEmpty)
124+
125+
// Post-condition: History should have the user's message and the model's response.
126+
XCTAssertEqual(chat.history.count, 2)
127+
let userInput = try XCTUnwrap(chat.history.first)
128+
XCTAssertEqual(userInput.role, "user")
129+
XCTAssertEqual(userInput.parts.count, 1)
130+
let userInputText = try XCTUnwrap(userInput.parts.first as? TextPart)
131+
XCTAssertEqual(userInputText.text, expectedInput)
132+
133+
let modelResponse = try XCTUnwrap(chat.history.last)
134+
XCTAssertEqual(modelResponse.role, "model")
135+
XCTAssertEqual(modelResponse.parts.count, 1)
136+
let modelResponseText = try XCTUnwrap(modelResponse.parts.first as? TextPart)
137+
XCTAssertFalse(modelResponseText.text.isEmpty)
138+
}
139+
140+
func testSendMessageStream_error_doesNotAppendHistory() async throws {
141+
MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler(
142+
forResource: "streaming-failure-finish-reason-safety",
143+
withExtension: "txt",
144+
subdirectory: "mock-responses/vertexai"
145+
)
146+
let model = GenerativeModel(
147+
modelName: modelName,
148+
modelResourceName: modelResourceName,
149+
firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(),
150+
apiConfig: FirebaseAI.defaultVertexAIAPIConfig,
151+
tools: nil,
152+
requestOptions: RequestOptions(),
153+
urlSession: urlSession
154+
)
155+
let chat = model.startChat()
156+
let input = "Test input"
157+
158+
// Pre-condition: History should be empty.
159+
XCTAssertTrue(chat.history.isEmpty)
160+
161+
do {
162+
let stream = try chat.sendMessageStream(input)
163+
for try await _ in stream {
164+
// Consume the stream.
165+
}
166+
XCTFail("Should have thrown a responseStoppedEarly error.")
167+
} catch let GenerateContentError.responseStoppedEarly(reason, _) {
168+
XCTAssertEqual(reason, .safety)
169+
} catch {
170+
XCTFail("Unexpected error thrown: \(error)")
171+
}
172+
173+
// Post-condition: History should still be empty.
174+
XCTAssertEqual(chat.history.count, 0)
175+
}
176+
177+
func testStartChat_withHistory_initializesCorrectly() async throws {
178+
let history = [
179+
ModelContent(role: "user", parts: "Question 1"),
180+
ModelContent(role: "model", parts: "Answer 1"),
181+
]
182+
let model = GenerativeModel(
183+
modelName: modelName,
184+
modelResourceName: modelResourceName,
185+
firebaseInfo: GenerativeModelTestUtil.testFirebaseInfo(),
186+
apiConfig: FirebaseAI.defaultVertexAIAPIConfig,
187+
tools: nil,
188+
requestOptions: RequestOptions(),
189+
urlSession: urlSession
190+
)
191+
192+
let chat = model.startChat(history: history)
193+
194+
XCTAssertEqual(chat.history.count, 2)
195+
XCTAssertEqual(chat.history, history)
196+
}
97197
}

FirebaseAI/Tests/Unit/JSONValueTests.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,48 @@ final class JSONValueTests: XCTestCase {
9797
XCTAssertEqual(json, "null")
9898
}
9999

100+
func testDecodeNestedObject() throws {
101+
let nestedObject: JSONObject = [
102+
"nestedKey": .string("nestedValue"),
103+
]
104+
let expectedObject: JSONObject = [
105+
"numberKey": .number(numberValue),
106+
"objectKey": .object(nestedObject),
107+
]
108+
let json = """
109+
{
110+
"numberKey": \(numberValue),
111+
"objectKey": {
112+
"nestedKey": "nestedValue"
113+
}
114+
}
115+
"""
116+
let jsonData = try XCTUnwrap(json.data(using: .utf8))
117+
118+
let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData))
119+
120+
XCTAssertEqual(jsonObject, .object(expectedObject))
121+
}
122+
123+
func testDecodeNestedArray() throws {
124+
let nestedArray: [JSONValue] = [.string("a"), .string("b")]
125+
let expectedObject: JSONObject = [
126+
"numberKey": .number(numberValue),
127+
"arrayKey": .array(nestedArray),
128+
]
129+
let json = """
130+
{
131+
"numberKey": \(numberValue),
132+
"arrayKey": ["a", "b"]
133+
}
134+
"""
135+
let jsonData = try XCTUnwrap(json.data(using: .utf8))
136+
137+
let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData))
138+
139+
XCTAssertEqual(jsonObject, .object(expectedObject))
140+
}
141+
100142
func testEncodeNumber() throws {
101143
let jsonData = try encoder.encode(JSONValue.number(numberValue))
102144

@@ -143,4 +185,30 @@ final class JSONValueTests: XCTestCase {
143185
let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
144186
XCTAssertEqual(json, "[null,\(numberValueEncoded)]")
145187
}
188+
189+
func testEncodeNestedObject() throws {
190+
let nestedObject: JSONObject = [
191+
"nestedKey": .string("nestedValue"),
192+
]
193+
let objectValue: JSONObject = [
194+
"numberKey": .number(numberValue),
195+
"objectKey": .object(nestedObject),
196+
]
197+
198+
let jsonData = try encoder.encode(JSONValue.object(objectValue))
199+
let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData))
200+
XCTAssertEqual(jsonObject, .object(objectValue))
201+
}
202+
203+
func testEncodeNestedArray() throws {
204+
let nestedArray: [JSONValue] = [.string("a"), .string("b")]
205+
let objectValue: JSONObject = [
206+
"numberKey": .number(numberValue),
207+
"arrayKey": .array(nestedArray),
208+
]
209+
210+
let jsonData = try encoder.encode(JSONValue.object(objectValue))
211+
let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData))
212+
XCTAssertEqual(jsonObject, .object(objectValue))
213+
}
146214
}

FirebaseAI/Tests/Unit/PartsRepresentableTests.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,55 @@ final class PartsRepresentableTests: XCTestCase {
121121
}
122122
}
123123
#endif
124+
125+
func testMixedParts() throws {
126+
let text = "This is a test"
127+
let data = try XCTUnwrap("This is some data".data(using: .utf8))
128+
let inlineData = InlineDataPart(data: data, mimeType: "text/plain")
129+
130+
let parts: [any PartsRepresentable] = [text, inlineData]
131+
let modelContent = ModelContent(parts: parts)
132+
133+
XCTAssertEqual(modelContent.parts.count, 2)
134+
let textPart = try XCTUnwrap(modelContent.parts[0] as? TextPart)
135+
XCTAssertEqual(textPart.text, text)
136+
let dataPart = try XCTUnwrap(modelContent.parts[1] as? InlineDataPart)
137+
XCTAssertEqual(dataPart, inlineData)
138+
}
139+
140+
#if canImport(UIKit)
141+
func testMixedParts_withImage() throws {
142+
let text = "This is a test"
143+
let image = try XCTUnwrap(UIImage(systemName: "star"))
144+
let parts: [any PartsRepresentable] = [text, image]
145+
let modelContent = ModelContent(parts: parts)
146+
147+
XCTAssertEqual(modelContent.parts.count, 2)
148+
let textPart = try XCTUnwrap(modelContent.parts[0] as? TextPart)
149+
XCTAssertEqual(textPart.text, text)
150+
let imagePart = try XCTUnwrap(modelContent.parts[1] as? InlineDataPart)
151+
XCTAssertEqual(imagePart.mimeType, "image/jpeg")
152+
XCTAssertFalse(imagePart.data.isEmpty)
153+
}
154+
155+
#elseif canImport(AppKit)
156+
func testMixedParts_withImage() throws {
157+
let text = "This is a test"
158+
let coreImage = CIImage(color: CIColor.blue)
159+
.cropped(to: CGRect(origin: CGPoint.zero, size: CGSize(width: 16, height: 16)))
160+
let rep = NSCIImageRep(ciImage: coreImage)
161+
let image = NSImage(size: rep.size)
162+
image.addRepresentation(rep)
163+
164+
let parts: [any PartsRepresentable] = [text, image]
165+
let modelContent = ModelContent(parts: parts)
166+
167+
XCTAssertEqual(modelContent.parts.count, 2)
168+
let textPart = try XCTUnwrap(modelContent.parts[0] as? TextPart)
169+
XCTAssertEqual(textPart.text, text)
170+
let imagePart = try XCTUnwrap(modelContent.parts[1] as? InlineDataPart)
171+
XCTAssertEqual(imagePart.mimeType, "image/jpeg")
172+
XCTAssertFalse(imagePart.data.isEmpty)
173+
}
174+
#endif
124175
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import XCTest
16+
17+
@testable import FirebaseAI
18+
19+
@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
20+
final class SafetyTests: XCTestCase {
21+
let decoder = JSONDecoder()
22+
let encoder = JSONEncoder()
23+
24+
override func setUp() {
25+
encoder.outputFormatting = .init(
26+
arrayLiteral: .prettyPrinted, .sortedKeys, .withoutEscapingSlashes
27+
)
28+
}
29+
30+
// MARK: - SafetyRating Decoding
31+
32+
func testDecodeSafetyRating_allFieldsPresent() throws {
33+
let json = """
34+
{
35+
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
36+
"probability": "NEGLIGIBLE",
37+
"probabilityScore": 0.1,
38+
"severity": "HARM_SEVERITY_LOW",
39+
"severityScore": 0.2,
40+
"blocked": true
41+
}
42+
"""
43+
let jsonData = try XCTUnwrap(json.data(using: .utf8))
44+
let rating = try decoder.decode(SafetyRating.self, from: jsonData)
45+
46+
XCTAssertEqual(rating.category, .dangerousContent)
47+
XCTAssertEqual(rating.probability, .negligible)
48+
XCTAssertEqual(rating.probabilityScore, 0.1)
49+
XCTAssertEqual(rating.severity, .low)
50+
XCTAssertEqual(rating.severityScore, 0.2)
51+
XCTAssertTrue(rating.blocked)
52+
}
53+
54+
func testDecodeSafetyRating_missingOptionalFields() throws {
55+
let json = """
56+
{
57+
"category": "HARM_CATEGORY_HARASSMENT",
58+
"probability": "LOW"
59+
}
60+
"""
61+
let jsonData = try XCTUnwrap(json.data(using: .utf8))
62+
let rating = try decoder.decode(SafetyRating.self, from: jsonData)
63+
64+
XCTAssertEqual(rating.category, .harassment)
65+
XCTAssertEqual(rating.probability, .low)
66+
XCTAssertEqual(rating.probabilityScore, 0.0)
67+
XCTAssertEqual(rating.severity, .unspecified)
68+
XCTAssertEqual(rating.severityScore, 0.0)
69+
XCTAssertFalse(rating.blocked)
70+
}
71+
72+
func testDecodeSafetyRating_unknownEnums() throws {
73+
let json = """
74+
{
75+
"category": "HARM_CATEGORY_UNKNOWN",
76+
"probability": "UNKNOWN_PROBABILITY",
77+
"severity": "UNKNOWN_SEVERITY"
78+
}
79+
"""
80+
let jsonData = try XCTUnwrap(json.data(using: .utf8))
81+
let rating = try decoder.decode(SafetyRating.self, from: jsonData)
82+
83+
XCTAssertEqual(rating.category.rawValue, "HARM_CATEGORY_UNKNOWN")
84+
XCTAssertEqual(rating.probability.rawValue, "UNKNOWN_PROBABILITY")
85+
XCTAssertEqual(rating.severity.rawValue, "UNKNOWN_SEVERITY")
86+
}
87+
88+
// MARK: - SafetySetting Encoding
89+
90+
func testEncodeSafetySetting_allFields() throws {
91+
let setting = SafetySetting(
92+
harmCategory: .hateSpeech,
93+
threshold: .blockMediumAndAbove,
94+
method: .severity
95+
)
96+
let jsonData = try encoder.encode(setting)
97+
let jsonString = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
98+
99+
XCTAssertEqual(jsonString, """
100+
{
101+
"category" : "HARM_CATEGORY_HATE_SPEECH",
102+
"method" : "SEVERITY",
103+
"threshold" : "BLOCK_MEDIUM_AND_ABOVE"
104+
}
105+
""")
106+
}
107+
108+
func testEncodeSafetySetting_nilMethod() throws {
109+
let setting = SafetySetting(
110+
harmCategory: .sexuallyExplicit,
111+
threshold: .blockOnlyHigh
112+
)
113+
let jsonData = try encoder.encode(setting)
114+
let jsonString = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
115+
116+
XCTAssertEqual(jsonString, """
117+
{
118+
"category" : "HARM_CATEGORY_SEXUALLY_EXPLICIT",
119+
"threshold" : "BLOCK_ONLY_HIGH"
120+
}
121+
""")
122+
}
123+
}

0 commit comments

Comments
 (0)