diff --git a/Tests/AnyLanguageModelTests/CharacterExtensionsTests.swift b/Tests/AnyLanguageModelTests/CharacterExtensionsTests.swift new file mode 100644 index 0000000..216f497 --- /dev/null +++ b/Tests/AnyLanguageModelTests/CharacterExtensionsTests.swift @@ -0,0 +1,33 @@ +import Testing + +@testable import AnyLanguageModel + +@Suite("Character Extensions") +struct CharacterExtensionsTests { + private func character(_ string: String) -> Character { + string.first! + } + + @Test func containsEmojiScalarDetectsEmoji() { + #expect(character("😀").containsEmojiScalar) + #expect(!character("A").containsEmojiScalar) + } + + @Test func isValidJSONStringCharacterAcceptsExpectedCharacters() { + #expect(character("A").isValidJSONStringCharacter) + #expect(character("7").isValidJSONStringCharacter) + #expect(character(" ").isValidJSONStringCharacter) + #expect(character("😀").isValidJSONStringCharacter) + #expect(character("é").isValidJSONStringCharacter) + } + + @Test func isValidJSONStringCharacterRejectsDisallowedCharacters() { + let control = Character(UnicodeScalar(0x1F)!) + + #expect(!character("\\").isValidJSONStringCharacter) + #expect(!character("\"").isValidJSONStringCharacter) + #expect(!character("”").isValidJSONStringCharacter) + #expect(!control.isValidJSONStringCharacter) + #expect(!character("】").isValidJSONStringCharacter) + } +} diff --git a/Tests/AnyLanguageModelTests/ConvertibleToGeneratedContentTests.swift b/Tests/AnyLanguageModelTests/ConvertibleToGeneratedContentTests.swift new file mode 100644 index 0000000..f759b12 --- /dev/null +++ b/Tests/AnyLanguageModelTests/ConvertibleToGeneratedContentTests.swift @@ -0,0 +1,48 @@ +import Foundation +import JSONSchema +import Testing + +@testable import AnyLanguageModel + +@Suite("ConvertibleToGeneratedContent") +struct ConvertibleToGeneratedContentTests { + @Test func optionalNoneMapsToNullGeneratedContent() { + let value: GeneratedContent? = nil + #expect(value.generatedContent.kind == .null) + } + + @Test func optionalSomeMapsToWrappedGeneratedContent() { + let wrapped = GeneratedContent("hello") + let value: GeneratedContent? = wrapped + + #expect(value.generatedContent == wrapped) + } + + @Test func arrayMapsToArrayGeneratedContent() { + let first = GeneratedContent("a") + let second = GeneratedContent(2) + let array = [first, second] + + #expect(array.generatedContent.kind == .array([first, second])) + } + + @Test func defaultInstructionsAndPromptRepresentationsUseJSONString() throws { + let content = GeneratedContent(properties: [ + "name": "AnyLanguageModel", + "stars": 5, + ]) + let decoder = JSONDecoder() + let expectedValue = try decoder.decode(JSONValue.self, from: Data(content.jsonString.utf8)) + let instructionsValue = try decoder.decode( + JSONValue.self, + from: Data(content.instructionsRepresentation.description.utf8) + ) + let promptValue = try decoder.decode( + JSONValue.self, + from: Data(content.promptRepresentation.description.utf8) + ) + + #expect(instructionsValue == expectedValue) + #expect(promptValue == expectedValue) + } +} diff --git a/Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift b/Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift new file mode 100644 index 0000000..eeb0397 --- /dev/null +++ b/Tests/AnyLanguageModelTests/DynamicGenerationSchemaTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +@Suite("DynamicGenerationSchema") +struct DynamicGenerationSchemaTests { + @Test func objectSchemaConvertsToGenerationSchema() throws { + let person = DynamicGenerationSchema( + name: "Person", + description: "A person object", + properties: [ + .init(name: "name", description: "Full name", schema: .init(type: String.self)), + .init(name: "age", schema: .init(type: Int.self), isOptional: true), + ] + ) + + let schema = try GenerationSchema(root: person, dependencies: []) + + #expect(schema.root == .ref("Person")) + #expect(schema.defs["Person"] != nil) + } + + @Test func anyOfSchemaAndStringEnumSchemaConvert() throws { + let text = DynamicGenerationSchema(type: String.self) + let integer = DynamicGenerationSchema(type: Int.self) + let payload = DynamicGenerationSchema(name: "Payload", anyOf: [text, integer]) + let color = DynamicGenerationSchema(name: "Color", anyOf: ["red", "green", "blue"]) + + let payloadSchema = try GenerationSchema(root: payload, dependencies: []) + let colorSchema = try GenerationSchema(root: color, dependencies: []) + + #expect(payloadSchema.root == .ref("Payload")) + #expect(colorSchema.root == .ref("Color")) + #expect(payloadSchema.debugDescription.contains("anyOf")) + #expect(colorSchema.debugDescription.contains("string(enum")) + } + + @Test func arraySchemaConvertsWithMinAndMax() throws { + let tags = DynamicGenerationSchema( + arrayOf: .init(type: String.self), + minimumElements: 1, + maximumElements: 3 + ) + let container = DynamicGenerationSchema( + name: "Container", + properties: [.init(name: "tags", schema: tags)] + ) + + let schema = try GenerationSchema(root: container, dependencies: []) + guard case .object(let objectNode) = schema.defs["Container"] else { + Issue.record("Expected Container definition to be an object") + return + } + guard case .array(let arrayNode) = objectNode.properties["tags"] else { + Issue.record("Expected tags property to be an array") + return + } + #expect(arrayNode.minItems == 1) + #expect(arrayNode.maxItems == 3) + } + + @Test func typeInitializerMapsScalarAndReferenceBodies() { + let boolSchema = DynamicGenerationSchema(type: Bool.self) + let stringSchema = DynamicGenerationSchema(type: String.self) + let intSchema = DynamicGenerationSchema(type: Int.self) + let floatSchema = DynamicGenerationSchema(type: Float.self) + let doubleSchema = DynamicGenerationSchema(type: Double.self) + let decimalSchema = DynamicGenerationSchema(type: Decimal.self) + let referenceSchema = DynamicGenerationSchema(type: GeneratedContent.self) + + if case .scalar(.bool) = boolSchema.body {} else { Issue.record("Expected bool scalar mapping") } + if case .scalar(.string) = stringSchema.body {} else { Issue.record("Expected string scalar mapping") } + if case .scalar(.integer) = intSchema.body {} else { Issue.record("Expected integer scalar mapping") } + if case .scalar(.number) = floatSchema.body {} else { Issue.record("Expected float number mapping") } + if case .scalar(.number) = doubleSchema.body {} else { Issue.record("Expected double number mapping") } + if case .scalar(.decimal) = decimalSchema.body {} else { Issue.record("Expected decimal mapping") } + + if case .reference(let name) = referenceSchema.body { + #expect(name.contains("GeneratedContent")) + } else { + Issue.record("Expected reference mapping for non-scalar Generable type") + } + } + + @Test func referenceInitializerCreatesReferenceBody() { + let reference = DynamicGenerationSchema(referenceTo: "Address") + if case .reference(let name) = reference.body { + #expect(name == "Address") + } else { + Issue.record("Expected reference body") + } + } + + @Test func duplicateDependencyNamesThrow() { + let dep1 = DynamicGenerationSchema(name: "Shared", properties: []) + let dep2 = DynamicGenerationSchema(name: "Shared", properties: []) + let root = DynamicGenerationSchema(referenceTo: "Shared") + + #expect(throws: GenerationSchema.SchemaError.self) { + _ = try GenerationSchema(root: root, dependencies: [dep1, dep2]) + } + } + + @Test func undefinedReferenceThrows() { + let root = DynamicGenerationSchema(referenceTo: "MissingType") + + #expect(throws: GenerationSchema.SchemaError.self) { + _ = try GenerationSchema(root: root, dependencies: []) + } + } +} diff --git a/Tests/AnyLanguageModelTests/GenerationGuideTests.swift b/Tests/AnyLanguageModelTests/GenerationGuideTests.swift new file mode 100644 index 0000000..5216a18 --- /dev/null +++ b/Tests/AnyLanguageModelTests/GenerationGuideTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +@Suite("GenerationGuide") +struct GenerationGuideTests { + @Test func stringFactoriesAreCallable() { + _ = GenerationGuide.constant("fixed") + _ = GenerationGuide.anyOf(["red", "green", "blue"]) + _ = GenerationGuide.pattern(#/^[a-z]+$/#) + } + + @Test func intFactoriesSetBounds() { + let minimum = GenerationGuide.minimum(1) + let maximum = GenerationGuide.maximum(10) + let range = GenerationGuide.range(2 ... 8) + + #expect(minimum.minimum == 1) + #expect(minimum.maximum == nil) + #expect(maximum.minimum == nil) + #expect(maximum.maximum == 10) + #expect(range.minimum == 2) + #expect(range.maximum == 8) + } + + @Test func floatFactoriesSetBounds() { + let minimum = GenerationGuide.minimum(1.25) + let maximum = GenerationGuide.maximum(9.75) + let range = GenerationGuide.range(2.5 ... 8.5) + + #expect(minimum.minimum == 1.25) + #expect(maximum.maximum == 9.75) + #expect(range.minimum == 2.5) + #expect(range.maximum == 8.5) + } + + @Test func doubleFactoriesSetBounds() { + let minimum = GenerationGuide.minimum(0.1) + let maximum = GenerationGuide.maximum(0.9) + let range = GenerationGuide.range(0.2 ... 0.8) + + #expect(minimum.minimum == 0.1) + #expect(maximum.maximum == 0.9) + #expect(range.minimum == 0.2) + #expect(range.maximum == 0.8) + } + + @Test func decimalFactoriesSetBounds() { + let minimum = GenerationGuide.minimum(Decimal(string: "1.5")!) + let maximum = GenerationGuide.maximum(Decimal(string: "9.5")!) + let range = GenerationGuide.range(Decimal(string: "2.5")! ... Decimal(string: "8.5")!) + + #expect(minimum.minimum == 1.5) + #expect(maximum.maximum == 9.5) + #expect(range.minimum == 2.5) + #expect(range.maximum == 8.5) + } + + @Test func arrayFactoriesSetCountBounds() { + let minimum = GenerationGuide<[String]>.minimumCount(1) + let maximum = GenerationGuide<[String]>.maximumCount(5) + let range = GenerationGuide<[String]>.count(2 ... 4) + let exact = GenerationGuide<[String]>.count(3) + let element = GenerationGuide<[String]>.element(.constant("x")) + + #expect(minimum.minimumCount == 1) + #expect(maximum.maximumCount == 5) + #expect(range.minimumCount == 2) + #expect(range.maximumCount == 4) + #expect(exact.minimumCount == 3) + #expect(exact.maximumCount == 3) + #expect(element.minimumCount == nil) + #expect(element.maximumCount == nil) + } +} diff --git a/Tests/AnyLanguageModelTests/InstructionsTests.swift b/Tests/AnyLanguageModelTests/InstructionsTests.swift new file mode 100644 index 0000000..d43daf4 --- /dev/null +++ b/Tests/AnyLanguageModelTests/InstructionsTests.swift @@ -0,0 +1,50 @@ +import Testing + +@testable import AnyLanguageModel + +@Suite("Instructions") +struct InstructionsTests { + @Test func initializesFromStringRepresentable() { + let instructions = Instructions("Be concise.") + #expect(instructions.description == "Be concise.") + } + + @Test func initializesFromInstructionsRepresentable() { + let existing = Instructions("Base instructions") + let wrapped = Instructions(existing) + #expect(wrapped.description == "Base instructions") + } + + @Test func builderCombinesLines() throws { + let instructions = try Instructions { + "First line" + "Second line" + } + + #expect(instructions.description == "First line\nSecond line") + } + + @Test func builderSupportsConditionalsAndOptionals() throws { + let includeConditional = true + let includeOptional = false + + let instructions = try Instructions { + "Always" + if includeConditional { + "Conditional" + } else { + "Other" + } + if includeOptional { + "Optional" + } + } + + #expect(instructions.description == "Always\nConditional") + } + + @Test func arrayRepresentationJoinsByNewline() { + let array = ["One", "Two", "Three"] + #expect(array.instructionsRepresentation.description == "One\nTwo\nThree") + } +} diff --git a/Tests/AnyLanguageModelTests/JSONDecoderExtensionsTests.swift b/Tests/AnyLanguageModelTests/JSONDecoderExtensionsTests.swift new file mode 100644 index 0000000..ed188bc --- /dev/null +++ b/Tests/AnyLanguageModelTests/JSONDecoderExtensionsTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +@Suite("JSONDecoder Extensions") +struct JSONDecoderExtensionsTests { + private struct Payload: Decodable { + let date: Date + } + + @Test func iso8601WithFractionalSecondsDecodesFractionalDates() throws { + let json = #"{"date":"2026-02-17T12:34:56.789Z"}"#.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601WithFractionalSeconds + + let payload = try decoder.decode(Payload.self, from: json) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expected = formatter.date(from: "2026-02-17T12:34:56.789Z")! + #expect(payload.date == expected) + } + + @Test func iso8601WithFractionalSecondsFallsBackToNonFractionalDates() throws { + let json = #"{"date":"2026-02-17T12:34:56Z"}"#.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601WithFractionalSeconds + + let payload = try decoder.decode(Payload.self, from: json) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let expected = formatter.date(from: "2026-02-17T12:34:56Z")! + #expect(payload.date == expected) + } + + @Test func iso8601WithFractionalSecondsThrowsDataCorruptedForInvalidDates() { + let json = #"{"date":"not-a-date"}"#.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601WithFractionalSeconds + + do { + _ = try decoder.decode(Payload.self, from: json) + Issue.record("Expected decode to fail for invalid date") + } catch let error as DecodingError { + if case .dataCorrupted = error { + #expect(Bool(true)) + } else { + Issue.record("Expected dataCorrupted, got \(error)") + } + } catch { + Issue.record("Expected DecodingError, got \(error)") + } + } +} diff --git a/Tests/AnyLanguageModelTests/LanguageModelFeedbackTests.swift b/Tests/AnyLanguageModelTests/LanguageModelFeedbackTests.swift new file mode 100644 index 0000000..c4a142f --- /dev/null +++ b/Tests/AnyLanguageModelTests/LanguageModelFeedbackTests.swift @@ -0,0 +1,45 @@ +import Testing + +@testable import AnyLanguageModel + +@Suite("LanguageModelFeedback") +struct LanguageModelFeedbackTests { + @Test func sentimentExposesAllCases() { + #expect(LanguageModelFeedback.Sentiment.allCases.count == 3) + #expect(LanguageModelFeedback.Sentiment.allCases.contains(.positive)) + #expect(LanguageModelFeedback.Sentiment.allCases.contains(.negative)) + #expect(LanguageModelFeedback.Sentiment.allCases.contains(.neutral)) + } + + @Test func issueCategoryExposesAllCases() { + #expect(LanguageModelFeedback.Issue.Category.allCases.count == 8) + #expect(LanguageModelFeedback.Issue.Category.allCases.contains(.unhelpful)) + #expect(LanguageModelFeedback.Issue.Category.allCases.contains(.tooVerbose)) + #expect(LanguageModelFeedback.Issue.Category.allCases.contains(.didNotFollowInstructions)) + #expect(LanguageModelFeedback.Issue.Category.allCases.contains(.incorrect)) + #expect(LanguageModelFeedback.Issue.Category.allCases.contains(.stereotypeOrBias)) + #expect(LanguageModelFeedback.Issue.Category.allCases.contains(.suggestiveOrSexual)) + #expect(LanguageModelFeedback.Issue.Category.allCases.contains(.vulgarOrOffensive)) + #expect(LanguageModelFeedback.Issue.Category.allCases.contains(.triggeredGuardrailUnexpectedly)) + } + + @Test func issueInitializerStoresCategoryAndExplanation() { + let issue = LanguageModelFeedback.Issue( + category: .tooVerbose, + explanation: "Response includes extra paragraphs." + ) + + #expect(issue.category == .tooVerbose) + #expect(issue.explanation == "Response includes extra paragraphs.") + } + + @Test func feedbackInitializerStoresSentimentAndIssues() { + let issue = LanguageModelFeedback.Issue(category: .incorrect, explanation: nil) + let feedback = LanguageModelFeedback(sentiment: .negative, issues: [issue]) + + #expect(feedback.sentiment == .negative) + #expect(feedback.issues.count == 1) + #expect(feedback.issues.first?.category == .incorrect) + #expect(feedback.issues.first?.explanation == nil) + } +} diff --git a/Tests/AnyLanguageModelTests/PromptTests.swift b/Tests/AnyLanguageModelTests/PromptTests.swift new file mode 100644 index 0000000..b77442f --- /dev/null +++ b/Tests/AnyLanguageModelTests/PromptTests.swift @@ -0,0 +1,50 @@ +import Testing + +@testable import AnyLanguageModel + +@Suite("Prompt") +struct PromptTests { + @Test func initializesFromStringRepresentable() { + let prompt = Prompt("Hello") + #expect(prompt.description == "Hello") + } + + @Test func initializesFromExistingPromptRepresentable() { + let existing = Prompt("Existing") + let wrapped = Prompt(existing) + #expect(wrapped.description == "Existing") + } + + @Test func initializesFromArrayRepresentable() { + let prompt = Prompt(["One", "Two", "Three"]) + #expect(prompt.description == "One\nTwo\nThree") + } + + @Test func builderSupportsConditionalsAndOptionals() { + let includeExtra = true + let maybeLine: String? = nil + + let prompt = Prompt { + "Always" + if includeExtra { + "Conditional" + } else { + "Other" + } + if let maybeLine { + maybeLine + } + } + + #expect(prompt.description == "Always\nConditional") + } + + @Test func builderTrimsLeadingAndTrailingWhitespaceAndNewlines() { + let prompt = Prompt { + "\n First line" + "Second line\n" + } + + #expect(prompt.description == "First line\nSecond line") + } +} diff --git a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift index adf74d3..e897354 100644 --- a/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift +++ b/Tests/AnyLanguageModelTests/SystemLanguageModelTests.swift @@ -195,21 +195,8 @@ import Testing let numbers = (0 ..< 3).map { _ in Int.random(in: 1 ... 100) } let payload = numbers.map(String.init).joined(separator: ", ") - let firstResponse = try await session.respond( - to: "Remember these numbers: \(payload). Reply with just the numbers." - ) - #expect(!firstResponse.content.isEmpty) - - let secondResponse = try await session.respond( - to: "What numbers did I ask you to remember? Reply with just the numbers." - ) - let repliedNumbers = secondResponse.content - .split { !$0.isNumber } - .compactMap { Int($0) } - if Set(repliedNumbers) != Set(numbers) { - // Guardrails can refuse to repeat exact values - // Verify the prompt was stored instead. - let promptText = session.transcript.compactMap { entry -> String? in + let promptText: () -> String = { + session.transcript.compactMap { entry -> String? in guard case let .prompt(prompt) = entry else { return nil } @@ -222,9 +209,51 @@ import Testing .joined(separator: " ") } .joined(separator: " ") + } + let isGuardrailViolation: (any Error) -> Bool = { error in + guard let generationError = error as? LanguageModelSession.GenerationError else { + return false + } + if case .guardrailViolation = generationError { + return true + } + return false + } + let firstResponse: LanguageModelSession.Response + do { + firstResponse = try await session.respond( + to: "Remember these numbers: \(payload). Reply with just the numbers." + ) + } catch { + if isGuardrailViolation(error) { + #expect(promptText().contains(payload)) + return + } + throw error + } + #expect(!firstResponse.content.isEmpty) + + let secondResponse: LanguageModelSession.Response + do { + secondResponse = try await session.respond( + to: "What numbers did I ask you to remember? Reply with just the numbers." + ) + } catch { + if isGuardrailViolation(error) { + #expect(promptText().contains(payload)) + return + } + throw error + } + let repliedNumbers = secondResponse.content + .split { !$0.isNumber } + .compactMap { Int($0) } + if Set(repliedNumbers) != Set(numbers) { + // Guardrails can refuse to repeat exact values + // Verify the prompt was stored instead. #expect(session.transcript.count >= 4) - #expect(promptText.contains(payload)) + #expect(promptText().contains(payload)) } } diff --git a/Tests/AnyLanguageModelTests/ToolExecutionDelegateTests.swift b/Tests/AnyLanguageModelTests/ToolExecutionDelegateTests.swift index 971692f..1b258e3 100644 --- a/Tests/AnyLanguageModelTests/ToolExecutionDelegateTests.swift +++ b/Tests/AnyLanguageModelTests/ToolExecutionDelegateTests.swift @@ -58,6 +58,8 @@ private struct ThrowingTool: Tool { private enum ThrowingToolError: Error, Equatable { case testError } +private struct DefaultToolExecutionDelegate: ToolExecutionDelegate {} + private struct ToolCallingTestModel: LanguageModel { typealias UnavailableReason = Never @@ -194,6 +196,66 @@ private struct ToolCallingTestModel: LanguageModel { @Suite("ToolExecutionDelegate") struct ToolExecutionDelegateTests { + @Test func defaultDelegateUsesExecuteDecisionAndNoOpCallbacks() async throws { + let arguments = try GeneratedContent(json: #"{"city":"Cupertino"}"#) + let toolCall = Transcript.ToolCall(id: "call-default", toolName: WeatherTool().name, arguments: arguments) + let toolSpy = spy(on: WeatherTool()) + let session = LanguageModelSession( + model: ToolCallingTestModel(toolCalls: [toolCall]), + tools: [toolSpy] + ) + session.toolExecutionDelegate = DefaultToolExecutionDelegate() + + let response = try await session.respond(to: "Hi") + let calls = await toolSpy.calls + + #expect(calls.count == 1) + #expect( + response.transcriptEntries.contains { entry in + if case .toolOutput = entry { return true } + return false + } + ) + } + + @Test func defaultDelegateNoOpFailureCallbackDoesNotInterfereWithErrorPropagation() async throws { + let arguments = try GeneratedContent(json: #"{"message":"fail"}"#) + let toolCall = Transcript.ToolCall(id: "call-default-fail", toolName: ThrowingTool().name, arguments: arguments) + let session = LanguageModelSession( + model: ToolCallingTestModel(toolCalls: [toolCall]), + tools: [ThrowingTool()] + ) + session.toolExecutionDelegate = DefaultToolExecutionDelegate() + + await #expect(throws: LanguageModelSession.ToolCallError.self) { + _ = try await session.respond(to: "Hi") + } + } + + @Test func toolExecutionDecisionCasesCanBeConstructed() { + let execute = ToolExecutionDecision.execute + let stop = ToolExecutionDecision.stop + let provide = ToolExecutionDecision.provideOutput([.text(.init(content: "stub"))]) + + if case .execute = execute { + #expect(Bool(true)) + } else { + Issue.record("Expected .execute") + } + + if case .stop = stop { + #expect(Bool(true)) + } else { + Issue.record("Expected .stop") + } + + if case .provideOutput(let segments) = provide { + #expect(segments.count == 1) + } else { + Issue.record("Expected .provideOutput") + } + } + @Test func stopAfterToolCalls() async throws { let arguments = try GeneratedContent(json: #"{"city":"Cupertino"}"#) let toolCall = Transcript.ToolCall(id: "call-1", toolName: WeatherTool().name, arguments: arguments) diff --git a/Tests/AnyLanguageModelTests/TranscriptTests.swift b/Tests/AnyLanguageModelTests/TranscriptTests.swift new file mode 100644 index 0000000..cb3ac94 --- /dev/null +++ b/Tests/AnyLanguageModelTests/TranscriptTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing + +@testable import AnyLanguageModel + +@Suite("Transcript") +struct TranscriptTests { + @Generable + struct Person { + var name: String + } + + @Test func entryIDRoutesToAssociatedValues() throws { + let instructions = Transcript.Instructions( + id: "instructions-id", + segments: [.text(.init(id: "instructions-segment", content: "Be concise"))], + toolDefinitions: [] + ) + let prompt = Transcript.Prompt( + id: "prompt-id", + segments: [.text(.init(id: "prompt-segment", content: "Hello"))] + ) + let arguments = try GeneratedContent(json: #"{"city":"Cupertino"}"#) + let toolCall = Transcript.ToolCall(id: "call-id", toolName: "getWeather", arguments: arguments) + let toolCalls = Transcript.ToolCalls(id: "tool-calls-id", [toolCall]) + let toolOutput = Transcript.ToolOutput( + id: "tool-output-id", + toolName: "getWeather", + segments: [.text(.init(id: "tool-output-segment", content: "Sunny"))] + ) + let response = Transcript.Response( + id: "response-id", + assetIDs: [], + segments: [.text(.init(id: "response-segment", content: "Done"))] + ) + + #expect(Transcript.Entry.instructions(instructions).id == "instructions-id") + #expect(Transcript.Entry.prompt(prompt).id == "prompt-id") + #expect(Transcript.Entry.toolCalls(toolCalls).id == "tool-calls-id") + #expect(Transcript.Entry.toolOutput(toolOutput).id == "tool-output-id") + #expect(Transcript.Entry.response(response).id == "response-id") + } + + @Test func segmentIDRoutesToAssociatedValues() throws { + let text = Transcript.TextSegment(id: "text-id", content: "Hello") + let structured = Transcript.StructuredSegment( + id: "structured-id", + source: "source", + content: try GeneratedContent(json: #"{"ok":true}"#) + ) + let image = Transcript.ImageSegment(id: "image-id", url: URL(string: "https://example.com/image.png")!) + + #expect(Transcript.Segment.text(text).id == "text-id") + #expect(Transcript.Segment.structure(structured).id == "structured-id") + #expect(Transcript.Segment.image(image).id == "image-id") + } + + @Test func imageSourceRoundTripsForDataAndURL() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let dataSource = Transcript.ImageSegment.Source.data(Data([0xDE, 0xAD]), mimeType: "image/png") + let encodedDataSource = try encoder.encode(dataSource) + let decodedDataSource = try decoder.decode(Transcript.ImageSegment.Source.self, from: encodedDataSource) + #expect(decodedDataSource == dataSource) + + let urlSource = Transcript.ImageSegment.Source.url(URL(string: "https://example.com/a.jpg")!) + let encodedURLSource = try encoder.encode(urlSource) + let decodedURLSource = try decoder.decode(Transcript.ImageSegment.Source.self, from: encodedURLSource) + #expect(decodedURLSource == urlSource) + } + + @Test func imageSourceDecodeThrowsForUnknownKind() { + let invalid = #"{"kind":"unknown"}"#.data(using: .utf8)! + let decoder = JSONDecoder() + + do { + _ = try decoder.decode(Transcript.ImageSegment.Source.self, from: invalid) + Issue.record("Expected decoding to fail for unknown kind") + } catch let error as DecodingError { + if case .dataCorrupted = error { + #expect(Bool(true)) + } else { + Issue.record("Expected dataCorrupted, got \(error)") + } + } catch { + Issue.record("Expected DecodingError, got \(error)") + } + } + + @Test func responseFormatNameExtractsRefTypeNameOrFallsBack() { + let refFormat = Transcript.ResponseFormat(type: Person.self) + #expect(refFormat.name.contains("Person")) + + let inlineSchema = GenerationSchema(type: String.self, anyOf: ["a", "b"]) + let fallbackFormat = Transcript.ResponseFormat(schema: inlineSchema) + #expect(fallbackFormat.name == "response") + } + + @Test func responseFormatAndToolDefinitionEquatableBehavior() { + let firstInlineSchema = GenerationSchema(type: String.self, anyOf: ["a"]) + let secondInlineSchema = GenerationSchema(type: String.self, anyOf: ["b"]) + + let firstFormat = Transcript.ResponseFormat(schema: firstInlineSchema) + let secondFormat = Transcript.ResponseFormat(schema: secondInlineSchema) + #expect(firstFormat == secondFormat) + + let firstToolDefinition = Transcript.ToolDefinition( + name: "tool", + description: "desc", + parameters: firstInlineSchema + ) + let secondToolDefinition = Transcript.ToolDefinition( + name: "tool", + description: "desc", + parameters: secondInlineSchema + ) + #expect(firstToolDefinition == secondToolDefinition) + } +} diff --git a/Tests/AnyLanguageModelTests/URLSessionExtensionsTests.swift b/Tests/AnyLanguageModelTests/URLSessionExtensionsTests.swift new file mode 100644 index 0000000..b672f92 --- /dev/null +++ b/Tests/AnyLanguageModelTests/URLSessionExtensionsTests.swift @@ -0,0 +1,21 @@ +import Testing + +@testable import AnyLanguageModel + +@Suite("URLSession Extensions") +struct URLSessionExtensionsTests { + @Test func invalidResponseDescriptionMatchesExpectedText() { + let error = URLSessionError.invalidResponse + #expect(error.description == "Invalid response") + } + + @Test func httpErrorDescriptionIncludesStatusCodeAndDetail() { + let error = URLSessionError.httpError(statusCode: 429, detail: "rate limit") + #expect(error.description == "HTTP error (Status 429): rate limit") + } + + @Test func decodingErrorDescriptionIncludesDetail() { + let error = URLSessionError.decodingError(detail: "keyNotFound") + #expect(error.description == "Decoding error: keyNotFound") + } +}