Implement portrait generation and retrieval for people in the school system. Added new API endpoints for generating and fetching portraits, including support for avatar and full-body images. Updated the client-side to handle portrait states and display options. Enhanced the person card UI to include tabs for overview and portrait, with appropriate localization strings. Updated solution files to include new test projects. Adjusted server configuration for SwarmUI integration.
ci / server (push) Failing after 3m46s
ci / client (push) Failing after 14s

This commit is contained in:
Leonid Pershin
2026-08-20 04:14:41 +03:00
parent 871178909e
commit dfa9a25dca
32 changed files with 1538 additions and 121 deletions
@@ -0,0 +1,113 @@
using System.Net;
using System.Net.Http.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 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 HasFullBody);
private sealed record SavesDirectoryResponse(string Path);
}
@@ -617,7 +617,7 @@ public class SchoolApiTests(AppHostFixture fixture)
seed is null ? (object)new { name, startDate } : new { name, startDate, seed },
TestContext.Current.CancellationToken);
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
internal static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
return problem?.Code;
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Server.Tests</RootNamespace>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HSchool.Server\HSchool.Server.csproj" />
<ProjectReference Include="..\..\src\HSchool.People\HSchool.People.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,72 @@
using HSchool.Server.Api;
using HSchool.Server.Game;
namespace HSchool.Server.Tests;
public class PortraitPromptBuilderTests
{
[Fact]
public void Build_IncludesAgeHairAndWornClothing()
{
var settings = new SwarmUiSettings
{
Positive = "School photo.",
Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
};
var card = SampleCard();
var (positive, _) = PortraitPromptBuilder.Build(card, settings, PortraitKind.Avatar);
Assert.Contains("age 12", positive, StringComparison.OrdinalIgnoreCase);
Assert.Contains("black", positive, StringComparison.OrdinalIgnoreCase);
Assert.Contains("shirt", positive, StringComparison.OrdinalIgnoreCase);
Assert.Contains("white", positive, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Head and shoulders.", positive, StringComparison.Ordinal);
}
[Fact]
public void Build_FullBody_AddsFullBodyPreset()
{
var settings = new SwarmUiSettings
{
Positive = "School photo.",
Avatar = new SwarmUiSettings.SwarmUiPreset { Positive = "Head and shoulders." },
FullBody = new SwarmUiSettings.SwarmUiPreset { Positive = "Standing full body." },
};
var (avatarPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Avatar);
var (fullPositive, _) = PortraitPromptBuilder.Build(SampleCard(), settings, PortraitKind.Full);
Assert.Contains("Head and shoulders.", avatarPositive, StringComparison.Ordinal);
Assert.DoesNotContain("Standing full body.", avatarPositive, StringComparison.Ordinal);
Assert.Contains("Standing full body.", fullPositive, StringComparison.Ordinal);
}
private static PersonCardResponse SampleCard() =>
new(
"f0.c0",
"Maria Ivanova",
"Ivanova",
"Maria",
"",
true,
12,
new DateTime(2000, 3, 14, 0, 0, 0, DateTimeKind.Utc),
["student"],
5,
"A",
"class-1",
null,
null,
[new LabeledStatResponse("HairColor", "Hair colour", "Black")],
[],
[],
[],
null,
null,
new PersonFamilyResponse([], [], [], []),
[new WornItemResponse("Shirt", "Shirt", "White", "White", [], 1f, "new")],
[],
0f,
0f);
}
@@ -0,0 +1,66 @@
using System.Net;
using System.Text;
using System.Text.Json;
using HSchool.Server.Game;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Tests;
public class SwarmUiClientTests
{
[Fact]
public async Task GenerateAsync_UsesSessionAndReturnsImageBytes()
{
var handler = new FakeHandler();
var http = new HttpClient(handler) { BaseAddress = new Uri("http://swarm.test/") };
var client = new SwarmUiClient(
http,
Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }),
NullLogger<SwarmUiClient>.Instance);
var settings = new SwarmUiSettings
{
Model = "model.safetensors",
Steps = 8,
CfgScale = 1,
Avatar = new SwarmUiSettings.SwarmUiPreset { Width = 512, Height = 512 },
};
var bytes = await client.GenerateAsync("a student", "bad", settings, PortraitKind.Avatar, CancellationToken.None);
Assert.Equal([0x89, 0x50, 0x4E, 0x47], bytes.Take(4));
Assert.Contains("/API/GetNewSession", handler.Requests[0]);
Assert.Contains("/API/GenerateText2Image", handler.Requests[1]);
}
private sealed class FakeHandler : HttpMessageHandler
{
public List<string> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request.RequestUri!.AbsolutePath);
if (request.RequestUri.AbsolutePath.Contains("GetNewSession", StringComparison.Ordinal))
{
var session = JsonSerializer.Serialize(new { session_id = "sess-1" });
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(session, Encoding.UTF8, "application/json"),
});
}
if (request.RequestUri.AbsolutePath.Contains("GenerateText2Image", StringComparison.Ordinal))
{
var payload = JsonSerializer.Serialize(new { images = new[] { "data:image/png;base64,iVBORw0KGgo=" } });
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
});
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
}