Files
h-school/tests/HSchool.AppHost.Tests/PortraitApiTests.cs
T
Leonid PershinandCursor ed015b9d6b Add Krea portrait models and write appearance as prose.
New schools default to Realism v2.5; living saves keep their catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:45:40 +03:00

221 lines
9.8 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)]
public class PortraitApiTests(AppHostFixture fixture)
{
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task GetPortrait_WithoutFile_Returns404()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Портреты", Start);
var personId = await FirstPersonIdAsync(client, school.Id);
using var response = await client.GetAsync(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait?kind=avatar",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("portrait-missing", await SchoolApiTests.ProblemCodeAsync(response));
}
[Fact]
public async Task GetPortrait_WithFile_ReturnsPngAndCardFlags()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Портрет на диске", Start);
var personId = await FirstPersonIdAsync(client, school.Id);
var directory = await SavesDirectoryAsync(client);
var portraits = Path.Combine(directory, $"{school.Id}.portraits");
Directory.CreateDirectory(portraits);
var png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==");
await File.WriteAllBytesAsync(Path.Combine(portraits, $"{personId}.avatar.png"), png, TestContext.Current.CancellationToken);
using var image = await client.GetAsync(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait?kind=avatar",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, image.StatusCode);
Assert.Equal("image/png", image.Content.Headers.ContentType?.MediaType);
var card = await client.GetFromJsonAsync<PersonCardResponse>(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(card);
Assert.True(card!.HasAvatar);
Assert.False(card.HasFullBody);
}
[Fact]
public async Task PostPortrait_WhenSwarmNotConfigured_Returns503()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Swarm выкл", Start);
var personId = await FirstPersonIdAsync(client, school.Id);
using var response = await client.PostAsync(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait?kind=avatar",
null,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Equal("swarmui-not-configured", await SchoolApiTests.ProblemCodeAsync(response));
}
[Fact]
public async Task GetPortraitPrompt_ReturnsBuiltPositiveAndNegative()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Промпт", Start);
var personId = await FirstPersonIdAsync(client, school.Id);
using var response = await client.GetAsync(
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(personId)}/portrait/prompt?kind=avatar",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<PortraitPromptPayload>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Equal("avatar", payload!.Kind);
Assert.Contains("years old", payload.Positive, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("age ", payload.Positive, StringComparison.OrdinalIgnoreCase);
Assert.False(string.IsNullOrWhiteSpace(payload.Negative));
Assert.False(string.IsNullOrWhiteSpace(payload.PresetId));
}
[Fact]
public async Task GetSwarmUiSettings_ReturnsPresets()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.LoginAsync(client);
var settings = await client.GetFromJsonAsync<SwarmUiSettingsPayload>(
"/api/settings/swarmui",
TestContext.Current.CancellationToken);
Assert.NotNull(settings);
Assert.NotEmpty(settings!.Presets);
Assert.NotEmpty(settings.Models);
Assert.Contains(settings.Models, model => model.Id == "museByStableYogi_v30TurboGguf.gguf");
Assert.Contains(settings.Models, model => model.Id == "realismByStableYogi_v25INT8Turbo.safetensors");
Assert.False(string.IsNullOrWhiteSpace(settings.ActivePresetId));
}
[Fact]
public async Task CreateSchool_CopiesPortraitSettingsIntoTheSave()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Пресеты школы", Start);
var directory = await SavesDirectoryAsync(client);
var json = await File.ReadAllTextAsync(
Path.Combine(directory, $"{school.Id}.json"),
TestContext.Current.CancellationToken);
using var document = JsonDocument.Parse(json);
Assert.True(document.RootElement.TryGetProperty("portraitSettings", out var presets));
Assert.True(presets.TryGetProperty("presets", out var list));
Assert.True(list.GetArrayLength() > 0);
Assert.True(presets.TryGetProperty("models", out var models));
Assert.True(models.GetArrayLength() > 0);
var ids = models.EnumerateArray().Select(model => model.GetProperty("id").GetString()).ToList();
Assert.Contains("museByStableYogi_v30TurboGguf.gguf", ids);
Assert.Contains("realismByStableYogi_v25INT8Turbo.safetensors", ids);
var defaultPreset = presets.GetProperty("presets").EnumerateArray()
.First(preset => preset.GetProperty("id").GetString() == "default");
Assert.Equal(
"realismByStableYogi_v25INT8Turbo.safetensors",
defaultPreset.GetProperty("model").GetString());
}
[Fact]
public async Task CreateSchool_RejectsEmptyPortraitPresets()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Плохие пресеты",
startDate = Start,
portraitSettings = new { activePresetId = "missing", presets = Array.Empty<object>(), ageRules = Array.Empty<object>() },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("invalid-portrait-settings", await SchoolApiTests.ProblemCodeAsync(response));
}
[Fact]
public async Task DeleteSchool_RemovesPortraitDirectory()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Удалить портреты", Start);
var personId = await FirstPersonIdAsync(client, school.Id);
var directory = await SavesDirectoryAsync(client);
var portraits = Path.Combine(directory, $"{school.Id}.portraits");
Directory.CreateDirectory(portraits);
await File.WriteAllBytesAsync(Path.Combine(portraits, $"{personId}.avatar.png"), [1, 2, 3], TestContext.Current.CancellationToken);
using var deleted = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
Assert.False(Directory.Exists(portraits));
}
private static async Task<string> FirstPersonIdAsync(HttpClient client, int schoolId)
{
var page = await client.GetFromJsonAsync<PeopleListResponse>(
$"/api/schools/{schoolId}/people?pageSize=1",
TestContext.Current.CancellationToken);
Assert.NotNull(page);
Assert.NotEmpty(page!.People);
return page.People[0].Id;
}
private static async Task<string> SavesDirectoryAsync(HttpClient client)
{
var payload = await client.GetFromJsonAsync<SavesDirectoryResponse>(
"/api/dev/saves-directory",
TestContext.Current.CancellationToken);
return payload!.Path;
}
private sealed record PeopleListResponse(int Total, IReadOnlyList<PersonListItem> People);
private sealed record PersonListItem(string Id);
private sealed record PersonCardResponse(
string Id,
bool HasAvatar,
bool HasCustom,
bool HasFullBody,
string? CustomPortraitPrompt);
private sealed record PortraitPromptPayload(
string Kind,
string Positive,
string Negative,
string? PromptExtra,
string PresetId,
string PresetLabel);
private sealed record SwarmUiSettingsPayload(
string ActivePresetId,
IReadOnlyList<SwarmUiPresetPayload> Presets,
IReadOnlyList<SwarmUiModelPayload> Models);
private sealed record SwarmUiPresetPayload(string Id, string Label);
private sealed record SwarmUiModelPayload(string Id);
private sealed record SavesDirectoryResponse(string Path);
}