diff --git a/Core/CivitaiSharp.Core.csproj b/Core/CivitaiSharp.Core.csproj index 63f8841..4eb3857 100644 --- a/Core/CivitaiSharp.Core.csproj +++ b/Core/CivitaiSharp.Core.csproj @@ -8,6 +8,7 @@ true true + true true diff --git a/Documentation/Guides/Configuration/SdkConfiguration.cs b/Documentation/Guides/Configuration/SdkConfiguration.cs index 9514df5..8c0192f 100644 --- a/Documentation/Guides/Configuration/SdkConfiguration.cs +++ b/Documentation/Guides/Configuration/SdkConfiguration.cs @@ -24,7 +24,7 @@ public static void ConfigureSdk(string[] args) var host = builder.Build(); - var sdkClient = host.Services.GetRequiredService(); + var sdkClient = host.Services.GetRequiredService(); Console.WriteLine("SDK configuration example completed."); } } diff --git a/Documentation/Guides/air-builder.md b/Documentation/Guides/air-builder.md new file mode 100644 index 0000000..e791d67 --- /dev/null +++ b/Documentation/Guides/air-builder.md @@ -0,0 +1,385 @@ +--- +title: AIR Builder +description: Learn how to use the AirBuilder to create AIR (Artificial Intelligence Resource) identifiers with a fluent API. +--- + +# AIR Builder + +The `AirBuilder` class provides a fluent, validated approach to constructing AIR (Artificial Intelligence Resource) identifiers for Civitai models and assets. It ensures all required properties are set and validates input before building the identifier. + +## Overview + +AIR identifiers uniquely identify AI model assets across different ecosystems and platforms. The format is: + +``` +urn:air:{ecosystem}:{type}:{source}:{modelId}@{versionId} +``` + +Example: +``` +urn:air:sdxl:lora:civitai:328553@368189 +``` + +## Getting Started + +### Installation + +The `AirBuilder` is part of the `CivitaiSharp.Sdk` package. + +```bash +dotnet add package CivitaiSharp.Sdk --prerelease +``` + +### Basic Usage + +```csharp +using CivitaiSharp.Sdk.Air; + +// Build an AIR identifier using the fluent API +var builder = new AirBuilder(); + +var airId = builder + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(328553) + .WithVersionId(368189) + .Build(); + +Console.WriteLine(airId.ToString()); +// Output: urn:air:sdxl:lora:civitai:328553@368189 +``` + +## Builder Methods + +### WithEcosystem + +Sets the model ecosystem (required): + +```csharp +builder.WithEcosystem(AirEcosystem.StableDiffusionXl); +builder.WithEcosystem(AirEcosystem.Flux1); +builder.WithEcosystem(AirEcosystem.Pony); +``` + +Available ecosystems: +- `StableDiffusion1` - Stable Diffusion 1.x (sd1) +- `StableDiffusion2` - Stable Diffusion 2.x (sd2) +- `StableDiffusionXl` - Stable Diffusion XL (sdxl) +- `Flux1` - FLUX.1 (flux1) +- `Pony` - Pony Diffusion (pony) + +### WithAssetType + +Sets the asset type (required): + +```csharp +builder.WithAssetType(AirAssetType.Lora); +builder.WithAssetType(AirAssetType.Checkpoint); +builder.WithAssetType(AirAssetType.Vae); +``` + +Available asset types: +- `Checkpoint` - Full model checkpoint +- `Lora` - LoRA (Low-Rank Adaptation) +- `Lycoris` - LyCORIS network +- `Vae` - VAE (Variational Autoencoder) +- `Embedding` - Textual Inversion embedding +- `Hypernetwork` - Hypernetwork + +### WithSource + +Sets the source platform (optional, defaults to `AirSource.Civitai`): + +```csharp +// Explicitly set source (usually not needed) +builder.WithSource(AirSource.Civitai); +``` + +### WithModelId + +Sets the model ID (required): + +```csharp +builder.WithModelId(328553); + +// Must be greater than 0 +``` + +### WithVersionId + +Sets the version ID (required): + +```csharp +builder.WithVersionId(368189); + +// Must be greater than 0 +``` + +### Reset / Reuse + +The `AirBuilder` is immutable and thread-safe: each `With*` method returns a new builder instance. There is no instance `Reset()` method. To "reset" or reuse a base configuration, either create a new `AirBuilder()` or keep a reusable base instance and call the fluent methods which return new instances. + +```csharp +// Start from a base builder and derive per-item builders (recommended) +var baseBuilder = new AirBuilder() + .WithEcosystem(AirEcosystem.Flux1) + .WithAssetType(AirAssetType.Lora); + +// For a new identifier, derive from the base and set IDs +var air1 = baseBuilder + .WithModelId(123) + .WithVersionId(456) + .Build(); + +// To "reset", simply start from a fresh builder or reuse baseBuilder +var air2 = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Checkpoint) + .WithModelId(789) + .WithVersionId(101) + .Build(); +``` + +### Build + +Constructs the `AirIdentifier` (validates all required properties are set): + +```csharp +var airId = builder.Build(); + +// Throws InvalidOperationException if: +// - Ecosystem is not set +// - AssetType is not set +// - ModelId is not set +// - VersionId is not set +``` + +## Validation + +The builder performs validation at two stages: + +### Input Validation + +Each property setter validates its input: + +```csharp +// ModelId must be > 0 +builder.WithModelId(0); // Throws ArgumentOutOfRangeException + +// VersionId must be > 0 +builder.WithVersionId(-1); // Throws ArgumentOutOfRangeException + +``` + +### Build Validation + +The `Build()` method ensures all required properties are set: + +```csharp +var builder = new AirBuilder() + .WithEcosystem(AirEcosystem.Flux1) + .WithModelId(123); + +// Missing AssetType and VersionId +var airId = builder.Build(); // Throws InvalidOperationException +``` + +## Complete Examples + +### Building from Civitai Model + +```csharp +using CivitaiSharp.Core; +using CivitaiSharp.Sdk.Air; + +public class ModelService(IApiClient apiClient) +{ + public async Task GetAirIdAsync(int modelId) + { + // Fetch model from Civitai + var result = await apiClient.Models.GetByIdAsync(modelId); + if (result is not Result.Success success) + return null; + + var model = success.Data; + var version = model.ModelVersions?.FirstOrDefault(); + + if (version is null) + return null; + + // Build AIR identifier + var builder = new AirBuilder(); + return builder + .WithEcosystem(GetEcosystem(version.BaseModel)) + .WithAssetType(GetAssetType(model.Type)) + .WithModelId(model.Id) + .WithVersionId(version.Id) + .Build(); + } + + private AirEcosystem GetEcosystem(string baseModel) => baseModel switch + { + "SD 1.5" => AirEcosystem.StableDiffusion1, + "SDXL 1.0" => AirEcosystem.StableDiffusionXl, + "Flux.1" => AirEcosystem.Flux1, + "Pony" => AirEcosystem.Pony, + _ => AirEcosystem.StableDiffusion1 + }; + + private AirAssetType GetAssetType(ModelType type) => type switch + { + ModelType.Checkpoint => AirAssetType.Checkpoint, + ModelType.Lora => AirAssetType.Lora, + ModelType.Vae => AirAssetType.Vae, + ModelType.TextualInversion => AirAssetType.Embedding, + ModelType.Hypernetwork => AirAssetType.Hypernetwork, + _ => AirAssetType.Checkpoint + }; +} +``` + +### Batch Building + +```csharp +using CivitaiSharp.Sdk.Air; + +public class BatchProcessor +{ + public List BuildMultipleIdentifiers() + { + var builder = new AirBuilder(); + var identifiers = new List(); + + // Build multiple identifiers efficiently + foreach (var (ecosystem, assetType, modelId, versionId) in GetModelData()) + { + var airId = builder + .WithEcosystem(ecosystem) + .WithAssetType(assetType) + .WithModelId(modelId) + .WithVersionId(versionId) + .Build(); + + identifiers.Add(airId); + + } + + return identifiers; + } + + private IEnumerable<(AirEcosystem, AirAssetType, long, long)> GetModelData() + { + yield return (AirEcosystem.StableDiffusionXl, AirAssetType.Lora, 328553, 368189); + yield return (AirEcosystem.Flux1, AirAssetType.Checkpoint, 123456, 789012); + yield return (AirEcosystem.Pony, AirAssetType.Lora, 111111, 222222); + } +} +``` + +### Builder with Error Handling + +```csharp +using CivitaiSharp.Sdk.Air; + +public AirIdentifier? TryBuildAirId( + AirEcosystem ecosystem, + AirAssetType assetType, + long modelId, + long versionId) +{ + try + { + var builder = new AirBuilder(); + return builder + .WithEcosystem(ecosystem) + .WithAssetType(assetType) + .WithModelId(modelId) + .WithVersionId(versionId) + .Build(); + } + catch (ArgumentOutOfRangeException ex) + { + Console.WriteLine($"Invalid ID: {ex.Message}"); + return null; + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"Missing required property: {ex.Message}"); + return null; + } +} +``` + +## Best Practices + +### Reuse Builders + +Reuse builder instances when creating multiple identifiers: + +```csharp +// Good - derive per-item builders from a reusable base configuration +var baseBuilder = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl); + +foreach (var data in modelData) +{ + var airId = baseBuilder + .WithAssetType(data.AssetType) + .WithModelId(data.ModelId) + .WithVersionId(data.VersionId) + .Build(); + + ProcessAirId(airId); +} +``` + +### Validate Early + +Validate input before passing to builder methods: + +```csharp +public AirIdentifier BuildFromUserInput(long modelId, long versionId) +{ + // Validate before building + if (modelId <= 0) + throw new ArgumentException("Model ID must be positive", nameof(modelId)); + + if (versionId <= 0) + throw new ArgumentException("Version ID must be positive", nameof(versionId)); + + return new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(modelId) + .WithVersionId(versionId) + .Build(); +} +``` + +### Use Method Chaining + +Take advantage of the fluent API for concise code: + +```csharp +// Preferred - fluent style +var airId = new AirBuilder() + .WithEcosystem(AirEcosystem.Flux1) + .WithAssetType(AirAssetType.Lora) + .WithModelId(123) + .WithVersionId(456) + .Build(); + +// Avoid - verbose style +var builder = new AirBuilder(); +builder.WithEcosystem(AirEcosystem.Flux1); +builder.WithAssetType(AirAssetType.Lora); +builder.WithModelId(123); +builder.WithVersionId(456); +var airId = builder.Build(); +``` + +## Related Resources + +- [AIR Identifier Guide](air-identifier.md) - Understanding AIR identifiers +- [CivitaiSharp.Sdk Introduction](sdk-introduction.md) - Working with Civitai's AI orchestration platform +- [Tools Introduction](tools-introduction.md) - Overview of CivitaiSharp.Tools utilities diff --git a/Documentation/Guides/air-identifier.md b/Documentation/Guides/air-identifier.md index e7314f5..4cd57d8 100644 --- a/Documentation/Guides/air-identifier.md +++ b/Documentation/Guides/air-identifier.md @@ -67,20 +67,20 @@ urn:air:model:leonardo:345435 ## Using AIR with CivitaiSharp -The CivitaiSharp.Sdk library provides utilities for working with AIR identifiers. +The CivitaiSharp.Sdk library provides strongly-typed utilities for working with AIR identifiers. ### Parsing an AIR ```csharp using CivitaiSharp.Sdk.Air; -var air = AirUrn.Parse("urn:air:sdxl:lora:civitai:328553@368189"); +var air = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:328553@368189"); -Console.WriteLine($"Ecosystem: {air.Ecosystem}"); // sdxl -Console.WriteLine($"Type: {air.Type}"); // lora -Console.WriteLine($"Source: {air.Source}"); // civitai -Console.WriteLine($"Id: {air.Id}"); // 328553 -Console.WriteLine($"Version: {air.Version}"); // 368189 +Console.WriteLine($"Ecosystem: {air.Ecosystem}"); // StableDiffusionXl +Console.WriteLine($"Asset Type: {air.AssetType}"); // Lora +Console.WriteLine($"Source: {air.Source}"); // Civitai +Console.WriteLine($"Model ID: {air.ModelId}"); // 328553 +Console.WriteLine($"Version ID: {air.VersionId}"); // 368189 ``` ### Creating an AIR @@ -88,17 +88,42 @@ Console.WriteLine($"Version: {air.Version}"); // 368189 ```csharp using CivitaiSharp.Sdk.Air; -var air = new AirUrn -{ - Ecosystem = "sdxl", - Type = "lora", - Source = "civitai", - Id = "328553", - Version = "368189" -}; +// Using the constructor +var air = new AirIdentifier( + AirEcosystem.StableDiffusionXl, + AirAssetType.Lora, + AirSource.Civitai, + 328553, + 368189); Console.WriteLine(air.ToString()); // Output: urn:air:sdxl:lora:civitai:328553@368189 + +// Using the factory method (defaults to Civitai source) +var air2 = AirIdentifier.Create( + AirEcosystem.StableDiffusionXl, + AirAssetType.Lora, + 328553, + 368189); +``` + +### Using the Builder Pattern + +For more complex scenarios, use the fluent builder: + +```csharp +using CivitaiSharp.Sdk.Air; + +var air = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithSource(AirSource.HuggingFace) + .WithModelId(328553) + .WithVersionId(368189) + .Build(); + +Console.WriteLine(air.ToString()); +// Output: urn:air:sdxl:lora:huggingface:328553@368189 ``` ### Validating an AIR @@ -106,7 +131,7 @@ Console.WriteLine(air.ToString()); ```csharp using CivitaiSharp.Sdk.Air; -if (AirUrn.TryParse("urn:air:sdxl:lora:civitai:328553@368189", out var air)) +if (AirIdentifier.TryParse("urn:air:sdxl:lora:civitai:328553@368189", out var air)) { Console.WriteLine($"Valid AIR: {air}"); } @@ -116,6 +141,19 @@ else } ``` +### Supported Sources + +CivitaiSharp supports four platforms as defined in the official AIR specification: + +| Source | Enum Value | Description | +|--------|------------|-------------| +| Civitai | `AirSource.Civitai` | Civitai platform resources | +| Hugging Face | `AirSource.HuggingFace` | Hugging Face model hub | +| OpenAI | `AirSource.OpenAi` | OpenAI models (DALL-E, GPT) | +| Leonardo | `AirSource.Leonardo` | Leonardo.Ai platform | + +All sources are strongly typed as enums, providing compile-time safety and IDE intellisense. + ## Common Use Cases ### Referencing Models in Applications @@ -125,7 +163,7 @@ AIR provides a standardized way to reference models in your application configur ```json { "models": { - "checkpoint": "urn:air:sdxl:model:civitai:101055@128078", + "checkpoint": "urn:air:sdxl:checkpoint:civitai:101055@128078", "lora": "urn:air:sdxl:lora:civitai:328553@368189" } } @@ -137,10 +175,20 @@ Since AIR is a universal identifier, it can be used to reference resources acros ```csharp // Civitai resource -var civitaiModel = AirUrn.Parse("urn:air:sdxl:model:civitai:101055@128078"); +var civitaiModel = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:101055@128078"); // Hugging Face resource -var hfModel = AirUrn.Parse("urn:air:model:huggingface:stabilityai/sdxl-vae"); +var hfModel = AirIdentifier.Parse("urn:air:sdxl:checkpoint:huggingface:100@200"); + +// Switching sources programmatically +var builder = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Checkpoint) + .WithModelId(101055) + .WithVersionId(128078); + +var civitai = builder.WithSource(AirSource.Civitai).Build(); +var leonardo = builder.WithSource(AirSource.Leonardo).Build(); ``` ## Next Steps diff --git a/Documentation/Guides/configuration.md b/Documentation/Guides/configuration.md index eca2e0e..c1735ad 100644 --- a/Documentation/Guides/configuration.md +++ b/Documentation/Guides/configuration.md @@ -54,7 +54,7 @@ Options are validated on assignment: ## CivitaiSharp.Sdk Configuration -The `CivitaiSdkClientOptions` class configures the Generator SDK client. +The `SdkClientOptions` class configures the Generator SDK client. > [!IMPORTANT] > Unlike CivitaiSharp.Core which can access public endpoints anonymously, the SDK **always requires authentication**. All Generator API operations require a valid API token. diff --git a/Documentation/Guides/images.md b/Documentation/Guides/images.md index a3b05af..0a9664a 100644 --- a/Documentation/Guides/images.md +++ b/Documentation/Guides/images.md @@ -147,7 +147,7 @@ if (image.Stats is { } stats) ## Pagination -Use page-based pagination for images: +Images use cursor-based pagination: [!code-csharp[Program.cs](Images/Program.cs#L62-L85)] diff --git a/Documentation/Guides/introduction.md b/Documentation/Guides/introduction.md index 95ff05b..a1f3231 100644 --- a/Documentation/Guides/introduction.md +++ b/Documentation/Guides/introduction.md @@ -48,7 +48,6 @@ public class MyService(IApiClient apiClient) .WhereType(ModelType.Lora) .WhereTag("anime") .ExecuteAsync(resultsLimit: 10); - .ExecuteAsync(); if (result is Result>.Success success) { diff --git a/Documentation/Guides/sdk-introduction.md b/Documentation/Guides/sdk-introduction.md index 8d93bc3..05b0e92 100644 --- a/Documentation/Guides/sdk-introduction.md +++ b/Documentation/Guides/sdk-introduction.md @@ -40,25 +40,19 @@ services.AddCivitaiSdk(options => ### Basic Usage ```csharp -public class ImageGenerationService(ICivitaiSdkClient sdkClient) +public class ImageGenerationService(ISdkClient sdkClient) { public async Task GenerateImageAsync() { - var request = new TextToImageJobRequest - { - Model = "urn:air:sd1:checkpoint:civitai:4201@130072", - Params = new ImageJobParams - { - Prompt = "a beautiful sunset over mountains", - NegativePrompt = "blurry, low quality", - Width = 512, - Height = 512, - Steps = 20, - CfgScale = 7.0 - } - }; - - var result = await sdkClient.Jobs.SubmitAsync(request); + var result = await sdkClient.Jobs + .CreateTextToImage() + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("a beautiful sunset over mountains") + .WithNegativePrompt("blurry, low quality") + .WithSize(1024, 1024) + .WithSteps(30) + .WithCfgScale(7.5m) + .SubmitAsync(); if (result is Result.Success success) { @@ -74,15 +68,16 @@ public class ImageGenerationService(ICivitaiSdkClient sdkClient) Submit and manage image generation jobs: -- `SubmitAsync` - Submit a text-to-image generation job -- `SubmitBatchAsync` - Submit multiple jobs as a batch -- `GetByIdAsync` - Get job status by ID -- `GetByTokenAsync` - Get job status by token -- `QueryAsync` - Query multiple jobs with filters -- `CancelByIdAsync` - Cancel a job by ID -- `CancelByTokenAsync` - Cancel jobs by batch token -- `TaintByIdAsync` - Mark a job as tainted by ID -- `TaintByTokenAsync` - Mark jobs as tainted by batch token +- `CreateTextToImage()` - Create a fluent builder for text-to-image jobs (recommended) +- `SubmitAsync()` - Submit a text-to-image generation job +- `SubmitBatchAsync()` - Submit multiple jobs as a batch +- `GetByIdAsync()` - Get job status by ID +- `GetByTokenAsync()` - Get job status by token +- `QueryAsync()` - Query multiple jobs with filters +- `CancelByIdAsync()` - Cancel a job by ID +- `CancelByTokenAsync()` - Cancel jobs by batch token +- `TaintByIdAsync()` - Mark a job as tainted by ID +- `TaintByTokenAsync()` - Mark jobs as tainted by batch token ### Coverage Service diff --git a/Documentation/Guides/tools-introduction.md b/Documentation/Guides/tools-introduction.md index 6e16bd4..b50441e 100644 --- a/Documentation/Guides/tools-introduction.md +++ b/Documentation/Guides/tools-introduction.md @@ -9,6 +9,7 @@ CivitaiSharp.Tools provides utility functionality for working with Civitai resou ## Key Features +- **AIR Builder** - Fluent API for constructing AIR (Artificial Intelligence Resource) identifiers with validation - **File Hashing** - Compute SHA256, SHA512, BLAKE3, and CRC32 hashes for file verification - **Download Management** - Download images and model files with configurable path patterns and hash verification - **HTML Parsing** - Convert Civitai's HTML descriptions to Markdown or plain text @@ -142,6 +143,7 @@ Configure download behavior via appsettings.json: ## Guides +- [AIR Builder](air-builder.md) - Build AIR identifiers with a fluent API - [File Hashing](file-hashing.md) - Compute and verify file hashes - [Downloading Files](downloading-files.md) - Download images and model files - [HTML Parsing](html-parsing.md) - Convert descriptions to Markdown or plain text diff --git a/Documentation/toc.yml b/Documentation/toc.yml index ed9a578..bc6efae 100644 --- a/Documentation/toc.yml +++ b/Documentation/toc.yml @@ -2,8 +2,6 @@ items: - name: Home href: index.html - name: Guides - href: Guides/ - topicHref: Guides/index.md + href: Guides/index.md - name: API Reference - href: Api/ - topicHref: Api/index.md + href: Api/index.md diff --git a/NUGET.md b/NUGET.md index e5f68aa..94efc33 100644 --- a/NUGET.md +++ b/NUGET.md @@ -448,12 +448,31 @@ else ## 6. Features - **Modern .NET 10** - Built with nullable reference types, records, and primary constructors -- **Fluent Query Builders** - Type-safe, immutable builders for constructing API requests -- **Result Pattern** - Explicit success/failure handling with discriminated union -- **Built-in Resilience** - Retry policies, circuit breakers, rate limiting, and timeouts +- **AOT-Ready** - Full Native AOT and trimming support with source-generated JSON serialization +- **Fluent Query Builders** - Type-safe, immutable, thread-safe builders for constructing API requests +- **Result Pattern** - Explicit success/failure handling without exceptions for expected errors +- **Built-in Resilience** - Standard resilience handler with retry policies, circuit breakers, rate limiting, and timeouts - **Dependency Injection** - First-class support for `IHttpClientFactory` and Microsoft DI -- **Streaming Downloads** - Memory-efficient response handling with `ResponseHeadersRead` -- **Explicit JSON Contract** - All model properties use `[JsonPropertyName]` for type safety + +### AOT and Trimming Support + +All CivitaiSharp packages are fully compatible with Native AOT compilation and IL trimming: + +- **Source-Generated JSON** - Uses `System.Text.Json` source generators (`JsonSerializerContext`) for reflection-free serialization +- **Trim-Safe** - All packages have `true` and `true` +- **No Runtime Reflection** - All type metadata is generated at compile-time + +To publish with AOT: + +```shell +dotnet publish -c Release -r win-x64 /p:PublishAot=true +``` + +To publish with trimming: + +```shell +dotnet publish -c Release -r win-x64 /p:PublishTrimmed=true +``` ## 7. Documentation - [API Reference](https://CivitaiSharp.Mewyk.com/Docs/api/) diff --git a/Sdk/Air/AirBuilder.cs b/Sdk/Air/AirBuilder.cs new file mode 100644 index 0000000..cb8726e --- /dev/null +++ b/Sdk/Air/AirBuilder.cs @@ -0,0 +1,181 @@ +namespace CivitaiSharp.Sdk.Air; + +using System; +using System.Diagnostics.CodeAnalysis; + +/// +/// Immutable, thread-safe builder for constructing instances with validation. +/// Each fluent method returns a new builder instance, allowing safe reuse and caching of base configurations. +/// +/// +/// +/// This builder simplifies the construction of AIR (Artificial Intelligence Resource) identifiers +/// by providing a clear, step-by-step fluent interface. Use this when you want to construct +/// AIR identifiers programmatically with compile-time guidance and runtime validation. +/// +/// +/// This builder follows the same immutable pattern as RequestBuilder in CivitaiSharp.Core. +/// Each method returns a new instance, making it inherently thread-safe. +/// +/// +/// +/// +/// var air = new AirBuilder() +/// .WithEcosystem(AirEcosystem.StableDiffusionXl) +/// .WithAssetType(AirAssetType.Lora) +/// .WithModelId(328553) +/// .WithVersionId(368189) +/// .Build(); +/// +/// Console.WriteLine(air.ToString()); +/// // Output: urn:air:sdxl:lora:civitai:328553@368189 +/// +/// +public sealed record AirBuilder +{ + private readonly AirEcosystem? _ecosystem; + private readonly AirAssetType? _assetType; + private readonly AirSource _source; + private readonly long? _modelId; + private readonly long? _versionId; + + /// + /// Initializes a new instance of the record with default values. + /// + public AirBuilder() + : this(ecosystem: null, assetType: null, source: AirIdentifier.DefaultSource, modelId: null, versionId: null) + { + } + + private AirBuilder( + AirEcosystem? ecosystem, + AirAssetType? assetType, + AirSource source, + long? modelId, + long? versionId) + { + _ecosystem = ecosystem; + _assetType = assetType; + _source = source; + _modelId = modelId; + _versionId = versionId; + } + + /// + /// Sets the ecosystem (e.g., Stable Diffusion XL, FLUX.1). + /// + /// The model ecosystem. + /// A new builder instance with the ecosystem set. + public AirBuilder WithEcosystem(AirEcosystem ecosystem) => + new(ecosystem, _assetType, _source, _modelId, _versionId); + + /// + /// Sets the asset type (e.g., Checkpoint, LoRA, Embedding). + /// + /// The asset type. + /// A new builder instance with the asset type set. + public AirBuilder WithAssetType(AirAssetType assetType) => + new(_ecosystem, assetType, _source, _modelId, _versionId); + + /// + /// Sets the source platform for the resource. + /// + /// The source platform. Defaults to if not specified. + /// A new builder instance with the source set. + public AirBuilder WithSource(AirSource source) => + new(_ecosystem, _assetType, source, _modelId, _versionId); + + /// + /// Sets the model ID. + /// + /// The model ID. Must be greater than 0. + /// A new builder instance with the model ID set. + /// Thrown when modelId is less than 1. + public AirBuilder WithModelId(long modelId) + { + ArgumentOutOfRangeException.ThrowIfLessThan(modelId, 1); + return new(_ecosystem, _assetType, _source, modelId, _versionId); + } + + /// + /// Sets the version ID. + /// + /// The version ID. Must be greater than 0. + /// A new builder instance with the version ID set. + /// Thrown when versionId is less than 1. + public AirBuilder WithVersionId(long versionId) + { + ArgumentOutOfRangeException.ThrowIfLessThan(versionId, 1); + return new(_ecosystem, _assetType, _source, _modelId, versionId); + } + + /// + /// Builds the with the configured values. + /// + /// A new instance. + /// + /// Thrown when required properties (, , + /// , ) have not been set. + /// + public AirIdentifier Build() + { + if (!_ecosystem.HasValue) + { + throw new InvalidOperationException( + $"{nameof(AirIdentifier.Ecosystem)} must be set before building. Use {nameof(WithEcosystem)}() to specify the model ecosystem."); + } + + if (!_assetType.HasValue) + { + throw new InvalidOperationException( + $"{nameof(AirIdentifier.AssetType)} must be set before building. Use {nameof(WithAssetType)}() to specify the asset type."); + } + + if (!_modelId.HasValue) + { + throw new InvalidOperationException( + $"{nameof(AirIdentifier.ModelId)} must be set before building. Use {nameof(WithModelId)}() to specify the model ID."); + } + + if (!_versionId.HasValue) + { + throw new InvalidOperationException( + $"{nameof(AirIdentifier.VersionId)} must be set before building. Use {nameof(WithVersionId)}() to specify the version ID."); + } + + return new AirIdentifier( + _ecosystem.Value, + _assetType.Value, + _source, + _modelId.Value, + _versionId.Value); + } + + /// + /// Attempts to build the with the configured values. + /// + /// When this method returns , contains the built ; + /// otherwise, contains the default value. + /// if all required properties were set and the identifier was built successfully; otherwise, . + /// + /// Use this method when you prefer to handle missing required properties without exceptions. + /// All properties (, , + /// , ) must be set for the build to succeed. + /// + public bool TryBuild([MaybeNullWhen(false)] out AirIdentifier result) + { + if (!_ecosystem.HasValue || !_assetType.HasValue || !_modelId.HasValue || !_versionId.HasValue) + { + result = default; + return false; + } + + result = new AirIdentifier( + _ecosystem.Value, + _assetType.Value, + _source, + _modelId.Value, + _versionId.Value); + return true; + } +} diff --git a/Sdk/Air/AirIdentifier.cs b/Sdk/Air/AirIdentifier.cs index 54ecc21..a4ce980 100644 --- a/Sdk/Air/AirIdentifier.cs +++ b/Sdk/Air/AirIdentifier.cs @@ -15,7 +15,7 @@ namespace CivitaiSharp.Sdk.Air; /// /// The default source for Civitai assets. /// - public const string CivitaiSource = "civitai"; + public const AirSource DefaultSource = AirSource.Civitai; /// /// Compiled regex pattern for parsing AIR identifiers. @@ -23,11 +23,6 @@ namespace CivitaiSharp.Sdk.Air; [GeneratedRegex(@"^urn:air:([a-z0-9]+):([a-z]+):([a-z]+):(\d+)@(\d+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex AirPattern(); - /// - /// Cached lowercase source for hash code computation to avoid allocation. - /// - private readonly string _sourceLower; - /// /// Gets the model ecosystem (e.g., sd1, sdxl, flux1). /// @@ -39,9 +34,9 @@ namespace CivitaiSharp.Sdk.Air; public AirAssetType AssetType { get; } /// - /// Gets the source of the asset. Currently always "civitai". + /// Gets the source platform of the asset. /// - public string Source { get; } + public AirSource Source { get; } /// /// Gets the model ID on Civitai. @@ -58,26 +53,23 @@ namespace CivitaiSharp.Sdk.Air; /// /// The model ecosystem. /// The asset type. - /// The source (typically "civitai"). + /// The source platform. /// The model ID. /// The version ID. - /// Thrown when source is null or whitespace. /// Thrown when modelId or versionId is less than 1. public AirIdentifier( AirEcosystem ecosystem, AirAssetType assetType, - string source, + AirSource source, long modelId, long versionId) { - ArgumentException.ThrowIfNullOrWhiteSpace(source); ArgumentOutOfRangeException.ThrowIfLessThan(modelId, 1); ArgumentOutOfRangeException.ThrowIfLessThan(versionId, 1); Ecosystem = ecosystem; AssetType = assetType; Source = source; - _sourceLower = source.ToLowerInvariant(); ModelId = modelId; VersionId = versionId; } @@ -95,7 +87,7 @@ public static AirIdentifier Create( AirAssetType assetType, long modelId, long versionId) => - new(ecosystem, assetType, CivitaiSource, modelId, versionId); + new(ecosystem, assetType, DefaultSource, modelId, versionId); /// /// Attempts to parse a string as an AIR identifier. @@ -120,7 +112,7 @@ public static bool TryParse(string? value, out AirIdentifier result) var ecosystemStr = match.Groups[1].Value; var typeStr = match.Groups[2].Value; - var source = match.Groups[3].Value.ToLowerInvariant(); + var sourceStr = match.Groups[3].Value; var modelIdStr = match.Groups[4].Value; var versionIdStr = match.Groups[5].Value; @@ -134,6 +126,11 @@ public static bool TryParse(string? value, out AirIdentifier result) return false; } + if (!EnumExtensions.TryParseFromApiString(sourceStr, out var source)) + { + return false; + } + if (!long.TryParse(modelIdStr, out var modelId) || modelId < 1) { return false; @@ -153,7 +150,8 @@ public static bool TryParse(string? value, out AirIdentifier result) /// /// The string to parse. /// The parsed . - /// Thrown when value is null or whitespace. + /// Thrown when value is null. + /// Thrown when value is empty or whitespace. /// Thrown when value is not a valid AIR identifier. public static AirIdentifier Parse(string value) { @@ -183,13 +181,13 @@ static bool IParsable.TryParse( /// /// The AIR identifier string in the format urn:air:{ecosystem}:{type}:{source}:{modelId}@{versionId}. public override string ToString() => - $"urn:air:{Ecosystem.ToApiString()}:{AssetType.ToApiString()}:{_sourceLower}:{ModelId}@{VersionId}"; + $"urn:air:{Ecosystem.ToApiString()}:{AssetType.ToApiString()}:{Source.ToApiString()}:{ModelId}@{VersionId}"; /// public bool Equals(AirIdentifier other) => Ecosystem == other.Ecosystem && AssetType == other.AssetType && - string.Equals(_sourceLower, other._sourceLower, StringComparison.Ordinal) && + Source == other.Source && ModelId == other.ModelId && VersionId == other.VersionId; @@ -197,7 +195,7 @@ public bool Equals(AirIdentifier other) => public override bool Equals(object? obj) => obj is AirIdentifier other && Equals(other); /// - public override int GetHashCode() => HashCode.Combine(Ecosystem, AssetType, _sourceLower, ModelId, VersionId); + public override int GetHashCode() => HashCode.Combine(Ecosystem, AssetType, Source, ModelId, VersionId); /// /// Determines whether two AIR identifiers are equal. diff --git a/Sdk/Air/AirSource.cs b/Sdk/Air/AirSource.cs new file mode 100644 index 0000000..aefc54f --- /dev/null +++ b/Sdk/Air/AirSource.cs @@ -0,0 +1,34 @@ +namespace CivitaiSharp.Sdk.Air; + +/// +/// Supported source platforms in the AIR (Artificial Intelligence Resource) identifier system. +/// +/// +/// API string mappings are defined in . +/// +public enum AirSource +{ + /// + /// Civitai platform (https://civitai.com). + /// Maps to API value "civitai". + /// + Civitai, + + /// + /// Hugging Face platform (https://huggingface.co). + /// Maps to API value "huggingface". + /// + HuggingFace, + + /// + /// OpenAI platform (https://openai.com). + /// Maps to API value "openai". + /// + OpenAi, + + /// + /// Leonardo.Ai platform (https://leonardo.ai). + /// Maps to API value "leonardo". + /// + Leonardo +} diff --git a/Sdk/CivitaiSharp.Sdk.csproj b/Sdk/CivitaiSharp.Sdk.csproj index 09fdc84..88a2a76 100644 --- a/Sdk/CivitaiSharp.Sdk.csproj +++ b/Sdk/CivitaiSharp.Sdk.csproj @@ -9,6 +9,7 @@ true + true true diff --git a/Sdk/Extensions/SdkApiStringRegistry.cs b/Sdk/Extensions/SdkApiStringRegistry.cs index 9673124..128d6c0 100644 --- a/Sdk/Extensions/SdkApiStringRegistry.cs +++ b/Sdk/Extensions/SdkApiStringRegistry.cs @@ -7,7 +7,7 @@ namespace CivitaiSharp.Sdk.Extensions; /// /// Registers SDK-specific enum mappings with the Core's . -/// Called automatically when is invoked. +/// Called automatically when is invoked. /// /// /// @@ -134,5 +134,14 @@ private static void Initialize() [AirEcosystem.Flux1] = "flux1", [AirEcosystem.Pony] = "pony", }); + + // Register AirSource mappings + ApiStringRegistry.Register(new Dictionary + { + [AirSource.Civitai] = "civitai", + [AirSource.HuggingFace] = "huggingface", + [AirSource.OpenAi] = "openai", + [AirSource.Leonardo] = "leonardo", + }); } } diff --git a/Sdk/Extensions/ServiceCollectionExtensions.cs b/Sdk/Extensions/ServiceCollectionExtensions.cs index 14c2f08..3ef0ffd 100644 --- a/Sdk/Extensions/ServiceCollectionExtensions.cs +++ b/Sdk/Extensions/ServiceCollectionExtensions.cs @@ -24,7 +24,7 @@ public static class ServiceCollectionExtensions /// Registers the Civitai SDK client and related services with the specified configuration action. /// /// The service collection to register services into. - /// Action to configure . + /// Action to configure . /// The service collection for chaining. /// Thrown if or is null. /// @@ -37,7 +37,7 @@ public static class ServiceCollectionExtensions /// public static IServiceCollection AddCivitaiSdk( this IServiceCollection services, - Action configure) + Action configure) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); @@ -54,8 +54,8 @@ public static IServiceCollection AddCivitaiSdk( /// The configuration source containing SDK settings. /// Optional section name to read from configuration. Defaults to "CivitaiSdk". /// The service collection for chaining. - /// Thrown if or is null. - /// Thrown if is null or whitespace. + /// Thrown if , , or is null. + /// Thrown if is empty or whitespace. /// /// /// // appsettings.json: @@ -77,7 +77,7 @@ public static IServiceCollection AddCivitaiSdk( ArgumentNullException.ThrowIfNull(configuration); ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); - services.Configure(configuration.GetSection(sectionName)); + services.Configure(configuration.GetSection(sectionName)); RegisterSdkServices(services); return services; } @@ -87,7 +87,7 @@ public static IServiceCollection AddCivitaiSdk( /// /// /// - /// Registering and as singletons is correct + /// Registering and as singletons is correct /// for this library. The HTTP client factory pattern () is used, /// which properly manages lifetimes and avoids socket exhaustion. /// @@ -103,7 +103,7 @@ private static void RegisterSdkServices(IServiceCollection services) services.AddHttpClient(nameof(SdkHttpClient), (serviceProvider, client) => { - var options = serviceProvider.GetRequiredService>().Value; + var options = serviceProvider.GetRequiredService>().Value; ConfigureHttpClient(client, options); }) .AddStandardResilienceHandler(); @@ -116,18 +116,18 @@ private static void RegisterSdkServices(IServiceCollection services) return new SdkHttpClient(httpClient, logger); }); - services.AddSingleton(serviceProvider => + services.AddSingleton(serviceProvider => { var httpClient = serviceProvider.GetRequiredService(); - var options = serviceProvider.GetRequiredService>().Value; - return new CivitaiSdkClient(httpClient, options); + var options = serviceProvider.GetRequiredService>().Value; + return new SdkClient(httpClient, options); }); } /// /// Configures the HTTP client base address, headers, and authentication. /// - private static void ConfigureHttpClient(HttpClient client, CivitaiSdkClientOptions options) + private static void ConfigureHttpClient(HttpClient client, SdkClientOptions options) { options.Validate(); diff --git a/Sdk/Http/QueryStringBuilder.cs b/Sdk/Http/QueryStringBuilder.cs index 066f79f..05bf298 100644 --- a/Sdk/Http/QueryStringBuilder.cs +++ b/Sdk/Http/QueryStringBuilder.cs @@ -8,6 +8,16 @@ namespace CivitaiSharp.Sdk.Http; /// Builder for constructing URL query strings with proper encoding. /// This class is mutable and not thread-safe. /// +/// +/// +/// var query = new QueryStringBuilder() +/// .Append("token", "abc123") +/// .AppendIf("wait", true) +/// .Append("startDate", DateTime.Now) +/// .BuildUri("/api/v1/jobs"); +/// // Result: "/api/v1/jobs?token=abc123&wait=true&startDate=2025-12-06T19:00:00.0000000Z" +/// +/// internal sealed class QueryStringBuilder { private readonly StringBuilder _builder = new(); diff --git a/Sdk/ICivitaiSdkClient.cs b/Sdk/ISdkClient.cs similarity index 63% rename from Sdk/ICivitaiSdkClient.cs rename to Sdk/ISdkClient.cs index 34f9f07..d3d5d92 100644 --- a/Sdk/ICivitaiSdkClient.cs +++ b/Sdk/ISdkClient.cs @@ -7,20 +7,20 @@ namespace CivitaiSharp.Sdk; /// model availability checking, and usage tracking. For the public API (models, images, tags, creators), /// use instead. /// -public interface ICivitaiSdkClient +public interface ISdkClient { /// - /// Provides access to image generation jobs: submit, query, cancel, and retrieve results. + /// Image generation jobs: submit, query, cancel, and retrieve results. /// IJobsService Jobs { get; } /// - /// Provides model and resource availability checks to verify assets are ready before submitting jobs. + /// Model and resource availability checks before submitting jobs. /// ICoverageService Coverage { get; } /// - /// Provides API consumption tracking: credit usage, job counts, and consumption history. + /// API consumption tracking: credits, job counts, and history. /// IUsageService Usage { get; } } diff --git a/Sdk/Json/Converters/AirIdentifierConverter.cs b/Sdk/Json/Converters/AirIdentifierConverter.cs new file mode 100644 index 0000000..9228255 --- /dev/null +++ b/Sdk/Json/Converters/AirIdentifierConverter.cs @@ -0,0 +1,36 @@ +namespace CivitaiSharp.Sdk.Json.Converters; + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using CivitaiSharp.Sdk.Air; + +/// +/// JSON converter for that serializes to/from AIR strings. +/// +public sealed class AirIdentifierConverter : JsonConverter +{ + /// + public override AirIdentifier Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + + if (string.IsNullOrWhiteSpace(value)) + { + throw new JsonException("AIR identifier cannot be null or empty."); + } + + if (!AirIdentifier.TryParse(value, out var result)) + { + throw new JsonException($"Invalid AIR identifier format: '{value}'. Expected format: urn:air:{{ecosystem}}:{{type}}:{{source}}:{{modelId}}@{{versionId}}"); + } + + return result; + } + + /// + public override void Write(Utf8JsonWriter writer, AirIdentifier value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/Sdk/Json/Converters/ControlNetPreprocessorConverter.cs b/Sdk/Json/Converters/ControlNetPreprocessorConverter.cs index d70df40..a9b5721 100644 --- a/Sdk/Json/Converters/ControlNetPreprocessorConverter.cs +++ b/Sdk/Json/Converters/ControlNetPreprocessorConverter.cs @@ -82,6 +82,7 @@ public override void Write(Utf8JsonWriter writer, ControlNetPreprocessor value, /// /// AOT-compatible JSON converter for nullable . +/// Handles null values by writing JSON null and reading null tokens appropriately. /// internal sealed class NullableControlNetPreprocessorConverter : JsonConverter { diff --git a/Sdk/Json/Converters/ProviderAssetAvailabilityDictionaryConverter.cs b/Sdk/Json/Converters/ProviderAssetAvailabilityDictionaryConverter.cs index 382acd6..54baf63 100644 --- a/Sdk/Json/Converters/ProviderAssetAvailabilityDictionaryConverter.cs +++ b/Sdk/Json/Converters/ProviderAssetAvailabilityDictionaryConverter.cs @@ -4,15 +4,16 @@ namespace CivitaiSharp.Sdk.Json.Converters; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; +using CivitaiSharp.Sdk.Air; using CivitaiSharp.Sdk.Models.Coverage; /// -/// AOT-compatible JSON converter for where TValue is . +/// AOT-compatible JSON converter for where TKey is and TValue is . /// -internal sealed class ProviderAssetAvailabilityDictionaryConverter : JsonConverter> +internal sealed class ProviderAssetAvailabilityDictionaryConverter : JsonConverter> { /// - public override IReadOnlyDictionary Read( + public override IReadOnlyDictionary Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -22,7 +23,7 @@ public override IReadOnlyDictionary Read( throw new JsonException("Expected start of object for dictionary."); } - var dictionary = new Dictionary(); + var dictionary = new Dictionary(); while (reader.Read()) { @@ -36,7 +37,12 @@ public override IReadOnlyDictionary Read( throw new JsonException("Expected property name."); } - var key = reader.GetString()!; + var keyString = reader.GetString()!; + if (!AirIdentifier.TryParse(keyString, out var key)) + { + throw new JsonException($"Invalid AIR identifier key: '{keyString}'"); + } + reader.Read(); var availability = JsonSerializer.Deserialize(ref reader, SdkJsonContext.Default.ProviderAssetAvailability); @@ -52,14 +58,14 @@ public override IReadOnlyDictionary Read( /// public override void Write( Utf8JsonWriter writer, - IReadOnlyDictionary value, + IReadOnlyDictionary value, JsonSerializerOptions options) { writer.WriteStartObject(); foreach (var kvp in value) { - writer.WritePropertyName(kvp.Key); + writer.WritePropertyName(kvp.Key.ToString()); JsonSerializer.Serialize(writer, kvp.Value, SdkJsonContext.Default.ProviderAssetAvailability); } diff --git a/Sdk/Json/Converters/SchedulerConverter.cs b/Sdk/Json/Converters/SchedulerConverter.cs index a4929d9..4f29e59 100644 --- a/Sdk/Json/Converters/SchedulerConverter.cs +++ b/Sdk/Json/Converters/SchedulerConverter.cs @@ -76,6 +76,7 @@ public override void Write(Utf8JsonWriter writer, Scheduler value, JsonSerialize /// /// AOT-compatible JSON converter for nullable . +/// Handles null values by writing JSON null and reading null tokens appropriately. /// internal sealed class NullableSchedulerConverter : JsonConverter { diff --git a/Sdk/Json/SdkJsonContext.cs b/Sdk/Json/SdkJsonContext.cs index 2faf33e..7a19c11 100644 --- a/Sdk/Json/SdkJsonContext.cs +++ b/Sdk/Json/SdkJsonContext.cs @@ -4,6 +4,7 @@ namespace CivitaiSharp.Sdk.Json; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; +using CivitaiSharp.Sdk.Air; using CivitaiSharp.Sdk.Json.Converters; using CivitaiSharp.Sdk.Models.Coverage; using CivitaiSharp.Sdk.Models.Jobs; @@ -21,6 +22,7 @@ namespace CivitaiSharp.Sdk.Json; AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, Converters = [ + typeof(AirIdentifierConverter), typeof(SchedulerConverter), typeof(NullableSchedulerConverter), typeof(NetworkTypeConverter), @@ -37,7 +39,6 @@ namespace CivitaiSharp.Sdk.Json; [JsonSerializable(typeof(ImageJobNetworkParams))] [JsonSerializable(typeof(ImageJobControlNet))] [JsonSerializable(typeof(Priority))] -[JsonSerializable(typeof(Priority?))] // Job response types [JsonSerializable(typeof(JobStatus))] [JsonSerializable(typeof(JobStatusCollection))] @@ -48,6 +49,8 @@ namespace CivitaiSharp.Sdk.Json; [JsonSerializable(typeof(Dictionary))] // Usage types [JsonSerializable(typeof(ConsumptionDetails))] +// AIR types +[JsonSerializable(typeof(AirIdentifier))] // Collection types [JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(List))] @@ -55,8 +58,8 @@ namespace CivitaiSharp.Sdk.Json; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(List))] -[JsonSerializable(typeof(IReadOnlyDictionary))] -[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(IReadOnlyDictionary))] +[JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IReadOnlyDictionary))] [JsonSerializable(typeof(Dictionary))] // Primitive types for properties diff --git a/Sdk/Models/Jobs/BatchJobRequest.cs b/Sdk/Models/Jobs/BatchJobRequest.cs index 08e6d3c..afe12ec 100644 --- a/Sdk/Models/Jobs/BatchJobRequest.cs +++ b/Sdk/Models/Jobs/BatchJobRequest.cs @@ -6,7 +6,7 @@ namespace CivitaiSharp.Sdk.Models.Jobs; /// /// Request model for batch job submission. /// -internal sealed class BatchJobRequest +public sealed class BatchJobRequest { /// /// Gets or sets the jobs to submit in this batch. diff --git a/Sdk/Models/Jobs/ImageJobControlNet.cs b/Sdk/Models/Jobs/ImageJobControlNet.cs index dc86ad8..922f148 100644 --- a/Sdk/Models/Jobs/ImageJobControlNet.cs +++ b/Sdk/Models/Jobs/ImageJobControlNet.cs @@ -1,5 +1,6 @@ namespace CivitaiSharp.Sdk.Models.Jobs; +using System; using System.Text.Json.Serialization; using CivitaiSharp.Sdk.Enums; using CivitaiSharp.Sdk.Json.Converters; @@ -61,4 +62,28 @@ public sealed class ImageJobControlNet /// [JsonPropertyName("endStep")] public decimal? EndStep { get; init; } + + /// + /// Validates that exactly one of or is provided. + /// + /// + /// Thrown when neither or both properties are provided. + /// + public void Validate() + { + var hasUrl = !string.IsNullOrWhiteSpace(ImageUrl); + var hasImage = !string.IsNullOrWhiteSpace(Image); + + if (!hasUrl && !hasImage) + { + throw new InvalidOperationException( + "Either ImageUrl or Image must be provided for ControlNet configuration."); + } + + if (hasUrl && hasImage) + { + throw new InvalidOperationException( + "Only one of ImageUrl or Image can be provided for ControlNet configuration, not both."); + } + } } diff --git a/Sdk/Models/Jobs/Priority.cs b/Sdk/Models/Jobs/Priority.cs index 272344c..632f337 100644 --- a/Sdk/Models/Jobs/Priority.cs +++ b/Sdk/Models/Jobs/Priority.cs @@ -5,10 +5,12 @@ namespace CivitaiSharp.Sdk.Models.Jobs; /// /// Priority configuration for job scheduling. /// -/// -/// This type is a struct for better memory efficiency as it contains only a single decimal value. -/// -public readonly struct Priority +/// +/// The priority modifier value. Higher values increase priority. Maps to JSON property "modifier". +/// Range: 0.1-10.0, default: 1.0. +/// +public sealed record Priority( + [property: JsonPropertyName("modifier")] decimal Modifier) { /// /// The default priority modifier value. @@ -16,21 +18,14 @@ public readonly struct Priority public const decimal DefaultModifier = 1.0m; /// - /// Initializes a new instance of the struct with the default modifier. + /// The minimum allowed priority modifier value. /// - public Priority() => Modifier = DefaultModifier; + public const decimal MinModifier = 0.1m; /// - /// Initializes a new instance of the struct with the specified modifier. + /// The maximum allowed priority modifier value. /// - /// The priority modifier value. - public Priority(decimal modifier) => Modifier = modifier; - - /// - /// Gets the priority modifier. Higher values increase priority. - /// - [JsonPropertyName("modifier")] - public decimal Modifier { get; init; } + public const decimal MaxModifier = 10.0m; /// /// Gets the default priority configuration. diff --git a/Sdk/Models/Jobs/TextToImageJobRequest.cs b/Sdk/Models/Jobs/TextToImageJobRequest.cs index 091a90b..8fc6219 100644 --- a/Sdk/Models/Jobs/TextToImageJobRequest.cs +++ b/Sdk/Models/Jobs/TextToImageJobRequest.cs @@ -4,6 +4,7 @@ namespace CivitaiSharp.Sdk.Models.Jobs; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; +using CivitaiSharp.Sdk.Air; /// /// Request model for text-to-image generation jobs. @@ -26,7 +27,7 @@ public sealed class TextToImageJobRequest /// /// urn:air:sdxl:checkpoint:civitai:4201@130072 [JsonPropertyName("model")] - public required string Model { get; init; } + public required AirIdentifier Model { get; init; } /// /// Gets or sets the generation parameters. Required. @@ -39,7 +40,7 @@ public sealed class TextToImageJobRequest /// Key is the AIR identifier, value is the network configuration. /// [JsonPropertyName("additionalNetworks")] - public IReadOnlyDictionary? AdditionalNetworks { get; init; } + public IReadOnlyDictionary? AdditionalNetworks { get; init; } /// /// Gets or sets ControlNet configurations for guided generation. diff --git a/Sdk/Request/ImageJobControlNetBuilder.cs b/Sdk/Request/ImageJobControlNetBuilder.cs new file mode 100644 index 0000000..323abd2 --- /dev/null +++ b/Sdk/Request/ImageJobControlNetBuilder.cs @@ -0,0 +1,130 @@ +namespace CivitaiSharp.Sdk.Request; + +using CivitaiSharp.Sdk.Enums; +using CivitaiSharp.Sdk.Models.Jobs; + +/// +/// Fluent builder for constructing instances. +/// +/// +/// This builder follows an immutable design pattern. Each method returns a new instance +/// with the updated configuration, making it thread-safe and cacheable. +/// +public sealed record ImageJobControlNetBuilder( + string? ImageUrl = null, + string? Image = null, + ControlNetPreprocessor? Preprocessor = null, + decimal? Weight = null, + decimal? StartStep = null, + decimal? EndStep = null) +{ + + /// + /// Creates a new instance. + /// + /// A new builder instance. + public static ImageJobControlNetBuilder Create() => new(); + + /// + /// Sets the URL of the control image. + /// + /// The control image URL. + /// A new builder instance with the updated image URL. + /// + /// Provide either this or , not both. + /// + public ImageJobControlNetBuilder WithImageUrl(string imageUrl) + => this with { ImageUrl = imageUrl, Image = null }; + + /// + /// Sets the base64-encoded control image data. + /// + /// The base64-encoded image data. + /// A new builder instance with the updated image data. + /// + /// + /// Provide either this or , not both. + /// + /// + /// Format: data:image/png;base64,iVBORw0KGgo... or raw base64 string. + /// + /// + public ImageJobControlNetBuilder WithImageData(string imageData) + => this with { Image = imageData, ImageUrl = null }; + + /// + /// Sets the preprocessor to apply to the control image. + /// + /// The preprocessor type. + /// A new builder instance with the updated preprocessor. + public ImageJobControlNetBuilder WithPreprocessor(ControlNetPreprocessor preprocessor) + => this with { Preprocessor = preprocessor }; + + /// + /// Sets the weight/influence of this ControlNet. + /// + /// The weight value. Range: 0.0-2.0, default: 1.0. + /// A new builder instance with the updated weight. + public ImageJobControlNetBuilder WithWeight(decimal weight) + => this with { Weight = weight }; + + /// + /// Sets the starting step for ControlNet influence. + /// + /// The start step. Range: 0.0-1.0. + /// A new builder instance with the updated start step. + public ImageJobControlNetBuilder WithStartStep(decimal startStep) + => this with { StartStep = startStep }; + + /// + /// Sets the ending step for ControlNet influence. + /// + /// The end step. Range: 0.0-1.0. + /// A new builder instance with the updated end step. + public ImageJobControlNetBuilder WithEndStep(decimal endStep) + => this with { EndStep = endStep }; + + /// + /// Sets the step range for ControlNet influence. + /// + /// The start step. Range: 0.0-1.0. + /// The end step. Range: 0.0-1.0. + /// A new builder instance with the updated step range. + public ImageJobControlNetBuilder WithStepRange(decimal startStep, decimal endStep) + => this with { StartStep = startStep, EndStep = endStep }; + + /// + /// Builds the instance. + /// + /// The configured . + /// + /// Thrown when neither or both image source properties are provided. + /// + public ImageJobControlNet Build() + { + var hasUrl = !string.IsNullOrWhiteSpace(ImageUrl); + var hasImage = !string.IsNullOrWhiteSpace(Image); + + if (!hasUrl && !hasImage) + { + throw new InvalidOperationException( + "Either ImageUrl or ImageData must be provided. Use WithImageUrl() or WithImageData()."); + } + + if (hasUrl && hasImage) + { + throw new InvalidOperationException( + "Cannot provide both ImageUrl and ImageData. Use only one method."); + } + + return new ImageJobControlNet + { + ImageUrl = ImageUrl, + Image = Image, + Preprocessor = Preprocessor, + Weight = Weight, + StartStep = StartStep, + EndStep = EndStep + }; + } +} diff --git a/Sdk/Request/ImageJobNetworkParamsBuilder.cs b/Sdk/Request/ImageJobNetworkParamsBuilder.cs new file mode 100644 index 0000000..58334b3 --- /dev/null +++ b/Sdk/Request/ImageJobNetworkParamsBuilder.cs @@ -0,0 +1,78 @@ +namespace CivitaiSharp.Sdk.Request; + +using CivitaiSharp.Sdk.Enums; +using CivitaiSharp.Sdk.Models.Jobs; + +/// +/// Fluent builder for constructing instances. +/// +/// +/// This builder follows an immutable design pattern. Each method returns a new instance +/// with the updated configuration, making it thread-safe and cacheable. +/// +public sealed record ImageJobNetworkParamsBuilder( + NetworkType? Type = null, + decimal? Strength = null, + string? TriggerWord = null, + decimal? ClipStrength = null) +{ + + /// + /// Creates a new instance. + /// + /// A new builder instance. + public static ImageJobNetworkParamsBuilder Create() => new(); + + /// + /// Sets the network type. + /// + /// The network type (LoRA, embedding, etc.). Required. + /// A new builder instance with the updated type. + public ImageJobNetworkParamsBuilder WithType(NetworkType type) + => this with { Type = type }; + + /// + /// Sets the strength/weight of the network. + /// + /// The strength value. Typically 0.0-2.0, default: 1.0. + /// A new builder instance with the updated strength. + public ImageJobNetworkParamsBuilder WithStrength(decimal strength) + => this with { Strength = strength }; + + /// + /// Sets the trigger words for this network. + /// + /// The trigger word(s) to activate the network. + /// A new builder instance with the updated trigger word. + public ImageJobNetworkParamsBuilder WithTriggerWord(string triggerWord) + => this with { TriggerWord = triggerWord }; + + /// + /// Sets the CLIP strength for text encoder influence. + /// + /// The CLIP strength. Range: 0.0-2.0. + /// A new builder instance with the updated CLIP strength. + public ImageJobNetworkParamsBuilder WithClipStrength(decimal clipStrength) + => this with { ClipStrength = clipStrength }; + + /// + /// Builds the instance. + /// + /// The configured . + /// Thrown when required properties are missing. + public ImageJobNetworkParams Build() + { + if (Type == null) + { + throw new InvalidOperationException("Type is required. Use WithType() to set it."); + } + + return new ImageJobNetworkParams + { + Type = Type.Value, + Strength = Strength, + TriggerWord = TriggerWord, + ClipStrength = ClipStrength + }; + } +} diff --git a/Sdk/Request/ImageJobParamsBuilder.cs b/Sdk/Request/ImageJobParamsBuilder.cs new file mode 100644 index 0000000..6c423ed --- /dev/null +++ b/Sdk/Request/ImageJobParamsBuilder.cs @@ -0,0 +1,165 @@ +namespace CivitaiSharp.Sdk.Request; + +using CivitaiSharp.Sdk.Enums; +using CivitaiSharp.Sdk.Models.Jobs; + +/// +/// Fluent builder for constructing instances. +/// +/// +/// This builder follows an immutable design pattern. Each method returns a new instance +/// with the updated configuration, making it thread-safe and cacheable. +/// +public sealed record ImageJobParamsBuilder( + string? Prompt = null, + string? NegativePrompt = null, + Scheduler? Scheduler = null, + int? Steps = null, + decimal? CfgScale = null, + int? Width = null, + int? Height = null, + long? Seed = null, + int? ClipSkip = null, + string? Image = null, + decimal? Strength = null) +{ + /// + /// Creates a new instance. + /// + /// A new builder instance. + public static ImageJobParamsBuilder Create() => new(); + + /// + /// Sets the positive prompt for image generation. + /// + /// The prompt text describing what to generate. Required. + /// A new builder instance with the updated prompt. + public ImageJobParamsBuilder WithPrompt(string prompt) + => this with { Prompt = prompt }; + + /// + /// Sets the negative prompt describing what to avoid in the generated image. + /// + /// The negative prompt text. + /// A new builder instance with the updated negative prompt. + public ImageJobParamsBuilder WithNegativePrompt(string negativePrompt) + => this with { NegativePrompt = negativePrompt }; + + /// + /// Sets the sampling algorithm to use. + /// + /// The scheduler/sampler. + /// A new builder instance with the updated scheduler. + public ImageJobParamsBuilder WithScheduler(Scheduler scheduler) + => this with { Scheduler = scheduler }; + + /// + /// Sets the number of sampling steps. + /// + /// The step count. Range: 1-100, default: 20. + /// A new builder instance with the updated steps. + public ImageJobParamsBuilder WithSteps(int steps) + => this with { Steps = steps }; + + /// + /// Sets the classifier-free guidance scale. + /// + /// The CFG scale. Range: 1-30, default: 7.0. + /// A new builder instance with the updated CFG scale. + /// + /// Higher values make the image more closely match the prompt but may reduce quality. + /// + public ImageJobParamsBuilder WithCfgScale(decimal cfgScale) + => this with { CfgScale = cfgScale }; + + /// + /// Sets the image dimensions. + /// + /// The width in pixels. Must be a multiple of 8. Range: 64-2048. + /// The height in pixels. Must be a multiple of 8. Range: 64-2048. + /// A new builder instance with the updated dimensions. + public ImageJobParamsBuilder WithSize(int width, int height) + => this with { Width = width, Height = height }; + + /// + /// Sets the image width. + /// + /// The width in pixels. Must be a multiple of 8. Range: 64-2048. + /// A new builder instance with the updated width. + public ImageJobParamsBuilder WithWidth(int width) + => this with { Width = width }; + + /// + /// Sets the image height. + /// + /// The height in pixels. Must be a multiple of 8. Range: 64-2048. + /// A new builder instance with the updated height. + public ImageJobParamsBuilder WithHeight(int height) + => this with { Height = height }; + + /// + /// Sets the random seed for reproducible generation. + /// + /// The seed value. + /// A new builder instance with the updated seed. + public ImageJobParamsBuilder WithSeed(long seed) + => this with { Seed = seed }; + + /// + /// Sets the number of CLIP layers to skip. + /// + /// The number of layers to skip. Range: 1-12. + /// A new builder instance with the updated CLIP skip. + /// + /// A value of 2 is commonly used for anime/Pony models. + /// + public ImageJobParamsBuilder WithClipSkip(int clipSkip) + => this with { ClipSkip = clipSkip }; + + /// + /// Sets the source image URL for image-to-image generation. + /// + /// The source image URL. + /// A new builder instance with the updated source image. + public ImageJobParamsBuilder WithSourceImage(string imageUrl) + => this with { Image = imageUrl }; + + /// + /// Sets the denoising strength for image-to-image generation. + /// + /// The strength value. Range: 0.0-1.0. + /// A new builder instance with the updated strength. + /// + /// Lower values preserve more of the source image; higher values allow more change. + /// + public ImageJobParamsBuilder WithStrength(decimal strength) + => this with { Strength = strength }; + + /// + /// Builds the instance. + /// + /// The configured . + /// Thrown when required properties are missing. + public ImageJobParams Build() + { + if (string.IsNullOrWhiteSpace(Prompt)) + { + throw new InvalidOperationException("Prompt is required. Use WithPrompt() to set it."); + } + + return new ImageJobParams + { + Prompt = Prompt, + NegativePrompt = NegativePrompt, + Scheduler = Scheduler, + Steps = Steps, + CfgScale = CfgScale, + Width = Width, + Height = Height, + Seed = Seed, + ClipSkip = ClipSkip, + Image = Image, + Strength = Strength + }; + } +} diff --git a/Sdk/Request/TextToImageJobBuilder.cs b/Sdk/Request/TextToImageJobBuilder.cs new file mode 100644 index 0000000..65d6cab --- /dev/null +++ b/Sdk/Request/TextToImageJobBuilder.cs @@ -0,0 +1,362 @@ +namespace CivitaiSharp.Sdk.Request; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using CivitaiSharp.Core.Response; +using CivitaiSharp.Sdk.Air; +using CivitaiSharp.Sdk.Models.Jobs; +using CivitaiSharp.Sdk.Models.Results; +using CivitaiSharp.Sdk.Services; + +/// +/// Fluent builder for constructing and submitting text-to-image generation jobs. +/// +/// +/// This builder follows an immutable design pattern. Each method returns a new instance +/// with the updated configuration, making it thread-safe and cacheable. +/// +public sealed record TextToImageJobBuilder( + IJobsService? JobsService = null, + AirIdentifier? Model = null, + ImageJobParamsBuilder? ParamsBuilder = null, + Dictionary? AdditionalNetworks = null, + List? ControlNets = null, + int? Quantity = null, + Priority? Priority = null, + Dictionary? Properties = null, + string? CallbackUrl = null, + int? Retries = null, + string? Timeout = null, + int? ClipSkip = null) +{ + + /// + /// Creates a new instance with the jobs service for submission. + /// + /// The jobs service for submitting jobs. + /// A new builder instance. + public static TextToImageJobBuilder Create(IJobsService jobsService) + { + ArgumentNullException.ThrowIfNull(jobsService); + return new TextToImageJobBuilder(JobsService: jobsService); + } + + /// + /// Sets the base model to use for generation. + /// + /// The AIR identifier for the model. Required. + /// A new builder instance with the updated model. + /// urn:air:sdxl:checkpoint:civitai:4201@130072 + public TextToImageJobBuilder WithModel(AirIdentifier model) + => this with { Model = model }; + + /// + /// Sets the positive prompt for image generation. + /// + /// The prompt text describing what to generate. Required. + /// A new builder instance with the updated prompt. + public TextToImageJobBuilder WithPrompt(string prompt) + { + var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create(); + return this with { ParamsBuilder = builder.WithPrompt(prompt) }; + } + + /// + /// Sets the negative prompt describing what to avoid. + /// + /// The negative prompt text. + /// A new builder instance with the updated negative prompt. + public TextToImageJobBuilder WithNegativePrompt(string negativePrompt) + { + var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create(); + return this with { ParamsBuilder = builder.WithNegativePrompt(negativePrompt) }; + } + + /// + /// Sets the image dimensions. + /// + /// The width in pixels. Must be a multiple of 8. Range: 64-2048. + /// The height in pixels. Must be a multiple of 8. Range: 64-2048. + /// A new builder instance with the updated dimensions. + public TextToImageJobBuilder WithSize(int width, int height) + { + var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create(); + return this with { ParamsBuilder = builder.WithSize(width, height) }; + } + + /// + /// Sets the number of sampling steps. + /// + /// The step count. Range: 1-100, default: 20. + /// A new builder instance with the updated steps. + public TextToImageJobBuilder WithSteps(int steps) + { + var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create(); + return this with { ParamsBuilder = builder.WithSteps(steps) }; + } + + /// + /// Sets the classifier-free guidance scale. + /// + /// The CFG scale. Range: 1-30, default: 7.0. + /// A new builder instance with the updated CFG scale. + public TextToImageJobBuilder WithCfgScale(decimal cfgScale) + { + var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create(); + return this with { ParamsBuilder = builder.WithCfgScale(cfgScale) }; + } + + /// + /// Sets the random seed for reproducible generation. + /// + /// The seed value. + /// A new builder instance with the updated seed. + public TextToImageJobBuilder WithSeed(long seed) + { + var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create(); + return this with { ParamsBuilder = builder.WithSeed(seed) }; + } + + /// + /// Configures generation parameters using a custom builder. + /// + /// The configured parameters builder. + /// A new builder instance with the updated parameters. + public TextToImageJobBuilder WithParams(ImageJobParamsBuilder paramsBuilder) + => this with { ParamsBuilder = paramsBuilder }; + + /// + /// Configures generation parameters using a configuration action. + /// + /// Action to configure the parameters builder. + /// A new builder instance with the updated parameters. + public TextToImageJobBuilder WithParams(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create(); + return this with { ParamsBuilder = configure(builder) }; + } + + /// + /// Adds an additional network (LoRA, embedding, etc.) to the generation. + /// + /// The AIR identifier for the network. + /// The network configuration. + /// A new builder instance with the added network. + public TextToImageJobBuilder AddAdditionalNetwork(AirIdentifier network, ImageJobNetworkParams networkParams) + { + ArgumentNullException.ThrowIfNull(networkParams); + var networks = AdditionalNetworks != null + ? new Dictionary(AdditionalNetworks) + : new Dictionary(); + networks[network] = networkParams; + return this with { AdditionalNetworks = networks }; + } + + /// + /// Adds an additional network (LoRA, embedding, etc.) using a builder. + /// + /// The AIR identifier for the network. + /// The configured network parameters builder. + /// A new builder instance with the added network. + public TextToImageJobBuilder AddAdditionalNetwork(AirIdentifier network, ImageJobNetworkParamsBuilder networkBuilder) + { + ArgumentNullException.ThrowIfNull(networkBuilder); + return AddAdditionalNetwork(network, networkBuilder.Build()); + } + + /// + /// Adds an additional network (LoRA, embedding, etc.) using a configuration action. + /// + /// The AIR identifier for the network. + /// Action to configure the network parameters builder. + /// A new builder instance with the added network. + public TextToImageJobBuilder AddAdditionalNetwork(AirIdentifier network, Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + var builder = configure(ImageJobNetworkParamsBuilder.Create()); + return AddAdditionalNetwork(network, builder.Build()); + } + + /// + /// Adds a ControlNet configuration for guided generation. + /// + /// The ControlNet configuration. + /// A new builder instance with the added ControlNet. + public TextToImageJobBuilder AddControlNet(ImageJobControlNet controlNet) + { + ArgumentNullException.ThrowIfNull(controlNet); + var controlNets = ControlNets != null + ? new List(ControlNets) + : new List(); + controlNets.Add(controlNet); + return this with { ControlNets = controlNets }; + } + + /// + /// Adds a ControlNet configuration using a builder. + /// + /// The configured ControlNet builder. + /// A new builder instance with the added ControlNet. + public TextToImageJobBuilder AddControlNet(ImageJobControlNetBuilder controlNetBuilder) + { + ArgumentNullException.ThrowIfNull(controlNetBuilder); + return AddControlNet(controlNetBuilder.Build()); + } + + /// + /// Adds a ControlNet configuration using a configuration action. + /// + /// Action to configure the ControlNet builder. + /// A new builder instance with the added ControlNet. + public TextToImageJobBuilder AddControlNet(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + var builder = configure(ImageJobControlNetBuilder.Create()); + return AddControlNet(builder.Build()); + } + + /// + /// Sets the number of images to generate. + /// + /// The quantity. Range: 1-10, default: 1. + /// A new builder instance with the updated quantity. + public TextToImageJobBuilder WithQuantity(int quantity) + => this with { Quantity = quantity }; + + /// + /// Sets the priority configuration for job scheduling. + /// + /// The priority configuration. + /// A new builder instance with the updated priority. + public TextToImageJobBuilder WithPriority(Priority priority) + => this with { Priority = priority }; + + /// + /// Adds a custom property for job tracking and querying. + /// + /// The property key. + /// The property value (must be JSON-serializable). + /// A new builder instance with the added property. + public TextToImageJobBuilder AddProperty(string key, JsonElement value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + var properties = Properties != null + ? new Dictionary(Properties) + : new Dictionary(); + properties[key] = value; + return this with { Properties = properties }; + } + + /// + /// Sets the webhook URL to call when the job completes. + /// + /// The webhook URL. + /// A new builder instance with the updated callback URL. + public TextToImageJobBuilder WithCallbackUrl(string callbackUrl) + => this with { CallbackUrl = callbackUrl }; + + /// + /// Sets the number of automatic retries on failure. + /// + /// The number of retry attempts. Default: 0. + /// A new builder instance with the updated retry attempts. + public TextToImageJobBuilder WithRetries(int retries) + => this with { Retries = retries }; + + /// + /// Sets the job timeout. + /// + /// The timeout duration. Format: "HH:mm:ss". Default: "00:10:00". + /// A new builder instance with the updated timeout. + public TextToImageJobBuilder WithTimeout(string timeout) + => this with { Timeout = timeout }; + + /// + /// Sets the job timeout. + /// + /// The timeout duration. Default: 10 minutes. + /// A new builder instance with the updated timeout. + public TextToImageJobBuilder WithTimeout(TimeSpan timeout) + => this with { Timeout = timeout.ToString(@"hh\:mm\:ss") }; + + /// + /// Sets the number of CLIP layers to skip. + /// + /// The number of layers to skip. Range: 1-12. + /// A new builder instance with the updated CLIP skip. + /// + /// A value of 2 is commonly used for anime/Pony models. + /// This can also be set via . + /// + public TextToImageJobBuilder WithClipSkip(int clipSkip) + => this with { ClipSkip = clipSkip }; + + /// + /// Builds the instance. + /// + /// The configured . + /// Thrown when required properties are missing. + public TextToImageJobRequest Build() + { + if (Model == null) + { + throw new InvalidOperationException("Model is required. Use WithModel() to set it."); + } + + if (ParamsBuilder == null) + { + throw new InvalidOperationException("Parameters are required. Use WithPrompt() or WithParams() to set them."); + } + + return new TextToImageJobRequest + { + Model = Model.Value, + Params = ParamsBuilder.Build(), + AdditionalNetworks = AdditionalNetworks?.Count > 0 + ? AdditionalNetworks + : null, + ControlNets = ControlNets?.Count > 0 + ? ControlNets.AsReadOnly() + : null, + Quantity = Quantity, + Priority = Priority, + Properties = Properties?.Count > 0 + ? Properties + : null, + CallbackUrl = CallbackUrl, + Retries = Retries, + Timeout = Timeout, + ClipSkip = ClipSkip + }; + } + + /// + /// Submits the job to the Civitai API. + /// + /// If true, blocks until the job completes (up to ~10 minutes). + /// If true, includes the original job specification in the response. + /// Token to cancel the asynchronous operation. + /// A task containing the job status collection with a token for polling. + /// + /// Thrown when the builder was not created via the jobs service factory method. + /// + public Task> SubmitAsync( + bool wait = false, + bool detailed = false, + CancellationToken cancellationToken = default) + { + if (JobsService == null) + { + throw new InvalidOperationException( + "Cannot submit job. This builder was not created via IJobsService.CreateTextToImage()."); + } + + var request = Build(); + return JobsService.SubmitAsync(request, wait, detailed, cancellationToken); + } +} diff --git a/Sdk/CivitaiSdkClient.cs b/Sdk/SdkClient.cs similarity index 83% rename from Sdk/CivitaiSdkClient.cs rename to Sdk/SdkClient.cs index 3be4722..565c621 100644 --- a/Sdk/CivitaiSdkClient.cs +++ b/Sdk/SdkClient.cs @@ -8,18 +8,18 @@ namespace CivitaiSharp.Sdk; /// Primary client facade for the Civitai Generator SDK. Provides access to image generation, /// model availability checking, and usage tracking via the orchestration endpoints. /// Obtain an instance through dependency injection using -/// . +/// . /// -public sealed class CivitaiSdkClient : ICivitaiSdkClient +public sealed class SdkClient : ISdkClient { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// Internal to enforce dependency injection usage. /// /// The HTTP client used to make API requests. /// The SDK client options. /// Thrown when or is null. - internal CivitaiSdkClient(SdkHttpClient httpClient, CivitaiSdkClientOptions options) + internal SdkClient(SdkHttpClient httpClient, SdkClientOptions options) { ArgumentNullException.ThrowIfNull(httpClient); ArgumentNullException.ThrowIfNull(options); diff --git a/Sdk/CivitaiSdkClientOptions.cs b/Sdk/SdkClientOptions.cs similarity index 85% rename from Sdk/CivitaiSdkClientOptions.cs rename to Sdk/SdkClientOptions.cs index 9ef1aa4..687e9e1 100644 --- a/Sdk/CivitaiSdkClientOptions.cs +++ b/Sdk/SdkClientOptions.cs @@ -7,7 +7,7 @@ namespace CivitaiSharp.Sdk; /// Unlike the public API used by CivitaiSharp.Core, the Generator API requires authentication /// for all requests, so the property must be set before the client can be used. /// -public sealed class CivitaiSdkClientOptions +public sealed class SdkClientOptions { /// /// Default base URL for the Civitai Orchestration API. @@ -36,13 +36,10 @@ public sealed class CivitaiSdkClientOptions private int _timeoutSeconds = DefaultTimeoutSeconds; /// - /// Gets or sets the API token for authentication. - /// This is required for all SDK operations. + /// API token for authentication (required). Obtain from https://civitai.com/user/account /// - /// - /// Obtain your API token from https://civitai.com/user/account - /// - /// Thrown when value is null or whitespace. + /// Thrown when value is null. + /// Thrown when value is empty or whitespace. public required string ApiToken { get => _apiToken; @@ -54,9 +51,10 @@ public required string ApiToken } /// - /// Gets or sets the base URL for the Civitai Orchestration API. + /// Base URL for the Civitai Orchestration API. /// - /// Thrown when value is null, whitespace, or not a valid absolute URI. + /// Thrown when value is null. + /// Thrown when value is empty, whitespace, or not a valid absolute URI. public string BaseUrl { get => _baseUrl; @@ -73,9 +71,10 @@ public string BaseUrl } /// - /// Gets or sets the API version path segment (e.g., "v1"). + /// API version path segment (e.g., "v1"). /// - /// Thrown when value is null or whitespace. + /// Thrown when value is null. + /// Thrown when value is empty or whitespace. public string ApiVersion { get => _apiVersion; @@ -129,7 +128,8 @@ public void Validate() /// /// The relative endpoint path (e.g., "jobs", "coverage"). /// The full API path including version prefix and consumer path. - /// Thrown if relativePath is null or whitespace. + /// Thrown if relativePath is null. + /// Thrown if relativePath is empty or whitespace. public string GetApiPath(string relativePath) { ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); diff --git a/Sdk/Services/CoverageService.cs b/Sdk/Services/CoverageService.cs index a850ed7..fab106d 100644 --- a/Sdk/Services/CoverageService.cs +++ b/Sdk/Services/CoverageService.cs @@ -6,57 +6,45 @@ namespace CivitaiSharp.Sdk.Services; using System.Threading; using System.Threading.Tasks; using CivitaiSharp.Core.Response; +using CivitaiSharp.Sdk.Air; using CivitaiSharp.Sdk.Http; using CivitaiSharp.Sdk.Models.Coverage; /// /// Implementation of the Coverage service for checking model availability. /// -internal sealed class CoverageService : ICoverageService +/// +/// This service is registered as a singleton and holds references to the HTTP client and options. +/// It is created via dependency injection and should not be instantiated directly. +/// +/// The HTTP client for API requests. +/// The SDK client options. +/// Thrown when or is null. +internal sealed class CoverageService(SdkHttpClient httpClient, SdkClientOptions options) : ICoverageService { - private readonly SdkHttpClient _httpClient; - private readonly CivitaiSdkClientOptions _options; - - /// - /// Initializes a new instance of the class. - /// - /// The HTTP client for API requests. - /// The SDK client options. - /// Thrown when or is null. - internal CoverageService(SdkHttpClient httpClient, CivitaiSdkClientOptions options) - { - ArgumentNullException.ThrowIfNull(httpClient); - ArgumentNullException.ThrowIfNull(options); - - _httpClient = httpClient; - _options = options; - } - /// - public Task>> GetAsync( - IEnumerable models, + public Task>> GetAsync( + IEnumerable models, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(models); // Avoid double enumeration by checking if already a list - var modelList = models as IReadOnlyList ?? models.ToList(); + var modelList = models as IReadOnlyList ?? models.ToList(); if (modelList.Count == 0) { throw new ArgumentException("At least one model is required.", nameof(models)); } var uri = BuildCoverageUri(modelList); - return _httpClient.GetAsync>(uri, cancellationToken); + return httpClient.GetAsync>(uri, cancellationToken); } /// public async Task> GetAsync( - string model, + AirIdentifier model, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(model); - var result = await GetAsync([model], cancellationToken).ConfigureAwait(false); return result.Match>( @@ -73,14 +61,14 @@ public async Task> GetAsync( onFailure: error => new Result.Failure(error)); } - private string BuildCoverageUri(IReadOnlyList models) + private string BuildCoverageUri(IReadOnlyList models) { - var path = _options.GetApiPath("coverage"); + var path = options.GetApiPath("coverage"); var query = new QueryStringBuilder(); foreach (var model in models) { - query.Append("model", model); + query.Append("model", model.ToString()); } return query.BuildUri(path); diff --git a/Sdk/Services/ICoverageService.cs b/Sdk/Services/ICoverageService.cs index a2852fa..87b586d 100644 --- a/Sdk/Services/ICoverageService.cs +++ b/Sdk/Services/ICoverageService.cs @@ -4,6 +4,7 @@ namespace CivitaiSharp.Sdk.Services; using System.Threading; using System.Threading.Tasks; using CivitaiSharp.Core.Response; +using CivitaiSharp.Sdk.Air; using CivitaiSharp.Sdk.Models.Coverage; /// @@ -16,11 +17,9 @@ public interface ICoverageService /// /// The AIR identifiers of the models to check. /// Token to cancel the asynchronous operation. - /// - /// A task containing a dictionary mapping AIR identifiers to their availability information. - /// - Task>> GetAsync( - IEnumerable models, + /// Dictionary mapping AIR identifiers to their availability information. + Task>> GetAsync( + IEnumerable models, CancellationToken cancellationToken = default); /// @@ -28,8 +27,8 @@ Task>> GetAsync( /// /// The AIR identifier of the model to check. /// Token to cancel the asynchronous operation. - /// A task containing the availability information for the model. + /// Availability information for the model. Task> GetAsync( - string model, + AirIdentifier model, CancellationToken cancellationToken = default); } diff --git a/Sdk/Services/IJobsService.cs b/Sdk/Services/IJobsService.cs index bd274c6..a71a27e 100644 --- a/Sdk/Services/IJobsService.cs +++ b/Sdk/Services/IJobsService.cs @@ -7,12 +7,30 @@ namespace CivitaiSharp.Sdk.Services; using CivitaiSharp.Core.Response; using CivitaiSharp.Sdk.Models.Jobs; using CivitaiSharp.Sdk.Models.Results; +using CivitaiSharp.Sdk.Request; /// /// Service for submitting, tracking, and managing image generation jobs. /// public interface IJobsService { + /// + /// Creates a fluent builder for text-to-image generation jobs with compile-time validation. + /// + /// A new instance. + /// + /// + /// var result = await client.Jobs + /// .CreateTextToImage() + /// .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + /// .WithPrompt("A beautiful sunset over mountains") + /// .WithSize(1024, 1024) + /// .WithSteps(30) + /// .SubmitAsync(); + /// + /// + TextToImageJobBuilder CreateTextToImage(); + /// /// Submits a single text-to-image generation job. /// diff --git a/Sdk/Services/IUsageService.cs b/Sdk/Services/IUsageService.cs index 91ddd05..49ae281 100644 --- a/Sdk/Services/IUsageService.cs +++ b/Sdk/Services/IUsageService.cs @@ -14,10 +14,10 @@ public interface IUsageService /// /// Gets account consumption statistics for the specified period. /// - /// Optional start date for the reporting period (ISO 8601 format). - /// Optional end date for the reporting period (ISO 8601 format). + /// Start date for reporting period (ISO 8601 format). + /// End date for reporting period (ISO 8601 format). /// Token to cancel the asynchronous operation. - /// A task containing the consumption details. + /// Consumption details. Task> GetConsumptionAsync( DateTime? startDate = null, DateTime? endDate = null, diff --git a/Sdk/Services/JobsService.cs b/Sdk/Services/JobsService.cs index 7d8002a..f4766ff 100644 --- a/Sdk/Services/JobsService.cs +++ b/Sdk/Services/JobsService.cs @@ -9,29 +9,23 @@ namespace CivitaiSharp.Sdk.Services; using CivitaiSharp.Sdk.Http; using CivitaiSharp.Sdk.Models.Jobs; using CivitaiSharp.Sdk.Models.Results; +using CivitaiSharp.Sdk.Request; /// /// Implementation of the Jobs service for managing image generation jobs. /// -internal sealed class JobsService : IJobsService +/// +/// This service is registered as a singleton and holds references to the HTTP client and options. +/// It is created via dependency injection and should not be instantiated directly. +/// +/// The HTTP client for API requests. +/// The SDK client options. +/// Thrown when or is null. +internal sealed class JobsService(SdkHttpClient httpClient, SdkClientOptions options) : IJobsService { - private readonly SdkHttpClient _httpClient; - private readonly CivitaiSdkClientOptions _options; - - /// - /// Initializes a new instance of the class. - /// - /// The HTTP client for API requests. - /// The SDK client options. - /// Thrown when or is null. - internal JobsService(SdkHttpClient httpClient, CivitaiSdkClientOptions options) - { - ArgumentNullException.ThrowIfNull(httpClient); - ArgumentNullException.ThrowIfNull(options); - - _httpClient = httpClient; - _options = options; - } + /// + public TextToImageJobBuilder CreateTextToImage() + => TextToImageJobBuilder.Create(this); /// public Task> SubmitAsync( @@ -42,8 +36,17 @@ public Task> SubmitAsync( { ArgumentNullException.ThrowIfNull(request); + // Validate ControlNet configurations if present + if (request.ControlNets is not null) + { + foreach (var controlNet in request.ControlNets) + { + controlNet.Validate(); + } + } + var uri = BuildJobsUri(wait, detailed); - return _httpClient.PostAsync(uri, request, cancellationToken); + return httpClient.PostAsync(uri, request, cancellationToken); } /// @@ -62,9 +65,21 @@ public Task> SubmitBatchAsync( throw new ArgumentException("At least one job request is required.", nameof(requests)); } + // Validate all ControlNet configurations if present + foreach (var request in jobsList) + { + if (request.ControlNets is not null) + { + foreach (var controlNet in request.ControlNets) + { + controlNet.Validate(); + } + } + } + var uri = BuildJobsUri(wait, detailed); var batchRequest = new BatchJobRequest { Jobs = jobsList }; - return _httpClient.PostAsync(uri, batchRequest, cancellationToken); + return httpClient.PostAsync(uri, batchRequest, cancellationToken); } /// @@ -76,7 +91,7 @@ public Task> GetByIdAsync( ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId)); var uri = BuildUri($"jobs/{jobId}", detailed: detailed); - return _httpClient.GetAsync(uri, cancellationToken); + return httpClient.GetAsync(uri, cancellationToken); } /// @@ -89,7 +104,7 @@ public Task> GetByTokenAsync( ArgumentException.ThrowIfNullOrWhiteSpace(token); var uri = BuildUri("jobs", token: token, wait: wait, detailed: detailed); - return _httpClient.GetAsync(uri, cancellationToken); + return httpClient.GetAsync(uri, cancellationToken); } /// @@ -101,7 +116,7 @@ public Task> QueryAsync( ArgumentNullException.ThrowIfNull(request); var uri = BuildUri("jobs/query", detailed: detailed); - return _httpClient.PostAsync(uri, request, cancellationToken); + return httpClient.PostAsync(uri, request, cancellationToken); } /// @@ -113,7 +128,7 @@ public Task> CancelByIdAsync( ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId)); var uri = BuildUri($"jobs/{jobId}", force: force); - return _httpClient.DeleteAsync(uri, cancellationToken); + return httpClient.DeleteAsync(uri, cancellationToken); } /// @@ -125,7 +140,7 @@ public Task> CancelByTokenAsync( ArgumentException.ThrowIfNullOrWhiteSpace(token); var uri = BuildUri("jobs", token: token, force: force); - return _httpClient.DeleteAsync(uri, cancellationToken); + return httpClient.DeleteAsync(uri, cancellationToken); } /// @@ -135,8 +150,8 @@ public Task> TaintByIdAsync( { ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId)); - var uri = _options.GetApiPath($"jobs/{jobId}/taint"); - return _httpClient.PutAsync(uri, cancellationToken); + var uri = options.GetApiPath($"jobs/{jobId}/taint"); + return httpClient.PutAsync(uri, cancellationToken); } /// @@ -147,7 +162,7 @@ public Task> TaintByTokenAsync( ArgumentException.ThrowIfNullOrWhiteSpace(token); var uri = BuildUri("jobs/taint", token: token); - return _httpClient.PutAsync(uri, cancellationToken); + return httpClient.PutAsync(uri, cancellationToken); } private string BuildJobsUri(bool wait, bool detailed) @@ -155,7 +170,7 @@ private string BuildJobsUri(bool wait, bool detailed) var query = new QueryStringBuilder() .AppendIf("wait", wait) .AppendIf("detailed", detailed); - return query.BuildUri(_options.GetApiPath("jobs")); + return query.BuildUri(options.GetApiPath("jobs")); } private string BuildUri( @@ -170,6 +185,6 @@ private string BuildUri( .AppendIf("wait", wait) .AppendIf("detailed", detailed) .Append("force", force); - return query.BuildUri(_options.GetApiPath(relativePath)); + return query.BuildUri(options.GetApiPath(relativePath)); } } diff --git a/Sdk/Services/UsageService.cs b/Sdk/Services/UsageService.cs index 860efba..eefc8c1 100644 --- a/Sdk/Services/UsageService.cs +++ b/Sdk/Services/UsageService.cs @@ -10,26 +10,15 @@ namespace CivitaiSharp.Sdk.Services; /// /// Implementation of the Usage service for monitoring account consumption. /// -internal sealed class UsageService : IUsageService +/// +/// This service is registered as a singleton and holds references to the HTTP client and options. +/// It is created via dependency injection and should not be instantiated directly. +/// +/// The HTTP client for API requests. +/// The SDK client options. +/// Thrown when or is null. +internal sealed class UsageService(SdkHttpClient httpClient, SdkClientOptions options) : IUsageService { - private readonly SdkHttpClient _httpClient; - private readonly CivitaiSdkClientOptions _options; - - /// - /// Initializes a new instance of the class. - /// - /// The HTTP client for API requests. - /// The SDK client options. - /// Thrown when or is null. - internal UsageService(SdkHttpClient httpClient, CivitaiSdkClientOptions options) - { - ArgumentNullException.ThrowIfNull(httpClient); - ArgumentNullException.ThrowIfNull(options); - - _httpClient = httpClient; - _options = options; - } - /// public Task> GetConsumptionAsync( DateTime? startDate = null, @@ -40,7 +29,7 @@ public Task> GetConsumptionAsync( .Append("startDate", startDate) .Append("endDate", endDate); - var uri = query.BuildUri(_options.GetApiPath("consumption")); - return _httpClient.GetAsync(uri, cancellationToken); + var uri = query.BuildUri(options.GetApiPath("consumption")); + return httpClient.GetAsync(uri, cancellationToken); } } diff --git a/Tests/CivitaiSharp.Sdk.Tests/Air/AirBuilderTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Air/AirBuilderTests.cs new file mode 100644 index 0000000..8be49aa --- /dev/null +++ b/Tests/CivitaiSharp.Sdk.Tests/Air/AirBuilderTests.cs @@ -0,0 +1,594 @@ +namespace CivitaiSharp.Sdk.Tests.Air; + +using System; +using CivitaiSharp.Sdk.Air; +using Xunit; + +public sealed class AirBuilderTests : IClassFixture +{ + #region Successful Build Tests + + [Fact] + public void WhenAllRequiredPropertiesAreSetThenBuildSucceeds() + { + // Arrange + var builder = new AirBuilder(); + + // Act + var result = builder + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(328553) + .WithVersionId(368189) + .Build(); + + // Assert + Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem); + Assert.Equal(AirAssetType.Lora, result.AssetType); + Assert.Equal(AirSource.Civitai, result.Source); + Assert.Equal(328553, result.ModelId); + Assert.Equal(368189, result.VersionId); + } + + [Fact] + public void WhenBuildingWithDefaultSourceThenSourceIsCivitai() + { + // Arrange & Act + var result = new AirBuilder() + .WithEcosystem(AirEcosystem.Flux1) + .WithAssetType(AirAssetType.Checkpoint) + .WithModelId(12345) + .WithVersionId(67890) + .Build(); + + // Assert + Assert.Equal(AirSource.Civitai, result.Source); + } + + [Fact] + public void WhenBuildingWithCustomSourceThenSourceIsSet() + { + // Arrange & Act + var result = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusion1) + .WithAssetType(AirAssetType.Vae) + .WithSource(AirSource.HuggingFace) + .WithModelId(100) + .WithVersionId(200) + .Build(); + + // Assert + Assert.Equal(AirSource.HuggingFace, result.Source); + } + + [Fact] + public void WhenBuildingWithAllEcosystemsThenAllSucceed() + { + // Arrange + var ecosystems = new[] + { + AirEcosystem.StableDiffusion1, + AirEcosystem.StableDiffusion2, + AirEcosystem.StableDiffusionXl, + AirEcosystem.Flux1, + AirEcosystem.Pony + }; + + // Act & Assert + foreach (var ecosystem in ecosystems) + { + var result = new AirBuilder() + .WithEcosystem(ecosystem) + .WithAssetType(AirAssetType.Checkpoint) + .WithModelId(1) + .WithVersionId(1) + .Build(); + + Assert.Equal(ecosystem, result.Ecosystem); + } + } + + [Fact] + public void WhenBuildingWithAllAssetTypesThenAllSucceed() + { + // Arrange + var assetTypes = new[] + { + AirAssetType.Checkpoint, + AirAssetType.Lora, + AirAssetType.Lycoris, + AirAssetType.Vae, + AirAssetType.Embedding, + AirAssetType.Hypernetwork + }; + + // Act & Assert + foreach (var assetType in assetTypes) + { + var result = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(assetType) + .WithModelId(1) + .WithVersionId(1) + .Build(); + + Assert.Equal(assetType, result.AssetType); + } + } + + #endregion + + #region Fluent API Chaining Tests + + [Fact] + public void WhenUsingFluentApiThenMethodsChainingWorks() + { + // Arrange & Act + var result = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(328553) + .WithVersionId(368189) + .WithSource(AirSource.Civitai) + .Build(); + + // Assert + Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem); + Assert.Equal(AirAssetType.Lora, result.AssetType); + } + + [Fact] + public void WhenSettingPropertiesInDifferentOrderThenBuildSucceeds() + { + // Arrange & Act + var result = new AirBuilder() + .WithVersionId(368189) + .WithModelId(328553) + .WithAssetType(AirAssetType.Lora) + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .Build(); + + // Assert + Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem); + Assert.Equal(AirAssetType.Lora, result.AssetType); + Assert.Equal(328553, result.ModelId); + Assert.Equal(368189, result.VersionId); + } + + #endregion + + #region Immutability Tests + + [Fact] + public void WhenWithMethodIsCalledThenOriginalBuilderIsUnchanged() + { + // Arrange + var original = new AirBuilder(); + + // Act + var modified = original + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(328553) + .WithVersionId(368189); + + // Assert - original should still fail to build (unchanged) + Assert.Throws(() => original.Build()); + + // modified should build successfully + var result = modified.Build(); + Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem); + } + + [Fact] + public void WhenBuildingFromSameBaseBuilderThenResultsAreIndependent() + { + // Arrange + var baseBuilder = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora); + + // Act - create two different configurations from the same base + var air1 = baseBuilder + .WithModelId(100) + .WithVersionId(200) + .Build(); + + var air2 = baseBuilder + .WithModelId(300) + .WithVersionId(400) + .Build(); + + // Assert - both should have SDXL/Lora but different IDs + Assert.Equal(AirEcosystem.StableDiffusionXl, air1.Ecosystem); + Assert.Equal(AirEcosystem.StableDiffusionXl, air2.Ecosystem); + Assert.Equal(100, air1.ModelId); + Assert.Equal(300, air2.ModelId); + } + + [Fact] + public void WhenNewBuilderIsCreatedThenSourceDefaultsToCivitai() + { + // Arrange & Act + var result = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(1) + .WithVersionId(1) + .Build(); + + // Assert + Assert.Equal(AirSource.Civitai, result.Source); + } + + #endregion + + #region TryBuild Tests + + [Fact] + public void WhenAllRequiredPropertiesAreSetThenTryBuildSucceeds() + { + // Arrange + var builder = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(328553) + .WithVersionId(368189); + + // Act + var success = builder.TryBuild(out var result); + + // Assert + Assert.True(success); + Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem); + Assert.Equal(AirAssetType.Lora, result.AssetType); + Assert.Equal(AirSource.Civitai, result.Source); + Assert.Equal(328553, result.ModelId); + Assert.Equal(368189, result.VersionId); + } + + [Fact] + public void WhenEcosystemIsMissingThenTryBuildReturnsFalse() + { + // Arrange + var builder = new AirBuilder() + .WithAssetType(AirAssetType.Lora) + .WithModelId(328553) + .WithVersionId(368189); + + // Act + var success = builder.TryBuild(out var result); + + // Assert + Assert.False(success); + Assert.Equal(default, result); + } + + [Fact] + public void WhenAssetTypeIsMissingThenTryBuildReturnsFalse() + { + // Arrange + var builder = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithModelId(328553) + .WithVersionId(368189); + + // Act + var success = builder.TryBuild(out _); + + // Assert + Assert.False(success); + } + + [Fact] + public void WhenModelIdIsMissingThenTryBuildReturnsFalse() + { + // Arrange + var builder = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithVersionId(368189); + + // Act + var success = builder.TryBuild(out _); + + // Assert + Assert.False(success); + } + + [Fact] + public void WhenVersionIdIsMissingThenTryBuildReturnsFalse() + { + // Arrange + var builder = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(328553); + + // Act + var success = builder.TryBuild(out _); + + // Assert + Assert.False(success); + } + + [Fact] + public void WhenNoPropertiesAreSetThenTryBuildReturnsFalse() + { + // Arrange + var builder = new AirBuilder(); + + // Act + var success = builder.TryBuild(out _); + + // Assert + Assert.False(success); + } + + [Fact] + public void WhenTryBuildSucceedsThenResultMatchesBuild() + { + // Arrange + var builder = new AirBuilder() + .WithEcosystem(AirEcosystem.Flux1) + .WithAssetType(AirAssetType.Checkpoint) + .WithSource(AirSource.HuggingFace) + .WithModelId(4201) + .WithVersionId(130072); + + // Act + var buildResult = builder.Build(); + var tryBuildSuccess = builder.TryBuild(out var tryBuildResult); + + // Assert + Assert.True(tryBuildSuccess); + Assert.Equal(buildResult, tryBuildResult); + } + + #endregion + + #region Validation Tests - Missing Required Properties + + [Fact] + public void WhenBuildingWithoutEcosystemThenThrowsInvalidOperationException() + { + // Arrange + var builder = new AirBuilder() + .WithAssetType(AirAssetType.Lora) + .WithModelId(328553) + .WithVersionId(368189); + + // Act & Assert + var exception = Assert.Throws(() => builder.Build()); + Assert.Contains("Ecosystem", exception.Message); + Assert.Contains("WithEcosystem", exception.Message); + } + + [Fact] + public void WhenBuildingWithoutAssetTypeThenThrowsInvalidOperationException() + { + // Arrange + var builder = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithModelId(328553) + .WithVersionId(368189); + + // Act & Assert + var exception = Assert.Throws(() => builder.Build()); + Assert.Contains("AssetType", exception.Message); + Assert.Contains("WithAssetType", exception.Message); + } + + [Fact] + public void WhenBuildingWithoutModelIdThenThrowsInvalidOperationException() + { + // Arrange + var builder = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithVersionId(368189); + + // Act & Assert + var exception = Assert.Throws(() => builder.Build()); + Assert.Contains("ModelId", exception.Message); + Assert.Contains("WithModelId", exception.Message); + } + + [Fact] + public void WhenBuildingWithoutVersionIdThenThrowsInvalidOperationException() + { + // Arrange + var builder = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(328553); + + // Act & Assert + var exception = Assert.Throws(() => builder.Build()); + Assert.Contains("VersionId", exception.Message); + Assert.Contains("WithVersionId", exception.Message); + } + + #endregion + + #region Validation Tests - Invalid Values + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void WhenSettingInvalidModelIdThenThrowsArgumentOutOfRangeException(long invalidModelId) + { + // Arrange + var builder = new AirBuilder(); + + // Act & Assert + Assert.Throws(() => builder.WithModelId(invalidModelId)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void WhenSettingInvalidVersionIdThenThrowsArgumentOutOfRangeException(long invalidVersionId) + { + // Arrange + var builder = new AirBuilder(); + + // Act & Assert + Assert.Throws(() => builder.WithVersionId(invalidVersionId)); + } + + [Fact] + public void WhenSettingAllSourceTypesThenBuildSucceeds() + { + // Arrange + var sources = new[] + { + AirSource.Civitai, + AirSource.HuggingFace, + AirSource.OpenAi, + AirSource.Leonardo + }; + + // Act & Assert + foreach (var source in sources) + { + var result = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Checkpoint) + .WithSource(source) + .WithModelId(1) + .WithVersionId(1) + .Build(); + + Assert.Equal(source, result.Source); + } + } + + #endregion + + #region Edge Case Tests + + [Fact] + public void WhenSettingLargeModelIdThenBuildSucceeds() + { + // Arrange & Act + var result = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(long.MaxValue) + .WithVersionId(1) + .Build(); + + // Assert + Assert.Equal(long.MaxValue, result.ModelId); + } + + [Fact] + public void WhenSettingLargeVersionIdThenBuildSucceeds() + { + // Arrange & Act + var result = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(1) + .WithVersionId(long.MaxValue) + .Build(); + + // Assert + Assert.Equal(long.MaxValue, result.VersionId); + } + + [Fact] + public void WhenOverwritingPropertyThenLastValueIsUsed() + { + // Arrange & Act + var result = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusion1) + .WithEcosystem(AirEcosystem.Flux1) // Overwrite + .WithAssetType(AirAssetType.Checkpoint) + .WithModelId(100) + .WithModelId(200) // Overwrite + .WithVersionId(1) + .Build(); + + // Assert + Assert.Equal(AirEcosystem.Flux1, result.Ecosystem); + Assert.Equal(200, result.ModelId); + } + + #endregion + + #region Integration Tests with AirIdentifier + + [Fact] + public void WhenBuiltAirIdentifierIsConvertedToStringThenFormatIsCorrect() + { + // Arrange & Act + var air = new AirBuilder() + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(328553) + .WithVersionId(368189) + .Build(); + + var airString = air.ToString(); + + // Assert + Assert.Equal("urn:air:sdxl:lora:civitai:328553@368189", airString); + } + + [Fact] + public void WhenBuiltAirIdentifierCanBeParsedBackThenValuesMatch() + { + // Arrange + var original = new AirBuilder() + .WithEcosystem(AirEcosystem.Flux1) + .WithAssetType(AirAssetType.Checkpoint) + .WithModelId(4201) + .WithVersionId(130072) + .Build(); + + // Act + var airString = original.ToString(); + var parsed = AirIdentifier.Parse(airString); + + // Assert + Assert.Equal(original.Ecosystem, parsed.Ecosystem); + Assert.Equal(original.AssetType, parsed.AssetType); + Assert.Equal(original.Source, parsed.Source); + Assert.Equal(original.ModelId, parsed.ModelId); + Assert.Equal(original.VersionId, parsed.VersionId); + } + + [Fact] + public void WhenBuildingMultipleAirIdentifiersFromSameBuilderThenAllAreIndependent() + { + // Arrange - immutable builder allows safe reuse + var baseBuilder = new AirBuilder(); + + // Act + var air1 = baseBuilder + .WithEcosystem(AirEcosystem.StableDiffusionXl) + .WithAssetType(AirAssetType.Lora) + .WithModelId(100) + .WithVersionId(200) + .Build(); + + var air2 = baseBuilder + .WithEcosystem(AirEcosystem.Flux1) + .WithAssetType(AirAssetType.Checkpoint) + .WithModelId(300) + .WithVersionId(400) + .Build(); + + // Assert + Assert.NotEqual(air1.Ecosystem, air2.Ecosystem); + Assert.NotEqual(air1.AssetType, air2.AssetType); + Assert.NotEqual(air1.ModelId, air2.ModelId); + Assert.NotEqual(air1.VersionId, air2.VersionId); + } + + #endregion +} diff --git a/Tests/CivitaiSharp.Sdk.Tests/Air/AirIdentifierTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Air/AirIdentifierTests.cs index 79f4037..b14800b 100644 --- a/Tests/CivitaiSharp.Sdk.Tests/Air/AirIdentifierTests.cs +++ b/Tests/CivitaiSharp.Sdk.Tests/Air/AirIdentifierTests.cs @@ -14,54 +14,18 @@ public void WhenCreatingWithValidParametersThenPropertiesAreSet() var air = new AirIdentifier( AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, - "civitai", + AirSource.Civitai, 4201, 130072); // Assert Assert.Equal(AirEcosystem.StableDiffusionXl, air.Ecosystem); Assert.Equal(AirAssetType.Checkpoint, air.AssetType); - Assert.Equal("civitai", air.Source); + Assert.Equal(AirSource.Civitai, air.Source); Assert.Equal(4201, air.ModelId); Assert.Equal(130072, air.VersionId); } - [Fact] - public void WhenCreatingWithNullSourceThenThrowsArgumentNullException() - { - // Act & Assert - Assert.Throws(() => new AirIdentifier( - AirEcosystem.StableDiffusionXl, - AirAssetType.Checkpoint, - null!, - 4201, - 130072)); - } - - [Fact] - public void WhenCreatingWithEmptySourceThenThrowsArgumentException() - { - // Act & Assert - Assert.Throws(() => new AirIdentifier( - AirEcosystem.StableDiffusionXl, - AirAssetType.Checkpoint, - "", - 4201, - 130072)); - } - - [Fact] - public void WhenCreatingWithWhitespaceSourceThenThrowsArgumentException() - { - // Act & Assert - Assert.Throws(() => new AirIdentifier( - AirEcosystem.StableDiffusionXl, - AirAssetType.Checkpoint, - " ", - 4201, - 130072)); - } - [Fact] public void WhenCreatingWithZeroModelIdThenThrowsArgumentOutOfRangeException() { @@ -69,7 +33,7 @@ public void WhenCreatingWithZeroModelIdThenThrowsArgumentOutOfRangeException() Assert.Throws(() => new AirIdentifier( AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, - "civitai", + AirSource.Civitai, 0, 130072)); } @@ -81,7 +45,7 @@ public void WhenCreatingWithNegativeModelIdThenThrowsArgumentOutOfRangeException Assert.Throws(() => new AirIdentifier( AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, - "civitai", + AirSource.Civitai, -1, 130072)); } @@ -93,7 +57,7 @@ public void WhenCreatingWithZeroVersionIdThenThrowsArgumentOutOfRangeException() Assert.Throws(() => new AirIdentifier( AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, - "civitai", + AirSource.Civitai, 4201, 0)); } @@ -105,7 +69,7 @@ public void WhenCreatingWithNegativeVersionIdThenThrowsArgumentOutOfRangeExcepti Assert.Throws(() => new AirIdentifier( AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, - "civitai", + AirSource.Civitai, 4201, -1)); } @@ -125,7 +89,7 @@ public void WhenCreatingViaFactoryMethodThenSourceIsCivitai() 67890); // Assert - Assert.Equal("civitai", air.Source); + Assert.Equal(AirSource.Civitai, air.Source); Assert.Equal(AirEcosystem.Flux1, air.Ecosystem); Assert.Equal(AirAssetType.Lora, air.AssetType); Assert.Equal(12345, air.ModelId); @@ -149,22 +113,26 @@ public void WhenParsingValidAirStringThenReturnsTrue() Assert.True(success); Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem); Assert.Equal(AirAssetType.Checkpoint, result.AssetType); - Assert.Equal("civitai", result.Source); + Assert.Equal(AirSource.Civitai, result.Source); Assert.Equal(4201, result.ModelId); Assert.Equal(130072, result.VersionId); } [Theory] - [InlineData("urn:air:sd1:lora:civitai:100@200", AirEcosystem.StableDiffusion1, AirAssetType.Lora)] - [InlineData("urn:air:sd2:vae:civitai:300@400", AirEcosystem.StableDiffusion2, AirAssetType.Vae)] - [InlineData("urn:air:flux1:checkpoint:civitai:500@600", AirEcosystem.Flux1, AirAssetType.Checkpoint)] - [InlineData("urn:air:pony:embedding:civitai:700@800", AirEcosystem.Pony, AirAssetType.Embedding)] - [InlineData("urn:air:sdxl:lycoris:civitai:900@1000", AirEcosystem.StableDiffusionXl, AirAssetType.Lycoris)] - [InlineData("urn:air:sdxl:hypernet:civitai:1100@1200", AirEcosystem.StableDiffusionXl, AirAssetType.Hypernetwork)] + [InlineData("urn:air:sd1:lora:civitai:100@200", AirEcosystem.StableDiffusion1, AirAssetType.Lora, AirSource.Civitai)] + [InlineData("urn:air:sd2:vae:civitai:300@400", AirEcosystem.StableDiffusion2, AirAssetType.Vae, AirSource.Civitai)] + [InlineData("urn:air:flux1:checkpoint:civitai:500@600", AirEcosystem.Flux1, AirAssetType.Checkpoint, AirSource.Civitai)] + [InlineData("urn:air:pony:embedding:civitai:700@800", AirEcosystem.Pony, AirAssetType.Embedding, AirSource.Civitai)] + [InlineData("urn:air:sdxl:lycoris:civitai:900@1000", AirEcosystem.StableDiffusionXl, AirAssetType.Lycoris, AirSource.Civitai)] + [InlineData("urn:air:sdxl:hypernet:civitai:1100@1200", AirEcosystem.StableDiffusionXl, AirAssetType.Hypernetwork, AirSource.Civitai)] + [InlineData("urn:air:sdxl:checkpoint:huggingface:100@200", AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, AirSource.HuggingFace)] + [InlineData("urn:air:flux1:lora:openai:300@400", AirEcosystem.Flux1, AirAssetType.Lora, AirSource.OpenAi)] + [InlineData("urn:air:sd1:checkpoint:leonardo:500@600", AirEcosystem.StableDiffusion1, AirAssetType.Checkpoint, AirSource.Leonardo)] public void WhenParsingVariousValidAirStringsThenParsesCorrectly( string airString, AirEcosystem expectedEcosystem, - AirAssetType expectedAssetType) + AirAssetType expectedAssetType, + AirSource expectedSource) { // Act var success = AirIdentifier.TryParse(airString, out var result); @@ -173,6 +141,7 @@ public void WhenParsingVariousValidAirStringsThenParsesCorrectly( Assert.True(success); Assert.Equal(expectedEcosystem, result.Ecosystem); Assert.Equal(expectedAssetType, result.AssetType); + Assert.Equal(expectedSource, result.Source); } [Fact] @@ -225,6 +194,8 @@ public void WhenParsingWhitespaceStringThenReturnsFalse() [InlineData("not-an-air-string")] [InlineData("urn:air:invalid:checkpoint:civitai:4201@130072")] [InlineData("urn:air:sdxl:invalid:civitai:4201@130072")] + [InlineData("urn:air:sdxl:checkpoint:unsupported:4201@130072")] // Invalid source + [InlineData("urn:air:sdxl:checkpoint:github:4201@130072")] // Invalid source [InlineData("urn:air:sdxl:checkpoint:civitai:abc@130072")] [InlineData("urn:air:sdxl:checkpoint:civitai:4201@abc")] [InlineData("urn:air:sdxl:checkpoint:civitai:0@130072")] @@ -386,15 +357,16 @@ public void WhenComparingDifferentVersionIdsThenReturnsFalse() } [Fact] - public void WhenComparingWithSourceCaseDifferenceThenReturnsTrue() + public void WhenComparingDifferentSourcesThenReturnsFalse() { - // Arrange - Source comparison should be case-insensitive - var air1 = new AirIdentifier(AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, "civitai", 4201, 130072); - var air2 = new AirIdentifier(AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, "CIVITAI", 4201, 130072); + // Arrange + var air1 = new AirIdentifier(AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, AirSource.Civitai, 4201, 130072); + var air2 = new AirIdentifier(AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, AirSource.HuggingFace, 4201, 130072); // Act & Assert - Assert.True(air1.Equals(air2)); - Assert.Equal(air1.GetHashCode(), air2.GetHashCode()); + Assert.False(air1.Equals(air2)); + Assert.False(air1 == air2); + Assert.True(air1 != air2); } [Fact] diff --git a/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobControlNetBuilderTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobControlNetBuilderTests.cs new file mode 100644 index 0000000..5f7f679 --- /dev/null +++ b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobControlNetBuilderTests.cs @@ -0,0 +1,151 @@ +namespace CivitaiSharp.Sdk.Tests.Request; + +using CivitaiSharp.Sdk.Enums; +using CivitaiSharp.Sdk.Request; +using Xunit; + +public sealed class ImageJobControlNetBuilderTests +{ + [Fact] + public void Create_ReturnsNewBuilderInstance() + { + var builder = ImageJobControlNetBuilder.Create(); + + Assert.NotNull(builder); + } + + [Fact] + public void WithImageUrl_SetsImageUrlAndClearsImageData() + { + var builder = ImageJobControlNetBuilder.Create() + .WithImageUrl("https://example.com/control.png"); + + var result = builder.Build(); + + Assert.Equal("https://example.com/control.png", result.ImageUrl); + Assert.Null(result.Image); + } + + [Fact] + public void WithImageData_SetsImageDataAndClearsImageUrl() + { + var builder = ImageJobControlNetBuilder.Create() + .WithImageData("data:image/png;base64,iVBORw0KGgo="); + + var result = builder.Build(); + + Assert.Equal("data:image/png;base64,iVBORw0KGgo=", result.Image); + Assert.Null(result.ImageUrl); + } + + [Fact] + public void WithImageUrl_AfterWithImageData_OverwritesImageData() + { + var builder = ImageJobControlNetBuilder.Create() + .WithImageData("data:image/png;base64,iVBORw0KGgo=") + .WithImageUrl("https://example.com/control.png"); + + var result = builder.Build(); + + Assert.Equal("https://example.com/control.png", result.ImageUrl); + Assert.Null(result.Image); + } + + [Fact] + public void WithPreprocessor_SetsPreprocessorValue() + { + var builder = ImageJobControlNetBuilder.Create() + .WithImageUrl("https://example.com/control.png") + .WithPreprocessor(ControlNetPreprocessor.Canny); + + var result = builder.Build(); + + Assert.Equal(ControlNetPreprocessor.Canny, result.Preprocessor); + } + + [Fact] + public void WithWeight_SetsWeightValue() + { + var builder = ImageJobControlNetBuilder.Create() + .WithImageUrl("https://example.com/control.png") + .WithWeight(0.9m); + + var result = builder.Build(); + + Assert.Equal(0.9m, result.Weight); + } + + [Fact] + public void WithStartStep_SetsStartStepValue() + { + var builder = ImageJobControlNetBuilder.Create() + .WithImageUrl("https://example.com/control.png") + .WithStartStep(0.1m); + + var result = builder.Build(); + + Assert.Equal(0.1m, result.StartStep); + } + + [Fact] + public void WithEndStep_SetsEndStepValue() + { + var builder = ImageJobControlNetBuilder.Create() + .WithImageUrl("https://example.com/control.png") + .WithEndStep(0.9m); + + var result = builder.Build(); + + Assert.Equal(0.9m, result.EndStep); + } + + [Fact] + public void WithStepRange_SetsBothStartAndEndStep() + { + var builder = ImageJobControlNetBuilder.Create() + .WithImageUrl("https://example.com/control.png") + .WithStepRange(0.2m, 0.8m); + + var result = builder.Build(); + + Assert.Equal(0.2m, result.StartStep); + Assert.Equal(0.8m, result.EndStep); + } + + [Fact] + public void Build_WithoutImageSource_ThrowsInvalidOperationException() + { + var builder = ImageJobControlNetBuilder.Create(); + + var exception = Assert.Throws(() => builder.Build()); + Assert.Contains("Either ImageUrl or ImageData must be provided", exception.Message); + } + + [Fact] + public void BuilderIsImmutable_ReturnsNewInstanceOnEachMethod() + { + var builder1 = ImageJobControlNetBuilder.Create(); + var builder2 = builder1.WithImageUrl("https://example.com/control.png"); + var builder3 = builder2.WithWeight(0.5m); + + Assert.NotSame(builder1, builder2); + Assert.NotSame(builder2, builder3); + } + + [Fact] + public void CompleteFluentAPI_BuildsCorrectControlNet() + { + var result = ImageJobControlNetBuilder.Create() + .WithImageUrl("https://example.com/control.png") + .WithPreprocessor(ControlNetPreprocessor.Depth) + .WithWeight(0.85m) + .WithStepRange(0.1m, 0.9m) + .Build(); + + Assert.Equal("https://example.com/control.png", result.ImageUrl); + Assert.Equal(ControlNetPreprocessor.Depth, result.Preprocessor); + Assert.Equal(0.85m, result.Weight); + Assert.Equal(0.1m, result.StartStep); + Assert.Equal(0.9m, result.EndStep); + } +} diff --git a/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobNetworkParamsBuilderTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobNetworkParamsBuilderTests.cs new file mode 100644 index 0000000..237dbb0 --- /dev/null +++ b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobNetworkParamsBuilderTests.cs @@ -0,0 +1,99 @@ +namespace CivitaiSharp.Sdk.Tests.Request; + +using CivitaiSharp.Sdk.Enums; +using CivitaiSharp.Sdk.Request; +using Xunit; + +public sealed class ImageJobNetworkParamsBuilderTests +{ + [Fact] + public void Create_ReturnsNewBuilderInstance() + { + var builder = ImageJobNetworkParamsBuilder.Create(); + + Assert.NotNull(builder); + } + + [Fact] + public void WithType_SetsTypeValue() + { + var builder = ImageJobNetworkParamsBuilder.Create() + .WithType(NetworkType.Lora); + + var result = builder.Build(); + + Assert.Equal(NetworkType.Lora, result.Type); + } + + [Fact] + public void WithStrength_SetsStrengthValue() + { + var builder = ImageJobNetworkParamsBuilder.Create() + .WithType(NetworkType.Lora) + .WithStrength(0.8m); + + var result = builder.Build(); + + Assert.Equal(0.8m, result.Strength); + } + + [Fact] + public void WithTriggerWord_SetsTriggerWordValue() + { + var builder = ImageJobNetworkParamsBuilder.Create() + .WithType(NetworkType.Lora) + .WithTriggerWord("anime style"); + + var result = builder.Build(); + + Assert.Equal("anime style", result.TriggerWord); + } + + [Fact] + public void WithClipStrength_SetsClipStrengthValue() + { + var builder = ImageJobNetworkParamsBuilder.Create() + .WithType(NetworkType.Lora) + .WithClipStrength(1.2m); + + var result = builder.Build(); + + Assert.Equal(1.2m, result.ClipStrength); + } + + [Fact] + public void Build_WithoutType_ThrowsInvalidOperationException() + { + var builder = ImageJobNetworkParamsBuilder.Create(); + + var exception = Assert.Throws(() => builder.Build()); + Assert.Contains("Type is required", exception.Message); + } + + [Fact] + public void BuilderIsImmutable_ReturnsNewInstanceOnEachMethod() + { + var builder1 = ImageJobNetworkParamsBuilder.Create(); + var builder2 = builder1.WithType(NetworkType.Lora); + var builder3 = builder2.WithStrength(0.5m); + + Assert.NotSame(builder1, builder2); + Assert.NotSame(builder2, builder3); + } + + [Fact] + public void CompleteFluentAPI_BuildsCorrectNetworkParams() + { + var result = ImageJobNetworkParamsBuilder.Create() + .WithType(NetworkType.Lora) + .WithStrength(0.9m) + .WithTriggerWord("cyberpunk") + .WithClipStrength(1.1m) + .Build(); + + Assert.Equal(NetworkType.Lora, result.Type); + Assert.Equal(0.9m, result.Strength); + Assert.Equal("cyberpunk", result.TriggerWord); + Assert.Equal(1.1m, result.ClipStrength); + } +} diff --git a/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobParamsBuilderTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobParamsBuilderTests.cs new file mode 100644 index 0000000..31ea44f --- /dev/null +++ b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobParamsBuilderTests.cs @@ -0,0 +1,207 @@ +namespace CivitaiSharp.Sdk.Tests.Request; + +using CivitaiSharp.Sdk.Enums; +using CivitaiSharp.Sdk.Request; +using Xunit; + +public sealed class ImageJobParamsBuilderTests +{ + [Fact] + public void Create_ReturnsNewBuilderInstance() + { + var builder = ImageJobParamsBuilder.Create(); + + Assert.NotNull(builder); + } + + [Fact] + public void WithPrompt_SetsPromptValue() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("test prompt"); + + var result = builder.Build(); + + Assert.Equal("test prompt", result.Prompt); + } + + [Fact] + public void WithNegativePrompt_SetsNegativePromptValue() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("positive") + .WithNegativePrompt("negative"); + + var result = builder.Build(); + + Assert.Equal("negative", result.NegativePrompt); + } + + [Fact] + public void WithScheduler_SetsSchedulerValue() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("test") + .WithScheduler(Scheduler.EulerAncestral); + + var result = builder.Build(); + + Assert.Equal(Scheduler.EulerAncestral, result.Scheduler); + } + + [Fact] + public void WithSteps_SetsStepsValue() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("test") + .WithSteps(30); + + var result = builder.Build(); + + Assert.Equal(30, result.Steps); + } + + [Fact] + public void WithCfgScale_SetsCfgScaleValue() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("test") + .WithCfgScale(7.5m); + + var result = builder.Build(); + + Assert.Equal(7.5m, result.CfgScale); + } + + [Fact] + public void WithSize_SetsBothWidthAndHeight() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("test") + .WithSize(1024, 768); + + var result = builder.Build(); + + Assert.Equal(1024, result.Width); + Assert.Equal(768, result.Height); + } + + [Fact] + public void WithWidth_SetsWidthOnly() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("test") + .WithWidth(512); + + var result = builder.Build(); + + Assert.Equal(512, result.Width); + Assert.Null(result.Height); + } + + [Fact] + public void WithHeight_SetsHeightOnly() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("test") + .WithHeight(768); + + var result = builder.Build(); + + Assert.Equal(768, result.Height); + Assert.Null(result.Width); + } + + [Fact] + public void WithSeed_SetsSeedValue() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("test") + .WithSeed(123456789L); + + var result = builder.Build(); + + Assert.Equal(123456789L, result.Seed); + } + + [Fact] + public void WithClipSkip_SetsClipSkipValue() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("test") + .WithClipSkip(2); + + var result = builder.Build(); + + Assert.Equal(2, result.ClipSkip); + } + + [Fact] + public void WithSourceImage_SetsImageUrl() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("test") + .WithSourceImage("https://example.com/image.png"); + + var result = builder.Build(); + + Assert.Equal("https://example.com/image.png", result.Image); + } + + [Fact] + public void WithStrength_SetsStrengthValue() + { + var builder = ImageJobParamsBuilder.Create() + .WithPrompt("test") + .WithStrength(0.7m); + + var result = builder.Build(); + + Assert.Equal(0.7m, result.Strength); + } + + [Fact] + public void Build_WithoutPrompt_ThrowsInvalidOperationException() + { + var builder = ImageJobParamsBuilder.Create(); + + var exception = Assert.Throws(() => builder.Build()); + Assert.Contains("Prompt is required", exception.Message); + } + + [Fact] + public void BuilderIsImmutable_ReturnsNewInstanceOnEachMethod() + { + var builder1 = ImageJobParamsBuilder.Create(); + var builder2 = builder1.WithPrompt("test"); + var builder3 = builder2.WithSteps(30); + + Assert.NotSame(builder1, builder2); + Assert.NotSame(builder2, builder3); + } + + [Fact] + public void CompleteFluentAPI_BuildsCorrectParams() + { + var result = ImageJobParamsBuilder.Create() + .WithPrompt("A beautiful sunset") + .WithNegativePrompt("blurry, low quality") + .WithScheduler(Scheduler.DpmPlusPlus2M) + .WithSteps(25) + .WithCfgScale(7.0m) + .WithSize(1024, 1024) + .WithSeed(42) + .WithClipSkip(2) + .Build(); + + Assert.Equal("A beautiful sunset", result.Prompt); + Assert.Equal("blurry, low quality", result.NegativePrompt); + Assert.Equal(Scheduler.DpmPlusPlus2M, result.Scheduler); + Assert.Equal(25, result.Steps); + Assert.Equal(7.0m, result.CfgScale); + Assert.Equal(1024, result.Width); + Assert.Equal(1024, result.Height); + Assert.Equal(42, result.Seed); + Assert.Equal(2, result.ClipSkip); + } +} diff --git a/Tests/CivitaiSharp.Sdk.Tests/Request/TextToImageJobBuilderTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Request/TextToImageJobBuilderTests.cs new file mode 100644 index 0000000..df462c6 --- /dev/null +++ b/Tests/CivitaiSharp.Sdk.Tests/Request/TextToImageJobBuilderTests.cs @@ -0,0 +1,437 @@ +namespace CivitaiSharp.Sdk.Tests.Request; + +using System.Text.Json; +using CivitaiSharp.Sdk.Air; +using CivitaiSharp.Sdk.Enums; +using CivitaiSharp.Sdk.Models.Jobs; +using CivitaiSharp.Sdk.Request; +using CivitaiSharp.Sdk.Services; +using NSubstitute; +using Xunit; + +public sealed class TextToImageJobBuilderTests : IClassFixture +{ + private readonly IJobsService _mockJobsService; + + public TextToImageJobBuilderTests() + { + _mockJobsService = Substitute.For(); + } + + [Fact] + public void Create_WithJobsService_ReturnsBuilderWithService() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService); + + Assert.NotNull(builder); + Assert.NotNull(builder.JobsService); + } + + [Fact] + public void WithModel_SetsModelValue() + { + var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"); + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(model) + .WithPrompt("test"); + + var result = builder.Build(); + + Assert.Equal(model, result.Model); + } + + [Fact] + public void WithPrompt_SetsPromptInParams() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("A beautiful landscape"); + + var result = builder.Build(); + + Assert.Equal("A beautiful landscape", result.Params.Prompt); + } + + [Fact] + public void WithNegativePrompt_SetsNegativePromptInParams() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("positive") + .WithNegativePrompt("negative"); + + var result = builder.Build(); + + Assert.Equal("negative", result.Params.NegativePrompt); + } + + [Fact] + public void WithSize_SetsSizeInParams() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .WithSize(1024, 768); + + var result = builder.Build(); + + Assert.Equal(1024, result.Params.Width); + Assert.Equal(768, result.Params.Height); + } + + [Fact] + public void WithSteps_SetsStepsInParams() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .WithSteps(30); + + var result = builder.Build(); + + Assert.Equal(30, result.Params.Steps); + } + + [Fact] + public void WithCfgScale_SetsCfgScaleInParams() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .WithCfgScale(7.5m); + + var result = builder.Build(); + + Assert.Equal(7.5m, result.Params.CfgScale); + } + + [Fact] + public void WithSeed_SetsSeedInParams() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .WithSeed(42); + + var result = builder.Build(); + + Assert.Equal(42, result.Params.Seed); + } + + [Fact] + public void WithParams_UsesProvidedBuilder() + { + var paramsBuilder = ImageJobParamsBuilder.Create() + .WithPrompt("custom prompt") + .WithSteps(25); + + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithParams(paramsBuilder); + + var result = builder.Build(); + + Assert.Equal("custom prompt", result.Params.Prompt); + Assert.Equal(25, result.Params.Steps); + } + + [Fact] + public void WithParams_WithConfigureAction_ConfiguresParams() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithParams(p => p + .WithPrompt("configured") + .WithSteps(20) + .WithCfgScale(7.0m)); + + var result = builder.Build(); + + Assert.Equal("configured", result.Params.Prompt); + Assert.Equal(20, result.Params.Steps); + Assert.Equal(7.0m, result.Params.CfgScale); + } + + [Fact] + public void AddAdditionalNetwork_WithParams_AddsNetworkToDictionary() + { + var networkId = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:1234@5678"); + var networkParams = new ImageJobNetworkParams { Type = NetworkType.Lora }; + + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .AddAdditionalNetwork(networkId, networkParams); + + var result = builder.Build(); + + Assert.NotNull(result.AdditionalNetworks); + Assert.Single(result.AdditionalNetworks); + Assert.Equal(NetworkType.Lora, result.AdditionalNetworks[networkId].Type); + } + + [Fact] + public void AddAdditionalNetwork_WithBuilder_AddsNetworkUsingBuilder() + { + var networkId = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:1234@5678"); + var networkBuilder = ImageJobNetworkParamsBuilder.Create() + .WithType(NetworkType.Lora) + .WithStrength(0.8m); + + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .AddAdditionalNetwork(networkId, networkBuilder); + + var result = builder.Build(); + + Assert.NotNull(result.AdditionalNetworks); + Assert.Equal(0.8m, result.AdditionalNetworks[networkId].Strength); + } + + [Fact] + public void AddAdditionalNetwork_WithConfigureAction_AddsNetworkUsingAction() + { + var networkId = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:1234@5678"); + + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .AddAdditionalNetwork(networkId, n => n + .WithType(NetworkType.Lora) + .WithTriggerWord("anime")); + + var result = builder.Build(); + + Assert.NotNull(result.AdditionalNetworks); + Assert.Equal("anime", result.AdditionalNetworks[networkId].TriggerWord); + } + + [Fact] + public void AddControlNet_WithControlNet_AddsToList() + { + var controlNet = new ImageJobControlNet { ImageUrl = "https://example.com/control.png" }; + + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .AddControlNet(controlNet); + + var result = builder.Build(); + + Assert.NotNull(result.ControlNets); + Assert.Single(result.ControlNets); + Assert.Equal("https://example.com/control.png", result.ControlNets[0].ImageUrl); + } + + [Fact] + public void AddControlNet_WithBuilder_AddsUsingBuilder() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .AddControlNet(ImageJobControlNetBuilder.Create() + .WithImageUrl("https://example.com/control.png") + .WithWeight(0.9m)); + + var result = builder.Build(); + + Assert.NotNull(result.ControlNets); + Assert.Equal(0.9m, result.ControlNets[0].Weight); + } + + [Fact] + public void AddControlNet_WithConfigureAction_AddsUsingAction() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .AddControlNet(c => c + .WithImageUrl("https://example.com/control.png") + .WithPreprocessor(ControlNetPreprocessor.Canny)); + + var result = builder.Build(); + + Assert.NotNull(result.ControlNets); + Assert.Equal(ControlNetPreprocessor.Canny, result.ControlNets[0].Preprocessor); + } + + [Fact] + public void WithQuantity_SetsQuantityValue() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .WithQuantity(4); + + var result = builder.Build(); + + Assert.Equal(4, result.Quantity); + } + + [Fact] + public void WithPriority_SetsPriorityValue() + { + var priority = new Priority(5.0m); + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .WithPriority(priority); + + var result = builder.Build(); + + Assert.Equal(priority, result.Priority); + } + + [Fact] + public void AddProperty_AddsPropertyToDictionary() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .AddProperty("key1", JsonDocument.Parse("\"value1\"").RootElement); + + var result = builder.Build(); + + Assert.NotNull(result.Properties); + Assert.Single(result.Properties); + Assert.Equal("value1", result.Properties["key1"].GetString()); + } + + [Fact] + public void WithCallbackUrl_SetsCallbackUrlValue() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .WithCallbackUrl("https://webhook.example.com"); + + var result = builder.Build(); + + Assert.Equal("https://webhook.example.com", result.CallbackUrl); + } + + [Fact] + public void WithRetries_SetsRetriesValue() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .WithRetries(3); + + var result = builder.Build(); + + Assert.Equal(3, result.Retries); + } + + [Fact] + public void WithTimeout_String_SetsTimeoutValue() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .WithTimeout("00:15:00"); + + var result = builder.Build(); + + Assert.Equal("00:15:00", result.Timeout); + } + + [Fact] + public void WithTimeout_TimeSpan_FormatsAndSetsTimeout() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .WithTimeout(TimeSpan.FromMinutes(20)); + + var result = builder.Build(); + + Assert.Equal("00:20:00", result.Timeout); + } + + [Fact] + public void WithClipSkip_SetsClipSkipValue() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + .WithPrompt("test") + .WithClipSkip(2); + + var result = builder.Build(); + + Assert.Equal(2, result.ClipSkip); + } + + [Fact] + public void Build_WithoutModel_ThrowsInvalidOperationException() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithPrompt("test"); + + var exception = Assert.Throws(() => builder.Build()); + Assert.Contains("Model is required", exception.Message); + } + + [Fact] + public void Build_WithoutPrompt_ThrowsInvalidOperationException() + { + var builder = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")); + + var exception = Assert.Throws(() => builder.Build()); + Assert.Contains("Parameters are required", exception.Message); + } + + [Fact] + public void BuilderIsImmutable_ReturnsNewInstanceOnEachMethod() + { + var builder1 = TextToImageJobBuilder.Create(_mockJobsService); + var builder2 = builder1.WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")); + var builder3 = builder2.WithPrompt("test"); + + Assert.NotSame(builder1, builder2); + Assert.NotSame(builder2, builder3); + } + + [Fact] + public void CompleteFluentAPI_BuildsCorrectRequest() + { + var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"); + var networkId = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:1234@5678"); + + var result = TextToImageJobBuilder.Create(_mockJobsService) + .WithModel(model) + .WithPrompt("A beautiful sunset over mountains") + .WithNegativePrompt("blurry, low quality") + .WithSize(1024, 1024) + .WithSteps(30) + .WithCfgScale(7.5m) + .WithSeed(42) + .AddAdditionalNetwork(networkId, n => n + .WithType(NetworkType.Lora) + .WithStrength(0.8m)) + .AddControlNet(c => c + .WithImageUrl("https://example.com/control.png") + .WithWeight(0.9m)) + .WithQuantity(2) + .WithCallbackUrl("https://webhook.example.com") + .Build(); + + Assert.Equal(model, result.Model); + Assert.Equal("A beautiful sunset over mountains", result.Params.Prompt); + Assert.Equal("blurry, low quality", result.Params.NegativePrompt); + Assert.Equal(1024, result.Params.Width); + Assert.Equal(1024, result.Params.Height); + Assert.Equal(30, result.Params.Steps); + Assert.Equal(7.5m, result.Params.CfgScale); + Assert.Equal(42, result.Params.Seed); + Assert.NotNull(result.AdditionalNetworks); + Assert.Single(result.AdditionalNetworks); + Assert.NotNull(result.ControlNets); + Assert.Single(result.ControlNets); + Assert.Equal(2, result.Quantity); + Assert.Equal("https://webhook.example.com", result.CallbackUrl); + } +} + diff --git a/Tests/CivitaiSharp.Sdk.Tests/CivitaiSdkClientOptionsTests.cs b/Tests/CivitaiSharp.Sdk.Tests/SdkClientOptionsTests.cs similarity index 67% rename from Tests/CivitaiSharp.Sdk.Tests/CivitaiSdkClientOptionsTests.cs rename to Tests/CivitaiSharp.Sdk.Tests/SdkClientOptionsTests.cs index dd95233..4fcd533 100644 --- a/Tests/CivitaiSharp.Sdk.Tests/CivitaiSdkClientOptionsTests.cs +++ b/Tests/CivitaiSharp.Sdk.Tests/SdkClientOptionsTests.cs @@ -2,7 +2,7 @@ namespace CivitaiSharp.Sdk.Tests; using Xunit; -public sealed class CivitaiSdkClientOptionsTests +public sealed class SdkClientOptionsTests { #region Default Values Tests @@ -10,12 +10,12 @@ public sealed class CivitaiSdkClientOptionsTests public void WhenCreatingOptionsWithDefaultsThenDefaultValuesAreUsed() { // Arrange & Act - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Assert - Assert.Equal(CivitaiSdkClientOptions.DefaultBaseUrl, options.BaseUrl); - Assert.Equal(CivitaiSdkClientOptions.DefaultApiVersion, options.ApiVersion); - Assert.Equal(CivitaiSdkClientOptions.DefaultTimeoutSeconds, options.TimeoutSeconds); + Assert.Equal(SdkClientOptions.DefaultBaseUrl, options.BaseUrl); + Assert.Equal(SdkClientOptions.DefaultApiVersion, options.ApiVersion); + Assert.Equal(SdkClientOptions.DefaultTimeoutSeconds, options.TimeoutSeconds); Assert.Equal("test-token", options.ApiToken); } @@ -23,10 +23,10 @@ public void WhenCreatingOptionsWithDefaultsThenDefaultValuesAreUsed() public void WhenCheckingDefaultConstantsThenValuesAreCorrect() { // Assert - Assert.Equal("https://orchestration.civitai.com", CivitaiSdkClientOptions.DefaultBaseUrl); - Assert.Equal("v1", CivitaiSdkClientOptions.DefaultApiVersion); - Assert.Equal(600, CivitaiSdkClientOptions.DefaultTimeoutSeconds); - Assert.Equal(1800, CivitaiSdkClientOptions.MaxTimeoutSeconds); + Assert.Equal("https://orchestration.civitai.com", SdkClientOptions.DefaultBaseUrl); + Assert.Equal("v1", SdkClientOptions.DefaultApiVersion); + Assert.Equal(600, SdkClientOptions.DefaultTimeoutSeconds); + Assert.Equal(1800, SdkClientOptions.MaxTimeoutSeconds); } #endregion @@ -37,7 +37,7 @@ public void WhenCheckingDefaultConstantsThenValuesAreCorrect() public void WhenSettingValidApiTokenThenTokenIsStored() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "initial-token" }; + var options = new SdkClientOptions { ApiToken = "initial-token" }; // Act options.ApiToken = "new-valid-token"; @@ -50,7 +50,7 @@ public void WhenSettingValidApiTokenThenTokenIsStored() public void WhenSettingNullApiTokenThenThrowsArgumentNullException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "initial-token" }; + var options = new SdkClientOptions { ApiToken = "initial-token" }; // Act & Assert Assert.Throws(() => options.ApiToken = null!); @@ -60,7 +60,7 @@ public void WhenSettingNullApiTokenThenThrowsArgumentNullException() public void WhenSettingEmptyApiTokenThenThrowsArgumentException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "initial-token" }; + var options = new SdkClientOptions { ApiToken = "initial-token" }; // Act & Assert Assert.Throws(() => options.ApiToken = ""); @@ -70,7 +70,7 @@ public void WhenSettingEmptyApiTokenThenThrowsArgumentException() public void WhenSettingWhitespaceApiTokenThenThrowsArgumentException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "initial-token" }; + var options = new SdkClientOptions { ApiToken = "initial-token" }; // Act & Assert Assert.Throws(() => options.ApiToken = " "); @@ -84,7 +84,7 @@ public void WhenSettingWhitespaceApiTokenThenThrowsArgumentException() public void WhenSettingValidBaseUrlThenUrlIsStored() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; const string expectedUrl = "https://api.example.com"; // Act @@ -98,7 +98,7 @@ public void WhenSettingValidBaseUrlThenUrlIsStored() public void WhenSettingBaseUrlWithTrailingSlashThenSlashIsRemoved() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act options.BaseUrl = "https://api.example.com/"; @@ -111,7 +111,7 @@ public void WhenSettingBaseUrlWithTrailingSlashThenSlashIsRemoved() public void WhenSettingNullBaseUrlThenThrowsArgumentNullException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act & Assert Assert.Throws(() => options.BaseUrl = null!); @@ -121,7 +121,7 @@ public void WhenSettingNullBaseUrlThenThrowsArgumentNullException() public void WhenSettingEmptyBaseUrlThenThrowsArgumentException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act & Assert Assert.Throws(() => options.BaseUrl = ""); @@ -131,7 +131,7 @@ public void WhenSettingEmptyBaseUrlThenThrowsArgumentException() public void WhenSettingWhitespaceBaseUrlThenThrowsArgumentException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act & Assert Assert.Throws(() => options.BaseUrl = " "); @@ -141,7 +141,7 @@ public void WhenSettingWhitespaceBaseUrlThenThrowsArgumentException() public void WhenSettingInvalidUrlThenThrowsArgumentException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act & Assert Assert.Throws(() => options.BaseUrl = "not-a-valid-url"); @@ -151,7 +151,7 @@ public void WhenSettingInvalidUrlThenThrowsArgumentException() public void WhenSettingNonHttpUrlThenThrowsArgumentException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act & Assert Assert.Throws(() => options.BaseUrl = "ftp://example.com"); @@ -161,7 +161,7 @@ public void WhenSettingNonHttpUrlThenThrowsArgumentException() public void WhenSettingHttpUrlThenUrlIsAccepted() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act options.BaseUrl = "http://localhost:8080"; @@ -178,7 +178,7 @@ public void WhenSettingHttpUrlThenUrlIsAccepted() public void WhenSettingValidApiVersionThenVersionIsStored() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act options.ApiVersion = "v2"; @@ -191,7 +191,7 @@ public void WhenSettingValidApiVersionThenVersionIsStored() public void WhenSettingNullApiVersionThenThrowsArgumentNullException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act & Assert Assert.Throws(() => options.ApiVersion = null!); @@ -201,7 +201,7 @@ public void WhenSettingNullApiVersionThenThrowsArgumentNullException() public void WhenSettingEmptyApiVersionThenThrowsArgumentException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act & Assert Assert.Throws(() => options.ApiVersion = ""); @@ -211,7 +211,7 @@ public void WhenSettingEmptyApiVersionThenThrowsArgumentException() public void WhenSettingWhitespaceApiVersionThenThrowsArgumentException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act & Assert Assert.Throws(() => options.ApiVersion = " "); @@ -225,7 +225,7 @@ public void WhenSettingWhitespaceApiVersionThenThrowsArgumentException() public void WhenSettingValidTimeoutThenTimeoutIsStored() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act options.TimeoutSeconds = 300; @@ -238,7 +238,7 @@ public void WhenSettingValidTimeoutThenTimeoutIsStored() public void WhenSettingMinimumTimeoutThenTimeoutIsAccepted() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act options.TimeoutSeconds = 1; @@ -251,20 +251,20 @@ public void WhenSettingMinimumTimeoutThenTimeoutIsAccepted() public void WhenSettingMaximumTimeoutThenTimeoutIsAccepted() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act - options.TimeoutSeconds = CivitaiSdkClientOptions.MaxTimeoutSeconds; + options.TimeoutSeconds = SdkClientOptions.MaxTimeoutSeconds; // Assert - Assert.Equal(CivitaiSdkClientOptions.MaxTimeoutSeconds, options.TimeoutSeconds); + Assert.Equal(SdkClientOptions.MaxTimeoutSeconds, options.TimeoutSeconds); } [Fact] public void WhenSettingZeroTimeoutThenThrowsArgumentOutOfRangeException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act & Assert Assert.Throws(() => options.TimeoutSeconds = 0); @@ -274,7 +274,7 @@ public void WhenSettingZeroTimeoutThenThrowsArgumentOutOfRangeException() public void WhenSettingNegativeTimeoutThenThrowsArgumentOutOfRangeException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act & Assert Assert.Throws(() => options.TimeoutSeconds = -1); @@ -284,10 +284,10 @@ public void WhenSettingNegativeTimeoutThenThrowsArgumentOutOfRangeException() public void WhenSettingTimeoutAboveMaximumThenThrowsArgumentOutOfRangeException() { // Arrange - var options = new CivitaiSdkClientOptions { ApiToken = "test-token" }; + var options = new SdkClientOptions { ApiToken = "test-token" }; // Act & Assert - Assert.Throws(() => options.TimeoutSeconds = CivitaiSdkClientOptions.MaxTimeoutSeconds + 1); + Assert.Throws(() => options.TimeoutSeconds = SdkClientOptions.MaxTimeoutSeconds + 1); } #endregion diff --git a/Tests/CivitaiSharp.Tools.Tests/ToolsTestFixture.cs b/Tests/CivitaiSharp.Tools.Tests/ToolsTestFixture.cs new file mode 100644 index 0000000..2f5cb22 --- /dev/null +++ b/Tests/CivitaiSharp.Tools.Tests/ToolsTestFixture.cs @@ -0,0 +1,20 @@ +namespace CivitaiSharp.Tools.Tests; + +/// +/// Shared test fixture for Tools tests. +/// +/// +/// Tools no longer depends on SDK types. The AirBuilder has been moved to the SDK project. +/// This fixture remains available for any shared test initialization needs. +/// +public sealed class ToolsTestFixture +{ + /// + /// Initializes the Tools test fixture. + /// + public ToolsTestFixture() + { + // Tools tests no longer require SDK initialization + // since AirBuilder has been moved to the SDK project + } +} diff --git a/Tools/CivitaiSharp.Tools.csproj b/Tools/CivitaiSharp.Tools.csproj index aaad525..b82dbb5 100644 --- a/Tools/CivitaiSharp.Tools.csproj +++ b/Tools/CivitaiSharp.Tools.csproj @@ -8,6 +8,7 @@ true true + true true