Added tests which guard the JSON of providers, the runtime, and services before source generation

This commit is contained in:
Thorsten Sommer committed 2026-11-02 18:42:28 +01:00
1 parent e954881fda
commit 9e5aee766c
31 files changed
+577 -93

No files matched your search

@@ -0,0 +1 @@
{"model":"messages-model","messages":[{"content":"What is the weather?","role":"user"},{"content":[{"type":"text","text":"Compare these pictures."},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0KGgo="}},{"type":"image","source":{"type":"url","url":"https://example.org/picture.png"}}],"role":"user"},{"content":[{"type":"text","text":"Let me search."},{"type":"tool_use","id":"toolu_1","name":"web_search","input":{"query":"weather"}}],"role":"assistant"},{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"It is sunny.","is_error":true}],"role":"user"}],"max_tokens":1024,"stream":true,"system":"You answer briefly.","tools":[{"name":"web_search","description":"Search the web.","strict":true,"input_schema":{"type":"object","properties":{"query":{"type":"string","description":"The search query."},"page":{"type":"integer","description":"The page, starting at 1."}},"required":["query"],"additionalProperties":false}}],"temperature":0.5,"top_k":40,"seed":12345678901,"stop":["END",7],"reasoning":{"effort":"low","summary":""},"logprobs":false}
@@ -0,0 +1 @@
{"model":"chat-model","messages":[{"content":"You answer briefly.","role":"system"},{"content":[{"type":"text","text":"What do the pictures show?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}},{"type":"image_url","image_url":"https://example.org/picture.png"}],"role":"user"},{"role":"assistant","content":null,"reasoning_content":"The weather needs a search.","tool_calls":[{"id":"call_1","type":"function","function":{"name":"web_search","arguments":"{\u0022query\u0022:\u0022weather\u0022}"},"index":0}]},{"role":"tool","content":"It is sunny.","tool_call_id":"call_1"}],"stream":true,"tools":[{"type":"function","function":{"name":"web_search","description":"Search the web.","parameters":{"type":"object","properties":{"query":{"type":"string","description":"The search query."},"page":{"type":["integer","null"],"description":"The page, starting at 1."}},"required":["query","page"],"additionalProperties":false},"strict":true}}],"parallel_tool_calls":false,"stream_options":{"include_usage":true},"temperature":0.5,"top_k":40,"seed":12345678901,"stop":["END",7],"reasoning":{"effort":"low","summary":""},"logprobs":false}
@@ -0,0 +1 @@
{"model":"responses-model","input":[{"content":"You answer briefly.","role":"developer"},{"content":[{"type":"input_text","text":"What does the picture show?"},{"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgo="}],"role":"user"},{"type":"function_call","id":"fc_1","call_id":"call_1","name":"web_search","arguments":"{\u0022query\u0022:\u0022weather\u0022}"},{"type":"function_call_output","call_id":"call_1","output":"It is sunny."}],"stream":true,"store":false,"tools":[{"type":"function","name":"web_search","description":"Search the web.","parameters":{"type":"object","properties":{"query":{"type":"string","description":"The search query."},"page":{"type":["integer","null"],"description":"The page, starting at 1."}},"required":["query","page"],"additionalProperties":false},"strict":true}],"temperature":0.5,"top_k":40,"seed":12345678901,"stop":["END",7],"reasoning":{"effort":"low","summary":""},"logprobs":false}
@@ -12,6 +12,12 @@ namespace AIStudio.Tests.Serialization;
public sealed class JsonContractTests
{
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.Stored))]
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderRequests))]
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderAnswers))]
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeRequests))]
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeAnswers))]
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceRequests))]
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceAnswers))]
public void TheContractsMatchThoseOfReflection(JsonTestRoot root)
{
var baseline = JsonReflectionBaseline.Of(root.Options);
+18 -7
View File
@@ -7,11 +7,12 @@ namespace AIStudio.Tests.Serialization;
/// Checks that a property missing in the JSON keeps the value a new object starts with.
/// </summary>
/// <remarks>
/// Files written by an older version of AI Studio lack every property added since. Reading them has to give
/// those properties their defaults, as a new object has them, e.g., a sub-object of the settings instead of
/// null. Source generation in .NET 10 breaks this for init-only properties: it sets them all while creating
/// the object, and a property missing in the JSON gets the default of its type instead (dotnet/runtime#84484).
/// Hence, the types AI Studio stores use set instead of init.<br/><br/>
/// Files written by an older version of AI Studio lack every property added since, and an answer of a
/// provider, of the runtime, or of a service leaves out whatever it has nothing to say about. Reading them has
/// to give those properties their defaults, as a new object has them, e.g., a sub-object of the settings
/// instead of null. Source generation in .NET 10 breaks this for init-only properties: it sets them all while
/// creating the object, and a property missing in the JSON gets the default of its type instead
/// (dotnet/runtime#84484). Hence, the types AI Studio reads use set instead of init.<br/><br/>
///
/// A new object is created the way reflection creates it: with its parameterless constructor, or, for a
/// struct, as the default of the struct. Source generation passes the values of a positional record struct
@@ -21,6 +22,9 @@ namespace AIStudio.Tests.Serialization;
public sealed class JsonDefaultsTests
{
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.Stored))]
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderAnswers))]
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeAnswers))]
[TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceAnswers))]
public void AnEmptyObjectGetsTheDefaultsOfANewOne(JsonTestRoot root)
{
var baseline = JsonReflectionBaseline.Of(root.Options);
@@ -60,7 +64,14 @@ public sealed class JsonDefaultsTests
Assert.That(failures, Is.Empty);
}
private static string ValueAsJson(JsonPropertyInfo property, object instance, JsonSerializerOptions options) => JsonSerializer.Serialize(property.Get!(instance), property.PropertyType, options);
private static string ValueAsJson(JsonPropertyInfo property, object instance, JsonSerializerOptions options) => AsJson(property.Get!(instance), property.PropertyType, options);
private static string DefaultAsJson(Type type, JsonSerializerOptions options) => type.IsValueType ? JsonSerializer.Serialize(Activator.CreateInstance(type), type, options) : "null";
private static string DefaultAsJson(Type type, JsonSerializerOptions options) => type.IsValueType ? AsJson(Activator.CreateInstance(type), type, options) : "null";
private static string AsJson(object? value, Type type, JsonSerializerOptions options) => value switch
{
// The default of a JsonElement holds no JSON at all, so it cannot be written:
JsonElement { ValueKind: JsonValueKind.Undefined } => "no JSON",
_ => JsonSerializer.Serialize(value, type, options),
};
}
@@ -85,5 +85,5 @@ public sealed class JsonGoldenFileTests
/// <remarks>
/// Read from the source tree, the way the other fixtures are, so the file needs no entry in the project file.
/// </remarks>
private static string ReadFixture(string fileName, [CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "Fixtures", fileName));
internal static string ReadFixture(string fileName, [CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "Fixtures", fileName));
}
@@ -0,0 +1,15 @@
using System.Text.Json;
namespace AIStudio.Tests.Serialization;
/// <summary>
/// A request in the fixtures, together with how AI Studio builds it and the options it writes it with.
/// </summary>
/// <param name="FileName">The name of the file in the folder Fixtures next to the tests, which holds the request as it got sent before.</param>
/// <param name="Create">Builds the request the way AI Studio does.</param>
/// <param name="Options">The options AI Studio writes the request with.</param>
public sealed record JsonGoldenRequest(string FileName, Func<object> Create, JsonSerializerOptions Options)
{
/// <inheritdoc />
public override string ToString() => this.FileName;
}
@@ -0,0 +1,33 @@
using System.Text.Json;
using AIStudio.Provider;
namespace AIStudio.Tests.Serialization;
/// <summary>
/// Writes requests the way AI Studio sends them, and checks that they get sent just as before.
/// </summary>
/// <remarks>
/// The files in the folder Fixtures were written with the contracts of reflection, before AI Studio got
/// contracts of its own. A provider reads every byte of a request, and one which got written otherwise, e.g.,
/// with a field under another name or without the tools, gets refused or misread. The requests are made up,
/// see <see cref="JsonSampleRequests"/>.
/// </remarks>
[TestFixture]
public sealed class JsonGoldenRequestTests
{
private static IEnumerable<JsonGoldenRequest> GoldenRequests()
{
TestHost.PrepareStaticState();
yield return new("chat-completion-request.json", JsonSampleRequests.ChatCompletion, ProviderJsonOptions.OPTIONS);
yield return new("responses-request.json", JsonSampleRequests.Responses, ProviderJsonOptions.OPTIONS);
yield return new("anthropic-messages-request.json", () => JsonSampleRequests.Anthropic(), ProviderJsonOptions.OPTIONS);
}
[TestCaseSource(nameof(GoldenRequests))]
public void ARequestIsSentAsBefore(JsonGoldenRequest goldenRequest)
{
var request = goldenRequest.Create();
Assert.That(JsonSerializer.Serialize(request, request.GetType(), goldenRequest.Options), Is.EqualTo(JsonGoldenFileTests.ReadFixture(goldenRequest.FileName)));
}
}
@@ -0,0 +1,143 @@
using System.Text.Json;
using AIStudio.Provider;
using AIStudio.Provider.Anthropic;
using AIStudio.Provider.OpenAI;
using AIStudio.Tools.ToolCallingSystem;
using AnthropicImageUrl = AIStudio.Provider.Anthropic.SubContentImageUrl;
using OpenAIImageUrl = AIStudio.Provider.OpenAI.SubContentImageUrl;
namespace AIStudio.Tests.Serialization;
/// <summary>
/// Requests to LLM providers as AI Studio builds them, with every kind of content they carry.
/// </summary>
/// <remarks>
/// The values are made up. What counts is that every part of a request holds one, so that its golden file shows
/// how each part gets written.
/// </remarks>
public static class JsonSampleRequests
{
/// <summary>
/// A tool with one required and one optional argument, the shape the web search has.
/// </summary>
public static ToolDefinition Tool() => new()
{
Id = "web_search",
Function = new()
{
Name = "web_search",
DescriptionForLLM = "Search the web.",
Strict = true,
Parameters = ToolParameterSchemaBuilder.Create()
.RequiredString("query", "The search query.")
.OptionalInteger("page", "The page, starting at 1.")
.Build(),
},
};
/// <summary>
/// A request to a Chat Completions API, with a tool, a call of it, and its result.
/// </summary>
public static ChatCompletionAPIRequest ChatCompletion() => new("chat-model", Messages(), true)
{
Tools = [ProviderToolAdapters.ToChatCompletionTool(Tool(), hostEnforcesStrict: true)],
ParallelToolCalls = false,
AdditionalApiParameters = ApiParameters(),
};
/// <summary>
/// A request to the Responses API of OpenAI, with an item of an earlier answer and the result of a tool.
/// </summary>
public static ResponsesAPIRequest Responses() => new("responses-model",
[
new TextMessage("You answer briefly.", "developer"),
new MultimodalMessage(
[
new SubContentInputText(SubContentType.INPUT_TEXT, "What does the picture show?"),
new SubContentInputImage(SubContentType.INPUT_IMAGE, "data:image/png;base64,iVBORw0KGgo="),
], "user"),
Parse("""{"type":"function_call","id":"fc_1","call_id":"call_1","name":"web_search","arguments":"{\"query\":\"weather\"}"}"""),
new ResponsesFunctionCallOutputItem { CallId = "call_1", Output = "It is sunny." },
],
true, false, [ProviderToolAdapters.ToResponsesTool(Tool())])
{
AdditionalApiParameters = ApiParameters(),
};
/// <summary>
/// A request to the Messages API of Anthropic, with images from a URL and as data, and a tool round.
/// </summary>
public static ChatRequest Anthropic() => new("messages-model",
[
new TextMessage("What is the weather?", "user"),
new MultimodalMessage(
[
new SubContentText(SubContentType.TEXT, "Compare these pictures."),
new SubContentImage(SubContentType.IMAGE, new SubContentBase64Image { MediaType = "image/png", Data = "iVBORw0KGgo=" }),
new SubContentImage(SubContentType.IMAGE, new AnthropicImageUrl { Url = "https://example.org/picture.png" }),
], "user"),
new AnthropicMessage(
[
Parse("""{"type":"text","text":"Let me search."}"""),
Parse("""{"type":"tool_use","id":"toolu_1","name":"web_search","input":{"query":"weather"}}"""),
]),
new AnthropicToolResultMessage([new AnthropicToolResultContent { ToolUseId = "toolu_1", Content = "It is sunny.", IsError = true }]),
],
1024, true, "You answer briefly.")
{
Tools = [ProviderToolAdapters.ToAnthropicTool(Tool())],
AdditionalApiParameters = ApiParameters(),
};
private static List<IMessageBase> Messages() =>
[
new TextMessage("You answer briefly.", "system"),
new MultimodalMessage(
[
new SubContentText(SubContentType.TEXT, "What do the pictures show?"),
new SubContentImageUrlNested(SubContentType.IMAGE_URL, new SubContentImageUrlData { Url = "data:image/png;base64,iVBORw0KGgo=" }),
new OpenAIImageUrl(SubContentType.IMAGE_URL, "https://example.org/picture.png"),
], "user"),
new AssistantToolCallMessage
{
Content = Parse("null"),
ReasoningContent = "The weather needs a search.",
ToolCalls =
[
new ChatCompletionToolCall
{
Id = "call_1",
Function = new() { Name = "web_search", Arguments = """{"query":"weather"}""" },
AdditionalMetadata = { ["index"] = Parse("0") },
},
],
},
new ToolResultMessage { Content = "It is sunny.", ToolCallId = "call_1" },
];
/// <summary>
/// Additional API parameters of every kind, read the way the provider settings read them.
/// </summary>
private static IDictionary<string, object> ApiParameters()
{
if (!AdditionalApiParametersParser.TryParse("""
"temperature": 0.5,
"top_k": 40,
"seed": 12345678901,
"stop": ["END", 7],
"reasoning": { "effort": "low", "summary": null },
"logprobs": false
""", out var parameters, out var errorMessage))
throw new InvalidOperationException(errorMessage);
return parameters;
}
private static JsonElement Parse(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
}
@@ -0,0 +1,94 @@
using System.Text.Json;
using AIStudio.Provider;
using AIStudio.Provider.Anthropic;
using AIStudio.Provider.Google;
using AIStudio.Provider.Groq;
using AIStudio.Provider.HuggingFace;
using AIStudio.Provider.OpenAI;
using AIStudio.Provider.OpenRouter;
using AIStudio.Provider.Requesty;
using AnthropicResponseStreamLine = AIStudio.Provider.Anthropic.ResponseStreamLine;
using FireworksResponseStreamLine = AIStudio.Provider.Fireworks.ResponseStreamLine;
using PerplexityResponseStreamLine = AIStudio.Provider.Perplexity.ResponseStreamLine;
namespace AIStudio.Tests.Serialization;
public static partial class JsonTestRoots
{
/// <summary>
/// What AI Studio sends to LLM providers.
/// </summary>
public static IEnumerable<TestCaseData> ProviderRequests()
{
TestHost.PrepareStaticState();
var options = ProviderJsonOptions.OPTIONS;
yield return CaseWithNamespace(typeof(ChatCompletionAPIRequest), options);
yield return CaseWithNamespace(typeof(ResponsesAPIRequest), options);
yield return CaseWithNamespace(typeof(ChatRequest), options);
//
// The messages and their parts, which converters write as the type they are of:
//
foreach (var type in new[] { typeof(IMessageBase), typeof(ISubContent), typeof(ISubContentImageUrl), typeof(ISubContentImageSource) }.SelectMany(ImplementationsOf))
yield return CaseWithNamespace(type, options);
//
// What a request holds as an object, which gets written as the type it is of, too. The additional API
// parameters hold the values the parser of the provider settings reads, and the input of the Responses API
// holds the items of an earlier answer as they came in:
//
foreach (var type in new[] { typeof(string), typeof(int), typeof(long), typeof(double), typeof(decimal), typeof(bool), typeof(Dictionary<string, object>), typeof(List<object>), typeof(JsonElement) })
yield return Case(new(type, options), "value of a request");
yield return Case(new(ProviderToolAdapters.ToChatCompletionTool(JsonSampleRequests.Tool(), hostEnforcesStrict: true).GetType(), options), "tool of a Chat Completions API");
yield return CaseWithNamespace(typeof(ResponsesFunctionTool), options);
yield return CaseWithNamespace(typeof(ResponsesFunctionCallOutputItem), options);
}
/// <summary>
/// What AI Studio receives from LLM providers.
/// </summary>
public static IEnumerable<TestCaseData> ProviderAnswers()
{
TestHost.PrepareStaticState();
var options = ProviderJsonOptions.OPTIONS;
// The lists of models:
yield return CaseWithNamespace(typeof(AIStudio.Provider.OpenAI.ModelsResponse), options);
yield return CaseWithNamespace(typeof(AIStudio.Provider.Mistral.ModelsResponse), options);
yield return CaseWithNamespace(typeof(AIStudio.Provider.SelfHosted.ModelsResponse), options);
yield return CaseWithNamespace(typeof(AIStudio.Provider.HuggingFace.ModelsResponse), options);
yield return Case(new(typeof(IList<HubModel>), options), "HuggingFace");
yield return CaseWithNamespace(typeof(RequestyModelsResponse), options);
yield return CaseWithNamespace(typeof(OpenRouterModelsResponse), options);
yield return CaseWithNamespace(typeof(GroqModelsResponse), options);
// The lines of streamed answers:
yield return CaseWithNamespace(typeof(ChatCompletionDeltaStreamLine), options);
yield return CaseWithNamespace(typeof(ChatCompletionAnnotationStreamLine), options);
yield return CaseWithNamespace(typeof(ChatCompletionToolStreamLine), options);
yield return CaseWithNamespace(typeof(ResponsesDeltaStreamLine), options);
yield return CaseWithNamespace(typeof(ResponsesAnnotationStreamLine), options);
yield return CaseWithNamespace(typeof(ResponsesCompletedStreamLine), options);
yield return CaseWithNamespace(typeof(AnthropicResponseStreamLine), options);
yield return CaseWithNamespace(typeof(AnthropicStreamLine), options);
yield return CaseWithNamespace(typeof(FireworksResponseStreamLine), options);
yield return CaseWithNamespace(typeof(PerplexityResponseStreamLine), options);
//
// The annotations, which a converter reads as the type their JSON tells:
//
foreach (var type in ImplementationsOf(typeof(Annotation)))
yield return CaseWithNamespace(type, options);
// Transcriptions and embeddings, where a converter reads the embeddings of Google as one or as a list:
yield return CaseWithNamespace(typeof(TranscriptionResponse), options);
yield return CaseWithNamespace(typeof(EmbeddingResponse), options);
yield return CaseWithNamespace(typeof(GoogleEmbeddingResponse), options);
yield return CaseWithNamespace(typeof(GoogleEmbedding), options);
yield return Case(new(typeof(List<GoogleEmbedding>), options), "Google");
}
}
@@ -0,0 +1,103 @@
using System.Reflection;
using System.Text.Json;
using AIStudio.Tools;
using AIStudio.Tools.Databases.VectorStore;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
namespace AIStudio.Tests.Serialization;
public static partial class JsonTestRoots
{
/// <summary>
/// What AI Studio sends to its runtime.
/// </summary>
public static IEnumerable<TestCaseData> RuntimeRequests()
{
TestHost.PrepareStaticState();
var options = RustService.JSON_OPTIONS;
yield return Case(new(typeof(SelectSecretRequest), options));
yield return Case(new(typeof(StoreSecretRequest), options));
yield return Case(new(typeof(RustService.ShareFileRequest), options));
yield return Case(new(typeof(PreviousDirectory), options));
yield return Case(new(typeof(SelectFileOptions), options));
yield return Case(new(typeof(SaveFileOptions), options));
yield return Case(new(typeof(OpenPathRequest), options));
yield return Case(new(typeof(OpenDocumentRequest), options));
yield return Case(new(typeof(RegisterShortcutRequest), options));
yield return Case(new(typeof(ValidateShortcutRequest), options));
yield return Case(new(typeof(SetInterfaceSizeRequest), options));
yield return Case(new(typeof(CreateMediaJobRequest), options));
yield return Case(new(typeof(LogEventRequest), options));
// Every request of the vector store:
foreach (var type in typeof(QdrantEdgeClientImplementation).GetNestedTypes(BindingFlags.NonPublic).Where(type => type.Name.EndsWith("Request", StringComparison.Ordinal)).OrderBy(type => type.Name, StringComparer.Ordinal))
yield return Case(new(type, options));
// The prompt injection filter, which gets called with the defaults of HttpClient, i.e., with camel case:
yield return Case(new(typeof(SanitizePromptInjectionsRequest), JsonSerializerOptions.Web));
yield return Case(new(typeof(SanitizePromptInjectionsBatchRequest), JsonSerializerOptions.Web));
}
/// <summary>
/// What AI Studio receives from its runtime.
/// </summary>
public static IEnumerable<TestCaseData> RuntimeAnswers()
{
TestHost.PrepareStaticState();
var options = RustService.JSON_OPTIONS;
yield return Case(new(typeof(RequestedSecret), options));
yield return Case(new(typeof(StoreSecretResponse), options));
yield return Case(new(typeof(DeleteSecretResponse), options));
yield return Case(new(typeof(ShareFileResponse), options));
yield return Case(new(typeof(ImagePrepareResponse), options));
yield return Case(new(typeof(PreparedModelImage), options));
yield return Case(new(typeof(TauriEvent), options));
yield return Case(new(typeof(UpdateResponse), options));
yield return Case(new(typeof(SetClipboardResponse), options));
yield return Case(new(typeof(DirectorySelectionResponse), options));
yield return Case(new(typeof(FileSelectionResponse), options));
yield return Case(new(typeof(FilesSelectionResponse), options));
yield return Case(new(typeof(FileSaveResponse), options));
yield return Case(new(typeof(OpenPathResponse), options));
yield return Case(new(typeof(OpenDocumentResponse), options));
yield return Case(new(typeof(ShortcutRegistrationResult), options));
yield return Case(new(typeof(ShortcutValidationResponse), options));
yield return Case(new(typeof(ShortcutResponse), options));
yield return Case(new(typeof(RuntimeInfoResponse), options));
yield return Case(new(typeof(AppExitResponse), options));
yield return Case(new(typeof(AppRestartResponse), options));
yield return Case(new(typeof(InterfaceSizeResponse), options));
yield return Case(new(typeof(CreateMediaJobResponse), options));
yield return Case(new(typeof(MediaJobEvent), options));
yield return Case(new(typeof(List<EnterpriseConfig>), options));
yield return Case(new(typeof(GetLogPathsResponse), options));
yield return Case(new(typeof(TokenizerResponse), options));
// The vector store, with the result of every query its client sends:
yield return Case(new(typeof(QdrantEdgeInfo), options));
yield return Case(new(typeof(RustService.DatabaseOperationResponse), options));
yield return Case(new(typeof(RustService.DatabaseQueryResponse<VectorStoreEnsureResult>), options));
yield return Case(new(typeof(RustService.DatabaseQueryResponse<bool?>), options));
yield return Case(new(typeof(RustService.DatabaseQueryResponse<List<VectorSearchResult>>), options));
yield return Case(new(typeof(RustService.DatabaseQueryResponse<List<StoredVector>>), options));
yield return Case(new(typeof(RustService.DatabaseQueryResponse<VectorStoreBackupPreparation>), options));
yield return Case(new(typeof(RustService.DatabaseQueryResponse<long?>), options));
//
// The events of a file extraction, which get read with the defaults of System.Text.Json. A converter reads
// their metadata as the type the JSON tells:
//
yield return Case(new(typeof(ContentStreamSseEvent), JsonSerializerOptions.Default));
yield return Case(new(typeof(string), JsonSerializerOptions.Default), "error of a file extraction");
foreach (var type in ImplementationsOf(typeof(ContentStreamSseMetadata)))
yield return Case(new(type, JsonSerializerOptions.Default));
// The prompt injection filter, which gets called with the defaults of HttpClient, i.e., with camel case:
yield return Case(new(typeof(SanitizePromptInjectionsResponse), JsonSerializerOptions.Web));
yield return Case(new(typeof(SanitizePromptInjectionsBatchResponse), JsonSerializerOptions.Web));
}
}
@@ -0,0 +1,38 @@
using AIStudio.Tools.ERIClient;
using AIStudio.Tools.ERIClient.DataModel;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Staan;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Tavily;
namespace AIStudio.Tests.Serialization;
public static partial class JsonTestRoots
{
/// <summary>
/// What AI Studio sends to ERI servers and to search services.
/// </summary>
public static IEnumerable<TestCaseData> ServiceRequests()
{
TestHost.PrepareStaticState();
yield return Case(new(typeof(RetrievalRequest), ERIClientBase.JSON_OPTIONS));
yield return Case(new(typeof(StaanSearchRequest), WebSearchJson.OPTIONS));
yield return Case(new(typeof(TavilySearchRequest), WebSearchJson.OPTIONS));
}
/// <summary>
/// What AI Studio receives from ERI servers and from search services.
/// </summary>
public static IEnumerable<TestCaseData> ServiceAnswers()
{
TestHost.PrepareStaticState();
yield return Case(new(typeof(List<AuthScheme>), ERIClientBase.JSON_OPTIONS));
yield return Case(new(typeof(AuthResponse), ERIClientBase.JSON_OPTIONS));
yield return Case(new(typeof(DataSourceInfo), ERIClientBase.JSON_OPTIONS));
yield return Case(new(typeof(List<EmbeddingInfo>), ERIClientBase.JSON_OPTIONS));
yield return Case(new(typeof(List<RetrievalInfo>), ERIClientBase.JSON_OPTIONS));
yield return Case(new(typeof(List<Context>), ERIClientBase.JSON_OPTIONS));
yield return Case(new(typeof(SecurityRequirements), ERIClientBase.JSON_OPTIONS));
yield return Case(new(typeof(StaanSearchResponse), WebSearchJson.OPTIONS));
yield return Case(new(typeof(TavilySearchResponse), WebSearchJson.OPTIONS));
}
}
+27 -2
View File
@@ -1,3 +1,5 @@
using System.Text.Json;
using AIStudio.Assistants.SWOTAnalysis;
using AIStudio.Chat;
using AIStudio.Settings;
@@ -15,9 +17,11 @@ namespace AIStudio.Tests.Serialization;
/// </summary>
/// <remarks>
/// Only the outermost types belong here: whatever they hold gets checked along with them. Add a type whenever
/// AI Studio starts to store a new kind of file, with the options it stores the file with.
/// AI Studio starts to store a new kind of file or to exchange a new kind of message, with the options it uses
/// for them. What AI Studio sends is listed apart from what it receives: only what it reads has to keep the
/// defaults of a new object.
/// </remarks>
public static class JsonTestRoots
public static partial class JsonTestRoots
{
/// <summary>
/// The data AI Studio keeps on the disk of the user.
@@ -67,6 +71,27 @@ public static class JsonTestRoots
return new TestCaseData(root).SetArgDisplayNames(purpose is null ? root.ToString() : $"{root}, {purpose}");
}
/// <summary>
/// Names the case after its type together with the last part of the namespace, which tells apart types of the
/// same name, e.g., the lists of models of two providers.
/// </summary>
private static TestCaseData CaseWithNamespace(Type type, JsonSerializerOptions options)
{
var typeNamespace = type.Namespace!;
return Case(new(type, options), typeNamespace[(typeNamespace.LastIndexOf('.') + 1)..]);
}
/// <summary>
/// Returns the classes and structs of AI Studio which are the given type.
/// </summary>
/// <remarks>
/// A converter which writes a value as the type it is of meets these types without any property naming them.
/// Listing them all here keeps a new one from going unchecked.
/// </remarks>
private static IEnumerable<Type> ImplementationsOf(Type type) => typeof(Program).Assembly.GetTypes()
.Where(candidate => candidate is { IsAbstract: false, IsInterface: false, IsGenericTypeDefinition: false } && type.IsAssignableFrom(candidate))
.OrderBy(candidate => candidate.FullName, StringComparer.Ordinal);
private static IEnumerable<Type> ManagedSettingValueTypes()
{
//