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
+25 -1
View File
@@ -262,6 +262,10 @@ no Chemistry; a related tongue from the name set may sit beside the native at a
overload does not slow walking. Home and locker stay in `people.json` and are not on this card.
The people list does not include any of these fields.
`hasAvatar` and `hasFullBody` tell the client whether PNG files already exist on disk for this
person. They are filled on the HTTP thread after the worker returns the card; generation does
not happen on this request.
```json
{
"id": "f0.c0",
@@ -308,10 +312,30 @@ The people list does not include any of these fields.
}
],
"carryMass": 1.2,
"carryCapacity": 14
"carryCapacity": 14,
"hasAvatar": false,
"hasFullBody": false
}
```
### `GET /api/schools/{id}/people/{personId}/portrait`
Returns a generated PNG when one exists. Query `kind=avatar|full` selects head-and-shoulders or
full-body. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`;
missing file is `404` `portrait-missing`. Invalid `kind` is `400` `invalid-query`. Content-Type
is `image/png`. Opening the card does not generate; use POST when the player asks.
### `POST /api/schools/{id}/people/{personId}/portrait`
Generates (or regenerates) a portrait through SwarmUI on the server. Same `kind` query as GET.
Success is `201` with `{ "kind", "hasAvatar", "hasFullBody" }` and a `Location` header pointing
at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swarmui-not-configured`.
Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files
land under `saves/{id}.portraits/` and survive until the school is deleted.
`GET /api/status` includes `swarmUiConfigured` so the client can disable generate buttons without
trying POST first.
### `GET /api/schools/{id}/staffing`
Money, uncovered subjects, the applicant pool and current staff. Reads the **published**
+15
View File
@@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Ai", "src\HSchool.A
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Ai.Tests", "tests\HSchool.Ai.Tests\HSchool.Ai.Tests.csproj", "{C355307C-D7D3-48C7-9615-CB5FBCF83D49}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Server.Tests", "tests\HSchool.Server.Tests\HSchool.Server.Tests.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -241,6 +243,18 @@ Global
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x64.Build.0 = Release|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x86.ActiveCfg = Release|Any CPU
{C355307C-D7D3-48C7-9615-CB5FBCF83D49}.Release|x86.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -262,6 +276,7 @@ Global
{85FF85BD-F572-4F0A-A11E-FA855D480C1D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{C21FB23F-F131-4651-8236-4F3E076B40BF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C355307C-D7D3-48C7-9615-CB5FBCF83D49} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
+2 -1
View File
@@ -15,7 +15,8 @@ if (headless)
var saves = Path.Combine(Path.GetTempPath(), "h-school-tests", Guid.NewGuid().ToString("N"));
server
.WithEnvironment("Simulation__SavesDirectory", saves)
.WithEnvironment("HSchool__AllowSaveReload", "true");
.WithEnvironment("HSchool__AllowSaveReload", "true")
.WithEnvironment("SwarmUi__BaseUrl", "");
}
if (!headless)
+22
View File
@@ -137,6 +137,17 @@ const ru = {
peopleSiblings: 'Братья и сёстры',
peoplePartners: 'Супруг(а)',
peopleTabOverview: 'Обзор',
peopleTabPortrait: 'Портрет',
peoplePortraitAvatar: 'Аватар',
peoplePortraitFull: 'В полный рост',
peoplePortraitGenerateAvatar: 'Сгенерировать аватар',
peoplePortraitGenerateFull: 'Сгенерировать в полный рост',
peoplePortraitGenerating: 'Генерация…',
peoplePortraitMissing: 'Ещё не сгенерировано.',
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
peoplePortraitFailed: 'Не удалось сгенерировать портрет.',
modeOverview: 'Обзор',
modeManage: 'Управление',
staffAllocated: 'Выделено',
@@ -357,6 +368,17 @@ const en: Messages = {
peopleSiblings: 'Siblings',
peoplePartners: 'Spouse',
peopleTabOverview: 'Overview',
peopleTabPortrait: 'Portrait',
peoplePortraitAvatar: 'Avatar',
peoplePortraitFull: 'Full body',
peoplePortraitGenerateAvatar: 'Generate avatar',
peoplePortraitGenerateFull: 'Generate full body',
peoplePortraitGenerating: 'Generating…',
peoplePortraitMissing: 'Not generated yet.',
peoplePortraitUnavailable: 'SwarmUI is not configured on the server.',
peoplePortraitFailed: 'Could not generate the portrait.',
modeOverview: 'Overview',
modeManage: 'Management',
staffAllocated: 'Allocated',
+38
View File
@@ -295,6 +295,8 @@ export interface PersonCard {
readonly carried: readonly CarriedItem[];
readonly carryMass: number;
readonly carryCapacity: number;
readonly hasAvatar: boolean;
readonly hasFullBody: boolean;
}
export interface WornItem {
@@ -337,6 +339,42 @@ export async function fetchPerson(schoolId: number, personId: string, lang: stri
);
}
export interface GameStatus {
readonly tick: number;
readonly tickRate: number;
readonly schools: number;
readonly maxSchools: number;
readonly connections: number;
readonly swarmUiConfigured: boolean;
}
export async function fetchGameStatus(): Promise<GameStatus> {
return request<GameStatus>('/api/status');
}
export interface PortraitResult {
readonly kind: 'avatar' | 'full';
readonly hasAvatar: boolean;
readonly hasFullBody: boolean;
}
export function portraitUrl(schoolId: number, personId: string, kind: 'avatar' | 'full'): string {
const params = new URLSearchParams({ kind });
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
}
export async function generatePortrait(
schoolId: number,
personId: string,
kind: 'avatar' | 'full',
): Promise<PortraitResult> {
const params = new URLSearchParams({ kind });
return request<PortraitResult>(
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`,
{ method: 'POST' },
);
}
export interface DirectoryPerson {
readonly id: string;
readonly fullName: string;
+56
View File
@@ -581,6 +581,62 @@ body {
font-size: 13px;
}
.people__tabs {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.people__tab-panel {
min-width: 0;
}
.people__card-header {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 8px;
}
.people__card-titles {
min-width: 0;
flex: 1;
}
.people__avatar {
width: 72px;
height: 72px;
flex-shrink: 0;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-raised);
}
.people__portrait {
display: block;
max-width: 100%;
margin: 0 0 8px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-raised);
}
.people__portrait--avatar {
width: 256px;
height: 256px;
object-fit: cover;
}
.people__portrait--full {
width: min(100%, 384px);
height: auto;
}
.people__portrait-panel .button {
margin-bottom: 16px;
}
.people__card-name {
margin: 0 0 2px;
font-size: 15px;
+18 -8
View File
@@ -9,7 +9,8 @@ import { getLocale } from '../i18n/locale.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { clear, el } from './dom.ts';
import { Modal } from './modal.ts';
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
import { formatPersonPlace } from './personCard.ts';
import { PersonCardHost } from './personCardHost.ts';
import {
actionError,
fillSelect,
@@ -68,11 +69,13 @@ export class ApplicantsDialog {
private dir: 'asc' | 'desc' = 'asc';
private cardToken = 0;
private busy = false;
private readonly cardHost = new PersonCardHost();
private painted: PersonCard | null = null;
constructor(private readonly options: ApplicantsDialogOptions) {
this.staffing = options.staffing;
this.selectedId = options.selectedId ?? null;
this.cardHost.attach(options.schoolId);
this.error.hidden = true;
this.ageMinInput.min = '0';
this.ageMaxInput.min = '0';
@@ -151,7 +154,7 @@ export class ApplicantsDialog {
this.paintList();
if (this.selectedId !== null) {
this.appendHireIfNeeded();
void this.openCard(this.selectedId);
}
}
@@ -310,19 +313,26 @@ export class ApplicantsDialog {
}
private paintCard(card: PersonCard | null): void {
clear(this.card);
this.painted = card;
if (card === null) {
clear(this.card);
this.card.append(el('p', { class: 'panel__empty', text: t('staffApplicantsPickHint') }));
return;
}
renderPersonCard(this.card, card, (id) => this.openRelative(id), this.placeOf(card.id));
this.appendHireIfNeeded();
this.cardHost.paint(
this.card,
card,
(id) => this.openRelative(id),
this.placeOf(card.id),
);
this.appendHireIfNeeded(this.card);
}
private appendHireIfNeeded(): void {
const applicant = this.staffing.applicants.find((row) => row.id === this.painted?.id);
private appendHireIfNeeded(parent: HTMLElement = this.card): void {
const personId = this.selectedId ?? this.painted?.id;
const applicant =
personId === null ? undefined : this.staffing.applicants.find((row) => row.id === personId);
if (applicant === undefined) {
return;
}
@@ -349,7 +359,7 @@ export class ApplicantsDialog {
onClick: () => void this.hire(applicant.id),
}),
);
this.card.append(actions);
parent.append(actions);
}
private openRelative(personId: string): void {
+12
View File
@@ -8,6 +8,9 @@ interface ElementOptions {
title?: string;
type?: string;
disabled?: boolean;
hidden?: boolean;
src?: string;
alt?: string;
dataset?: Record<string, string>;
onClick?: (event: Event) => void;
}
@@ -24,6 +27,15 @@ export function el<K extends keyof HTMLElementTagNameMap>(
if (options.title !== undefined) element.title = options.title;
if (options.type !== undefined) element.setAttribute('type', options.type);
if (options.disabled !== undefined) element.toggleAttribute('disabled', options.disabled);
if (options.hidden !== undefined) element.toggleAttribute('hidden', options.hidden);
if (options.src !== undefined && 'src' in element) {
(element as HTMLImageElement).src = options.src;
}
if (options.alt !== undefined && 'alt' in element) {
(element as HTMLImageElement).alt = options.alt;
}
if (options.onClick !== undefined) element.addEventListener('click', options.onClick);
for (const [key, value] of Object.entries(options.dataset ?? {})) {
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiError,
fetchPerson,
fetchGameStatus,
fetchStaffing,
fetchTimetable,
hireStaff,
@@ -24,6 +25,14 @@ vi.mock('../net/api.ts', async (importOriginal) => {
fetchStaffing: vi.fn(),
fetchTimetable: vi.fn(),
fetchPerson: vi.fn(),
fetchGameStatus: vi.fn().mockResolvedValue({
tick: 0,
tickRate: 20,
schools: 1,
maxSchools: 6,
connections: 0,
swarmUiConfigured: false,
}),
hireStaff: vi.fn(),
assignSubject: vi.fn(),
unassignSubject: vi.fn(),
@@ -59,6 +68,8 @@ function personCard(): PersonCard {
carried: [],
carryMass: 0,
carryCapacity: 0,
hasAvatar: false,
hasFullBody: false,
};
}
@@ -107,7 +118,17 @@ describe('ManagementPanel payroll cap', () => {
vi.mocked(hireStaff).mockReset();
vi.mocked(fetchStaffing).mockResolvedValue(staffing());
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
vi.mocked(fetchPerson).mockResolvedValue(personCard());
vi.mocked(fetchPerson).mockImplementation((_schoolId, personId) =>
Promise.resolve({ ...personCard(), id: personId }),
);
vi.mocked(fetchGameStatus).mockResolvedValue({
tick: 0,
tickRate: 20,
schools: 1,
maxSchools: 6,
connections: 0,
swarmUiConfigured: false,
});
vi.mocked(hireStaff).mockRejectedValue(
new ApiError(409, 'payroll-exceeded', 'no', 10_000, 9_000, 1_000, 12_000),
);
@@ -147,11 +168,23 @@ describe('ManagementPanel payroll cap', () => {
row.click();
await vi.waitFor(() => expect(fetchPerson).toHaveBeenCalled());
const hire = [...dialog.querySelectorAll('button')].find((button) => button.textContent === t('staffHire'));
if (!(hire instanceof HTMLButtonElement)) {
await vi.waitFor(() =>
expect(dialog.querySelector('.people__card-name')?.textContent).toContain('Sidorova'),
);
const hire = await vi.waitFor(() => {
const actions = dialog.querySelector('.staffing__actions');
if (actions === null) {
throw new Error('staffing actions are missing');
}
const button = actions.querySelector('button');
if (!(button instanceof HTMLButtonElement)) {
throw new Error('hire button is missing');
}
return button;
});
hire.click();
const banner = dialog.querySelector('.staffing__error');
if (!(banner instanceof HTMLElement)) {
+26 -25
View File
@@ -13,7 +13,8 @@ import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { ApplicantsDialog } from './applicantsDialog.ts';
import { clear, el } from './dom.ts';
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
import { formatPersonPlace } from './personCard.ts';
import { PersonCardHost } from './personCardHost.ts';
import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts';
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
@@ -64,6 +65,7 @@ export class ManagementPanel {
private locate: ((id: string) => string) | null = null;
private painted: PersonCard | null = null;
private poolDialog: ApplicantsDialog | null = null;
private readonly cardHost = new PersonCardHost();
constructor() {
this.error.hidden = true;
@@ -136,12 +138,13 @@ export class ManagementPanel {
}
this.timetableGrid.attach(schoolId);
this.cardHost.attach(schoolId);
void this.reload();
}
setLocate(locate: (id: string) => string): void {
this.locate = locate;
this.relocate();
this.cardHost.relocate((id) => this.placeOf(id));
}
private async reload(): Promise<void> {
@@ -399,39 +402,33 @@ export class ManagementPanel {
}
private paintCard(card: PersonCard | null): void {
clear(this.card);
this.painted = card;
if (card === null) {
clear(this.card);
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
return;
}
renderPersonCard(this.card, card, (id) => void this.openRelative(id), this.placeOf(card.id));
this.mountPersonTimetable(card);
this.appendActions(card.id);
this.cardHost.paint(
this.card,
card,
(id) => void this.openRelative(id),
this.placeOf(card.id),
(overview, painted) => {
this.mountPersonTimetable(overview, painted);
this.appendActions(overview, painted.id);
},
);
}
private relocate(): void {
const line = this.card.querySelector('.people__card-place');
if (!(line instanceof HTMLElement) || this.painted === null) {
return;
}
line.textContent = this.placeOf(this.painted.id);
}
private placeOf(id: string): string {
return this.locate?.(id) ?? formatPersonPlace('away');
}
private mountPersonTimetable(card: PersonCard): void {
private mountPersonTimetable(overview: HTMLElement, card: PersonCard): void {
const schoolId = this.schoolId;
const query = personTimetableQuery(card);
if (schoolId === null || query === null) {
return;
}
this.card.append(this.personTimetableTitle, this.personGrid.element);
overview.append(this.personTimetableTitle, this.personGrid.element);
this.personGrid.attach(schoolId);
const token = this.cardToken;
void fetchTimetable(schoolId, getLocale(), query)
@@ -451,6 +448,10 @@ export class ManagementPanel {
});
}
private placeOf(id: string): string {
return this.locate?.(id) ?? formatPersonPlace('away');
}
private async openRelative(personId: string): Promise<void> {
const staffing = this.staffing;
if (staffing !== null && staffing.staff.some((row) => row.id === personId)) {
@@ -466,7 +467,7 @@ export class ManagementPanel {
await this.select(personId);
}
private appendActions(personId: string): void {
private appendActions(parent: HTMLElement, personId: string): void {
const staffing = this.staffing;
if (staffing === null) {
return;
@@ -474,11 +475,11 @@ export class ManagementPanel {
const member = staffing.staff.find((row) => row.id === personId);
if (member !== undefined && member.position === TEACHER) {
this.appendSubjects(member);
this.appendSubjects(parent, member);
}
}
private appendSubjects(member: StaffMember): void {
private appendSubjects(parent: HTMLElement, member: StaffMember): void {
const staffing = this.staffing;
if (staffing === null) {
return;
@@ -530,7 +531,7 @@ export class ManagementPanel {
);
}
this.card.append(actions);
parent.append(actions);
}
private async assign(personId: string): Promise<void> {
+19 -20
View File
@@ -2,7 +2,8 @@ import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonC
import { getLocale } from '../i18n/locale.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { clear, el } from './dom.ts';
import { formatPersonPlace, placement, renderPersonCard, roleLabels } from './personCard.ts';
import { formatPersonPlace, placement, roleLabels } from './personCard.ts';
import { PersonCardHost } from './personCardHost.ts';
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
@@ -62,6 +63,7 @@ export class PeoplePanel {
private cardToken = 0;
private locate: ((id: string) => string) | null = null;
private painted: PersonCard | null = null;
private readonly cardHost = new PersonCardHost();
constructor(private readonly options: PeoplePanelOptions) {
this.ageMinInput.min = '0';
@@ -156,6 +158,7 @@ export class PeoplePanel {
show(schoolId: number): void {
this.schoolId = schoolId;
this.cardHost.attach(schoolId);
this.sort = 'surname';
this.dir = 'asc';
this.page = 1;
@@ -179,7 +182,7 @@ export class PeoplePanel {
setLocate(locate: (id: string) => string): void {
this.locate = locate;
this.relocate();
this.cardHost.relocate((id) => this.placeOf(id));
}
private onFilterChange(): void {
@@ -361,38 +364,30 @@ export class PeoplePanel {
}
private paintCard(card: PersonCard | null): void {
clear(this.card);
this.painted = card;
if (card === null) {
clear(this.card);
this.card.append(el('p', { class: 'panel__empty', text: t('peoplePickHint') }));
return;
}
renderPersonCard(this.card, card, (id) => void this.openCard(id), this.placeOf(card.id));
this.mountPersonTimetable(card);
this.cardHost.paint(
this.card,
card,
(id) => void this.openCard(id),
this.placeOf(card.id),
(overview, painted) => this.mountPersonTimetable(overview, painted),
);
}
private relocate(): void {
const line = this.card.querySelector('.people__card-place');
if (!(line instanceof HTMLElement) || this.painted === null) {
return;
}
line.textContent = this.placeOf(this.painted.id);
}
private placeOf(id: string): string {
return this.locate?.(id) ?? formatPersonPlace('away');
}
private mountPersonTimetable(card: PersonCard): void {
private mountPersonTimetable(overview: HTMLElement, card: PersonCard): void {
const schoolId = this.schoolId;
const query = personTimetableQuery(card);
if (schoolId === null || query === null) {
return;
}
this.card.append(this.personTimetableTitle, this.personGrid.element);
overview.append(this.personTimetableTitle, this.personGrid.element);
this.personGrid.attach(schoolId);
const token = this.cardToken;
void fetchTimetable(schoolId, getLocale(), query)
@@ -411,6 +406,10 @@ export class PeoplePanel {
this.personGrid.setTable(null);
});
}
private placeOf(id: string): string {
return this.locate?.(id) ?? formatPersonPlace('away');
}
}
function field(label: HTMLElement, control: HTMLElement): HTMLLabelElement {
+58 -28
View File
@@ -1,11 +1,11 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { PersonCard } from '../net/api.ts';
import { getLocale, setLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { renderPersonCard } from './personCard.ts';
import { renderPersonCard, type PersonCardHandlers } from './personCard.ts';
const initial = getLocale();
@@ -58,15 +58,29 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
],
carryMass: 1.2,
carryCapacity: 14,
hasAvatar: false,
hasFullBody: false,
...overrides,
};
}
function handlers(overrides: Partial<PersonCardHandlers> = {}): PersonCardHandlers {
return {
onRelative: () => {},
schoolId: 1,
tab: 'overview',
onTabChange: () => {},
portraitBusy: null,
portraitError: null,
...overrides,
};
}
describe('renderPersonCard', () => {
it('shows worn layers with colour and carried mass in the same scroll', () => {
it('shows worn layers with colour and carried mass on the overview tab', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(root, card(), () => {});
renderPersonCard(root, card(), handlers());
expect(root.textContent).toContain(t('peopleApparel'));
expect(root.textContent).toContain('Верх');
@@ -74,7 +88,7 @@ describe('renderPersonCard', () => {
expect(root.textContent).toContain(t('peopleCarry'));
expect(root.textContent).toContain(t('peopleCarryMass', { held: '1.2', cap: '14' }));
expect(root.textContent).toContain('Учебник · Математика');
expect(root.querySelector('.people__tabs')).toBeNull();
expect(root.querySelector('.people__tabs')).not.toBeNull();
});
it('shows the server condition caption, not a band inferred from the number', () => {
@@ -95,7 +109,7 @@ describe('renderPersonCard', () => {
},
],
}),
() => {},
handlers(),
);
expect(root.textContent).toContain('целая');
@@ -104,30 +118,46 @@ describe('renderPersonCard', () => {
expect((root.querySelector('.people__need-fill') as HTMLElement | null)?.style.width).toBe('10%');
});
it('changes the caption when the payload label crosses a threshold', () => {
setLocale('ru');
it('shows the avatar on overview only when hasAvatar is true', () => {
const root = document.createElement('div');
renderPersonCard(root, card(), () => {});
expect(root.querySelector('.people__wear-label')?.textContent).toBe('целая');
renderPersonCard(root, card({ hasAvatar: false }), handlers({ schoolId: 1 }));
expect(root.querySelector('.people__avatar')).toBeNull();
root.replaceChildren();
renderPersonCard(
root,
card({
worn: [
{
defName: 'Shirt',
label: 'Рубашка',
color: 'White',
colorLabel: 'Белый',
layers: [{ defName: 'Top', label: 'Верх' }],
condition: 0.1,
conditionLabel: 'висит лохмотьями',
},
],
}),
() => {},
);
expect(root.querySelector('.people__wear-label')?.textContent).toBe('висит лохмотьями');
renderPersonCard(root, card({ hasAvatar: true }), handlers({ schoolId: 1 }));
const image = root.querySelector('.people__avatar') as HTMLImageElement | null;
expect(image).not.toBeNull();
expect(image?.getAttribute('src')).toContain('/portrait?kind=avatar');
});
it('does not mount apparel on the portrait tab', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(root, card(), handlers({ tab: 'portrait' }));
const panel = root.querySelector('.people__portrait-panel');
expect(panel).not.toBeNull();
expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar'));
expect(panel?.textContent).not.toContain(t('peopleApparel'));
});
it('disables generate while busy', () => {
setLocale('en');
const root = document.createElement('div');
renderPersonCard(root, card(), handlers({ tab: 'portrait', portraitBusy: 'avatar' }));
const buttons = [...root.querySelectorAll('button')].filter((button) => button.textContent?.includes('Generating'));
expect(buttons.length).toBeGreaterThan(0);
expect(buttons.every((button) => button.disabled)).toBe(true);
});
it('calls onTabChange when a tab is clicked', () => {
const onTabChange = vi.fn();
const root = document.createElement('div');
renderPersonCard(root, card(), handlers({ onTabChange }));
const portraitTab = [...root.querySelectorAll('button')].find((button) => button.textContent === t('peopleTabPortrait'));
portraitTab?.click();
expect(onTabChange).toHaveBeenCalledWith('portrait');
});
});
+130 -24
View File
@@ -1,4 +1,5 @@
import type { PersonCard, PersonListItem, PersonRel, PersonRole } from '../net/api.ts';
import { portraitUrl } from '../net/api.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { el } from './dom.ts';
@@ -8,6 +9,24 @@ const ROLE_KEYS: Record<PersonRole, MessageKey> = {
parent: 'peopleRoleParent',
};
export type PersonCardTab = 'overview' | 'portrait';
export interface PersonCardHandlers {
readonly onRelative: (id: string) => void;
readonly place?: string;
readonly schoolId: number | null;
readonly tab: PersonCardTab;
readonly onTabChange: (tab: PersonCardTab) => void;
readonly onGeneratePortrait?: (kind: 'avatar' | 'full') => void;
readonly portraitBusy?: 'avatar' | 'full' | null;
readonly portraitError?: string | null;
readonly swarmConfigured?: boolean;
}
export interface PersonCardMount {
readonly overviewPane: HTMLElement;
}
export function roleLabels(roles: readonly string[]): string {
return roles
.map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role))
@@ -30,15 +49,57 @@ export function placement(person: Pick<PersonListItem, 'classYear' | 'classLette
export function renderPersonCard(
parent: HTMLElement,
card: PersonCard,
onRelative: (id: string) => void,
place?: string,
): void {
parent.append(
handlers: PersonCardHandlers,
): PersonCardMount {
const tabs = el('div', { class: 'people__tabs' });
const overviewTab = tabButton(t('peopleTabOverview'), handlers.tab === 'overview', () => handlers.onTabChange('overview'));
const portraitTab = tabButton(t('peopleTabPortrait'), handlers.tab === 'portrait', () => handlers.onTabChange('portrait'));
tabs.append(overviewTab, portraitTab);
const overviewPane = el('div', { class: 'people__tab-panel' });
const portraitPane = el('div', { class: 'people__tab-panel people__portrait-panel', hidden: handlers.tab !== 'portrait' });
if (handlers.tab !== 'overview') {
overviewPane.hidden = true;
}
mountOverview(overviewPane, card, handlers);
mountPortrait(portraitPane, card, handlers);
parent.append(tabs, overviewPane, portraitPane);
return { overviewPane };
}
function tabButton(label: string, active: boolean, onClick: () => void): HTMLButtonElement {
return el('button', {
class: `panel__tab${active ? ' panel__tab--active' : ''}`,
type: 'button',
text: label,
onClick,
});
}
function mountOverview(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void {
const header = el('div', { class: 'people__card-header' });
if (card.hasAvatar && handlers.schoolId !== null) {
header.append(
el('img', {
class: 'people__avatar',
alt: card.fullName,
src: portraitUrl(handlers.schoolId, card.id, 'avatar'),
}),
);
}
const titles = el('div', { class: 'people__card-titles' });
titles.append(
el('h3', { class: 'people__card-name', text: card.fullName }),
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
);
if (place !== undefined && place.length > 0) {
parent.append(el('p', { class: 'people__card-place', text: place }));
header.append(titles);
parent.append(header);
if (handlers.place !== undefined && handlers.place.length > 0) {
parent.append(el('p', { class: 'people__card-place', text: handlers.place }));
}
if (card.activityLabel !== null && card.activityLabel.length > 0) {
parent.append(el('p', { class: 'people__card-activity', text: card.activityLabel }));
@@ -51,15 +112,62 @@ export function renderPersonCard(
appendCarry(parent, card);
const family = section(t('peopleFamily'));
appendRelatives(family, t('peopleParents'), card.family.parents, onRelative);
appendRelatives(family, t('peopleChildren'), card.family.children, onRelative);
appendRelatives(family, t('peopleSiblings'), card.family.siblings, onRelative);
appendRelatives(family, t('peoplePartners'), card.family.partners, onRelative);
appendRelatives(family, t('peopleParents'), card.family.parents, handlers.onRelative);
appendRelatives(family, t('peopleChildren'), card.family.children, handlers.onRelative);
appendRelatives(family, t('peopleSiblings'), card.family.siblings, handlers.onRelative);
appendRelatives(family, t('peoplePartners'), card.family.partners, handlers.onRelative);
if (family.childElementCount > 1) {
parent.append(family);
}
}
function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void {
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') }));
parent.append(portraitPreview(card, handlers, 'avatar'));
parent.append(
el('button', {
class: 'button button--small',
type: 'button',
text: handlers.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'),
disabled: handlers.portraitBusy !== null || handlers.swarmConfigured === false,
onClick: () => handlers.onGeneratePortrait?.('avatar'),
}),
);
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitFull') }));
parent.append(portraitPreview(card, handlers, 'full'));
parent.append(
el('button', {
class: 'button button--small',
type: 'button',
text: handlers.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'),
disabled: handlers.portraitBusy !== null || handlers.swarmConfigured === false,
onClick: () => handlers.onGeneratePortrait?.('full'),
}),
);
if (handlers.swarmConfigured === false) {
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
}
if ((handlers.portraitError?.length ?? 0) > 0) {
parent.append(el('p', { class: 'panel__error', text: handlers.portraitError! }));
}
}
function portraitPreview(card: PersonCard, handlers: PersonCardHandlers, kind: 'avatar' | 'full'): HTMLElement {
const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody;
if (hasImage && handlers.schoolId !== null) {
return el('img', {
class: kind === 'avatar' ? 'people__portrait people__portrait--avatar' : 'people__portrait people__portrait--full',
alt: card.fullName,
src: portraitUrl(handlers.schoolId, card.id, kind),
});
}
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
}
function cardMeta(card: PersonCard): string {
const bits = [
roleLabels(card.roles),
@@ -119,36 +227,34 @@ function appendApparel(parent: HTMLElement, rows: PersonCard['worn']): void {
return;
}
const list = el('div', { class: 'people__garments' });
const block = section(t('peopleApparel'));
const garments = el('div', { class: 'people__garments' });
for (const row of rows) {
const layers = row.layers.map((layer) => layer.label).join(', ');
const color = row.colorLabel ?? row.color;
const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label;
const garment = el('div', { class: 'people__garment' });
garment.append(el('span', { text: layers.length > 0 ? layers : value }));
if (layers.length > 0) {
garment.append(el('span', { text: value }));
}
const share = Math.max(0, Math.min(1, row.condition));
const fill = el('span', { class: 'people__need-fill' });
fill.style.width = `${Math.round(share * 100)}%`;
fill.classList.toggle('people__need-fill--low', share < 0.25);
list.append(
el(
'div',
{ class: 'people__garment' },
el(
'div',
{ class: 'people__pair' },
el('dt', { text: layers.length > 0 ? layers : row.label }),
el('dd', { text: value }),
),
garment.append(
el(
'div',
{ class: 'people__wear' },
el('span', { class: 'people__wear-label', text: row.conditionLabel }),
el('span', { class: 'people__need-track' }, fill),
),
),
);
garments.append(garment);
}
parent.append(section(t('peopleApparel')), list);
block.append(garments);
parent.append(block);
}
function appendCarry(parent: HTMLElement, card: PersonCard): void {
+138
View File
@@ -0,0 +1,138 @@
import { ApiError, fetchGameStatus, fetchPerson, generatePortrait, type PersonCard } from '../net/api.ts';
import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { clear } from './dom.ts';
import {
renderPersonCard,
type PersonCardHandlers,
type PersonCardMount,
type PersonCardTab,
} from './personCard.ts';
/** Shared card tab state, portrait generation and Swarm availability for people-like panels. */
export class PersonCardHost {
private tab: PersonCardTab = 'overview';
private portraitBusy: 'avatar' | 'full' | null = null;
private portraitError: string | null = null;
private swarmConfigured = false;
private schoolId: number | null = null;
private painted: PersonCard | null = null;
private mount: PersonCardMount | null = null;
private container: HTMLElement | null = null;
private onRelative: (id: string) => void = () => {};
private placeOf: (id: string) => string = () => '';
private onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void;
attach(schoolId: number): void {
this.schoolId = schoolId;
void fetchGameStatus()
.then((status) => {
this.swarmConfigured = status.swarmUiConfigured;
})
.catch(() => {
this.swarmConfigured = false;
});
}
detach(): void {
this.schoolId = null;
this.painted = null;
this.mount = null;
this.container = null;
this.onOverviewMounted = undefined;
this.tab = 'overview';
this.portraitBusy = null;
this.portraitError = null;
}
paint(
container: HTMLElement,
card: PersonCard | null,
onRelative: (id: string) => void,
placeOf: (id: string) => string,
onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void,
): void {
this.container = container;
this.onRelative = onRelative;
this.placeOf = placeOf;
this.onOverviewMounted = onOverviewMounted;
clear(container);
this.painted = card;
this.mount = null;
if (card === null) {
return;
}
this.repaint(container, card);
}
private refreshPainted(): void {
if (this.painted !== null && this.container !== null) {
this.repaint(this.container, this.painted);
}
}
private repaint(container: HTMLElement, card: PersonCard): void {
clear(container);
this.mount = renderPersonCard(container, card, this.handlers(this.onRelative, this.placeOf(card.id)));
this.onOverviewMounted?.(this.mount.overviewPane, card);
}
private handlers(onRelative: (id: string) => void, place: string): PersonCardHandlers {
return {
onRelative,
place,
schoolId: this.schoolId,
tab: this.tab,
onTabChange: (tab) => {
this.tab = tab;
this.refreshPainted();
},
onGeneratePortrait: (kind) => void this.generate(kind),
portraitBusy: this.portraitBusy,
portraitError: this.portraitError,
swarmConfigured: this.swarmConfigured,
};
}
private async generate(kind: 'avatar' | 'full'): Promise<void> {
const schoolId = this.schoolId;
const personId = this.painted?.id;
if (schoolId === null || personId === undefined || this.portraitBusy !== null) {
return;
}
this.portraitBusy = kind;
this.portraitError = null;
this.refreshPainted();
try {
const result = await generatePortrait(schoolId, personId, kind);
const card = await fetchPerson(schoolId, personId, getLocale());
this.painted = {
...card,
hasAvatar: result.hasAvatar,
hasFullBody: result.hasFullBody,
};
} catch (error) {
this.portraitError =
error instanceof ApiError && error.code === 'swarmui-not-configured'
? t('peoplePortraitUnavailable')
: t('peoplePortraitFailed');
} finally {
this.portraitBusy = null;
this.refreshPainted();
}
}
relocate(placeOf: (id: string) => string): void {
this.placeOf = placeOf;
const line = this.container?.querySelector('.people__card-place');
if (!(line instanceof HTMLElement) || this.painted === null) {
return;
}
line.textContent = placeOf(this.painted.id);
}
}
+3 -1
View File
@@ -65,7 +65,9 @@ internal sealed record PersonCardResponse(
IReadOnlyList<WornItemResponse> Worn,
IReadOnlyList<CarriedItemResponse> Carried,
float CarryMass,
float CarryCapacity);
float CarryCapacity,
bool HasAvatar = false,
bool HasFullBody = false);
internal sealed record WornItemResponse(
string DefName,
+84 -1
View File
@@ -174,6 +174,7 @@ internal static class SchoolEndpoints
string personId,
string? lang,
GameCommandQueue commands,
PortraitService portraits,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
@@ -187,13 +188,95 @@ internal static class SchoolEndpoints
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return outcome.Error switch
{
PersonLookupError.None when outcome.Card is not null => Results.Ok(outcome.Card),
PersonLookupError.None when outcome.Card is not null =>
Results.Ok(portraits.WithPortraitFlags(id, outcome.Card)),
PersonLookupError.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
})
.WithName("GetSchoolPerson");
schools.MapGet("/{id:int}/people/{personId}/portrait", async (
int id,
string personId,
string? kind,
PortraitService portraits,
SchoolStore store,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
}
if (!PortraitKindParser.TryParse(kind, out var portraitKind))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full.");
}
var lookup = await portraits.EnsurePersonAsync(id, personId, cancellationToken);
if (lookup == PersonLookupError.UnknownPerson)
{
return Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school.");
}
if (lookup != PersonLookupError.None)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
var path = store.PortraitPath(id, personId, portraitKind);
if (!File.Exists(path))
{
return Problem(StatusCodes.Status404NotFound, "portrait-missing", "That portrait has not been generated yet.");
}
return Results.File(path, "image/png");
})
.WithName("GetSchoolPersonPortrait");
schools.MapPost("/{id:int}/people/{personId}/portrait", async (
int id,
string personId,
string? kind,
PortraitService portraits,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
}
if (!PortraitKindParser.TryParse(kind, out var portraitKind))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "kind must be avatar or full.");
}
if (!portraits.IsGenerationEnabled)
{
return Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured.");
}
var result = await portraits.GenerateAsync(id, personId, portraitKind, cancellationToken);
return result.Outcome switch
{
PortraitGenerationOutcome.Succeeded => Results.Created(
$"/api/schools/{id}/people/{Uri.EscapeDataString(personId)}/portrait?kind={(portraitKind == PortraitKind.Avatar ? "avatar" : "full")}",
new PortraitResponse(
portraitKind == PortraitKind.Avatar ? "avatar" : "full",
result.HasAvatar,
result.HasFullBody)),
PortraitGenerationOutcome.UnknownPerson =>
Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
PortraitGenerationOutcome.NotConfigured =>
Problem(StatusCodes.Status503ServiceUnavailable, "swarmui-not-configured", "SwarmUI is not configured."),
PortraitGenerationOutcome.TimedOut =>
Problem(StatusCodes.Status504GatewayTimeout, "swarmui-timeout", "SwarmUI did not finish in time."),
_ => Problem(StatusCodes.Status502BadGateway, "swarmui-unavailable", "SwarmUI could not generate the portrait."),
};
})
.WithName("GenerateSchoolPersonPortrait");
schools.MapGet("/{id:int}/staffing", (
int id,
string? lang,
+28
View File
@@ -0,0 +1,28 @@
namespace HSchool.Server.Game;
internal enum PortraitKind
{
Avatar,
Full,
}
internal static class PortraitKindParser
{
public static bool TryParse(string? value, out PortraitKind kind)
{
if (string.Equals(value, "avatar", StringComparison.OrdinalIgnoreCase))
{
kind = PortraitKind.Avatar;
return true;
}
if (string.Equals(value, "full", StringComparison.OrdinalIgnoreCase))
{
kind = PortraitKind.Full;
return true;
}
kind = default;
return false;
}
}
@@ -0,0 +1,48 @@
using HSchool.Server.Api;
namespace HSchool.Server.Game;
/// <summary>Turns a person card into a Flux-style English prompt for SwarmUI.</summary>
internal static class PortraitPromptBuilder
{
public static (string Positive, string Negative) Build(PersonCardResponse card, SwarmUiSettings settings, PortraitKind kind)
{
var preset = kind == PortraitKind.Avatar ? settings.Avatar : settings.FullBody;
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(settings.Positive))
{
parts.Add(settings.Positive.Trim());
}
if (!string.IsNullOrWhiteSpace(preset.Positive))
{
parts.Add(preset.Positive.Trim());
}
parts.Add(card.Female ? "A young woman" : "A young man");
parts.Add($"age {card.Age}");
foreach (var row in card.Body)
{
parts.Add($"{row.Label.ToLowerInvariant()} {row.Value.ToLowerInvariant()}");
}
foreach (var item in card.Worn)
{
var color = item.ColorLabel ?? item.Color;
if (!string.IsNullOrWhiteSpace(color))
{
parts.Add($"wearing {item.Label.ToLowerInvariant()} in {color.ToLowerInvariant()}");
}
else
{
parts.Add($"wearing {item.Label.ToLowerInvariant()}");
}
}
var positive = string.Join(", ", parts.Where(part => part.Length > 0));
var negative = settings.Negative?.Trim() ?? string.Empty;
return (positive, negative);
}
}
+138
View File
@@ -0,0 +1,138 @@
using HSchool.Server.Api;
namespace HSchool.Server.Game;
internal sealed class PortraitService(
SchoolStore store,
SwarmUiClient swarm,
SwarmUiSettings settings,
GameCommandQueue commands,
ILogger<PortraitService> logger)
{
private static readonly TimeSpan PersonLookupTimeout = TimeSpan.FromSeconds(5);
/// <summary>English labels for Swarm prompts, independent of the UI locale.</summary>
private const string PromptLocale = "en";
public bool IsGenerationEnabled => swarm.IsConfigured;
public (bool HasAvatar, bool HasFullBody) Flags(int schoolId, string personId) =>
store.PortraitFlags(schoolId, personId);
public PersonCardResponse WithPortraitFlags(int schoolId, PersonCardResponse card)
{
var (hasAvatar, hasFullBody) = Flags(schoolId, card.Id);
return card with { HasAvatar = hasAvatar, HasFullBody = hasFullBody };
}
public async Task<PersonLookupError> EnsurePersonAsync(
int schoolId,
string personId,
CancellationToken cancellationToken)
{
var command = new GameCommand.GetPerson(
schoolId,
personId,
PromptLocale,
NewCompletion<PersonCardResult>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
return outcome.Error;
}
public async Task<PortraitGenerationResult> GenerateAsync(
int schoolId,
string personId,
PortraitKind kind,
CancellationToken cancellationToken)
{
if (!swarm.IsConfigured)
{
return PortraitGenerationResult.NotConfigured;
}
var command = new GameCommand.GetPerson(
schoolId,
personId,
PromptLocale,
NewCompletion<PersonCardResult>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(PersonLookupTimeout, cancellationToken);
if (outcome.Error == PersonLookupError.UnknownPerson)
{
return PortraitGenerationResult.UnknownPerson;
}
if (outcome.Error != PersonLookupError.None || outcome.Card is null)
{
return PortraitGenerationResult.UnknownSchool;
}
var (positive, negative) = PortraitPromptBuilder.Build(outcome.Card, settings, kind);
try
{
var bytes = await swarm.GenerateAsync(positive, negative, settings, kind, cancellationToken);
store.SavePortrait(schoolId, personId, kind, bytes);
var flags = Flags(schoolId, personId);
return PortraitGenerationResult.Succeeded(kind, flags.HasAvatar, flags.HasFullBody);
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogWarning(ex, "SwarmUI timed out for school {SchoolId} person {PersonId}.", schoolId, personId);
return PortraitGenerationResult.TimedOut;
}
catch (HttpRequestException ex)
{
logger.LogWarning(ex, "SwarmUI request failed for school {SchoolId} person {PersonId}.", schoolId, personId);
return PortraitGenerationResult.Unavailable;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Portrait generation failed for school {SchoolId} person {PersonId}.", schoolId, personId);
return PortraitGenerationResult.Unavailable;
}
}
private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
}
internal enum PortraitGenerationOutcome
{
Succeeded,
UnknownSchool,
UnknownPerson,
NotConfigured,
Unavailable,
TimedOut,
}
internal sealed record PortraitGenerationResult(
PortraitGenerationOutcome Outcome,
PortraitKind Kind,
bool HasAvatar,
bool HasFullBody)
{
public static PortraitGenerationResult UnknownSchool { get; } =
new(PortraitGenerationOutcome.UnknownSchool, default, false, false);
public static PortraitGenerationResult UnknownPerson { get; } =
new(PortraitGenerationOutcome.UnknownPerson, default, false, false);
public static PortraitGenerationResult NotConfigured { get; } =
new(PortraitGenerationOutcome.NotConfigured, default, false, false);
public static PortraitGenerationResult Unavailable { get; } =
new(PortraitGenerationOutcome.Unavailable, default, false, false);
public static PortraitGenerationResult TimedOut { get; } =
new(PortraitGenerationOutcome.TimedOut, default, false, false);
public static PortraitGenerationResult Succeeded(PortraitKind kind, bool hasAvatar, bool hasFullBody) =>
new(PortraitGenerationOutcome.Succeeded, kind, hasAvatar, hasFullBody);
}
internal sealed record PortraitResponse(string Kind, bool HasAvatar, bool HasFullBody);
+51
View File
@@ -260,6 +260,55 @@ internal sealed class SchoolStore
{
File.Delete(timetable);
}
var portraits = PortraitsDirectory(id);
if (Directory.Exists(portraits))
{
Directory.Delete(portraits, recursive: true);
}
}
public bool HasPortrait(int schoolId, string personId, PortraitKind kind) =>
File.Exists(PortraitPath(schoolId, personId, kind));
public (bool HasAvatar, bool HasFullBody) PortraitFlags(int schoolId, string personId)
{
var directory = PortraitsDirectory(schoolId);
if (!Directory.Exists(directory))
{
return (false, false);
}
return (HasPortrait(schoolId, personId, PortraitKind.Avatar), HasPortrait(schoolId, personId, PortraitKind.Full));
}
public string PortraitPath(int schoolId, string personId, PortraitKind kind)
{
var safeId = SanitizePersonId(personId);
var suffix = kind == PortraitKind.Avatar ? "avatar" : "full";
return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png");
}
public void SavePortrait(int schoolId, string personId, PortraitKind kind, ReadOnlySpan<byte> png)
{
var path = PortraitPath(schoolId, personId, kind);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temp = path + ".tmp";
File.WriteAllBytes(temp, png);
File.Move(temp, path, overwrite: true);
}
private static string SanitizePersonId(string personId)
{
foreach (var ch in personId)
{
if (ch is not (>= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '.' or '-' or '_'))
{
throw new ArgumentException("The person id contains invalid characters.", nameof(personId));
}
}
return personId;
}
public RosterDocument? TryReadPeople(int id)
@@ -319,6 +368,8 @@ internal sealed class SchoolStore
private string TimetablePath(int id) => Path.Combine(DirectoryPath, $"{id}.timetable.json");
private string PortraitsDirectory(int id) => Path.Combine(DirectoryPath, $"{id}.portraits");
private string IndexPath() => Path.Combine(DirectoryPath, IndexFileName);
private static void WriteAtomic<T>(string path, T value, JsonSerializerOptions? options = null)
+180
View File
@@ -0,0 +1,180 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
internal sealed class SwarmUiClient
{
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
private readonly HttpClient _http;
private readonly SwarmUiOptions _options;
private readonly ILogger<SwarmUiClient> _logger;
private string? _sessionId;
public SwarmUiClient(HttpClient http, IOptions<SwarmUiOptions> options, ILogger<SwarmUiClient> logger)
{
_http = http;
_options = options.Value;
_logger = logger;
}
public bool IsConfigured => !string.IsNullOrWhiteSpace(_options.BaseUrl);
public async Task<byte[]> GenerateAsync(
string prompt,
string negativePrompt,
SwarmUiSettings settings,
PortraitKind kind,
CancellationToken cancellationToken)
{
if (!IsConfigured)
{
throw new InvalidOperationException("SwarmUI is not configured.");
}
return await RunWithSessionAsync(async () =>
{
var preset = kind == PortraitKind.Avatar ? settings.Avatar : settings.FullBody;
var body = new Dictionary<string, object?>
{
["session_id"] = _sessionId,
["images"] = 1,
["donotsave"] = true,
["prompt"] = prompt,
["negativeprompt"] = negativePrompt,
["model"] = settings.Model,
["steps"] = settings.Steps,
["cfgscale"] = settings.CfgScale,
["width"] = preset.Width,
["height"] = preset.Height,
["seed"] = settings.Seed,
};
if (!string.IsNullOrWhiteSpace(settings.Sampler))
{
body["sampler"] = settings.Sampler;
}
if (settings.ClipSkip > 0)
{
body["clipskip"] = settings.ClipSkip;
}
using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
using var response = await _http.PostAsync("/API/GenerateText2Image", content, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (root.TryGetProperty("error_id", out var errorId)
&& errorId.GetString() == "invalid_session_id")
{
throw new SwarmUiSessionInvalidException();
}
if (root.TryGetProperty("error", out var error))
{
throw new InvalidOperationException($"SwarmUI error: {error.GetString()}");
}
if (!root.TryGetProperty("images", out var images) || images.GetArrayLength() == 0)
{
throw new InvalidOperationException("SwarmUI returned no images.");
}
var first = images[0].GetString() ?? throw new InvalidOperationException("SwarmUI image entry was empty.");
return await ReadImageAsync(first, cancellationToken);
}, cancellationToken);
}
private async Task<byte[]> ReadImageAsync(string reference, CancellationToken cancellationToken)
{
if (reference.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = reference.IndexOf(',', StringComparison.Ordinal);
if (comma < 0)
{
throw new InvalidOperationException("Malformed data URL from SwarmUI.");
}
return Convert.FromBase64String(reference[(comma + 1)..]);
}
var path = reference.StartsWith('/') ? reference : $"/{reference}";
using var response = await _http.GetAsync(path, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
private async Task<T> RunWithSessionAsync<T>(Func<Task<T>> call, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_sessionId))
{
await RefreshSessionAsync(cancellationToken);
}
try
{
return await call();
}
catch (SwarmUiSessionInvalidException)
{
await RefreshSessionAsync(cancellationToken);
return await call();
}
}
private async Task RefreshSessionAsync(CancellationToken cancellationToken)
{
using var content = new StringContent("{}", Encoding.UTF8, "application/json");
using var response = await _http.PostAsync("/API/GetNewSession", content, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
_sessionId = document.RootElement.GetProperty("session_id").GetString()
?? throw new InvalidOperationException("SwarmUI did not return a session_id.");
_logger.LogDebug("SwarmUI session refreshed.");
}
internal sealed class SwarmUiSessionInvalidException : Exception;
}
internal static class SwarmUiClientRegistration
{
public static IServiceCollection AddSwarmUi(this IServiceCollection services, IConfiguration configuration)
{
services
.AddOptions<SwarmUiOptions>()
.Bind(configuration.GetSection(SwarmUiOptions.SectionName))
.Validate(options => options.TimeoutSeconds is > 0 and <= 3600, "SwarmUi:TimeoutSeconds must be between 1 and 3600.")
.ValidateOnStart();
services.AddHttpClient<SwarmUiClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<SwarmUiOptions>>().Value;
if (!string.IsNullOrWhiteSpace(options.BaseUrl))
{
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/");
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
}
if (!string.IsNullOrWhiteSpace(options.Authorization))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.Authorization);
}
});
return services;
}
}
@@ -0,0 +1,63 @@
using System.Text.Json;
namespace HSchool.Server.Game;
/// <summary>Generation defaults and prompt templates loaded from swarmui.json next to the server.</summary>
internal sealed class SwarmUiSettings
{
public string Model { get; init; } = "";
public int Steps { get; init; } = 8;
public double CfgScale { get; init; } = 1;
public int ClipSkip { get; init; } = 1;
public string Sampler { get; init; } = "euler";
public long Seed { get; init; } = -1;
public string Positive { get; init; } = "";
public string Negative { get; init; } = "";
public SwarmUiPreset Avatar { get; init; } = new();
public SwarmUiPreset FullBody { get; init; } = new();
internal sealed class SwarmUiPreset
{
public int Width { get; init; } = 512;
public int Height { get; init; } = 512;
public string Positive { get; init; } = "";
}
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
public static SwarmUiSettings Load(IHostEnvironment environment, ILogger logger)
{
var path = Path.Combine(environment.ContentRootPath, "swarmui.json");
if (!File.Exists(path))
{
logger.LogWarning("SwarmUI settings file {Path} is missing; portrait generation will use empty defaults.", path);
return new SwarmUiSettings();
}
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<SwarmUiSettings>(json, Json) ?? new SwarmUiSettings();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not read SwarmUI settings from {Path}.", path);
return new SwarmUiSettings();
}
}
}
+8
View File
@@ -22,6 +22,14 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</None>
<None Include="swarmui.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</None>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="HSchool.Server.Tests" />
</ItemGroup>
</Project>
+20 -3
View File
@@ -1,9 +1,11 @@
using System.Net.WebSockets;
using HSchool.Server;
using HSchool.Server.Api;
using HSchool.Server.Game;
using HSchool.Server.Net;
using HSchool.Simulation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
@@ -34,6 +36,9 @@ builder.Services.AddSingleton<ModContent>();
builder.Services.AddSingleton<GameSocketHandler>();
builder.Services.AddSingleton<GameLoopService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
builder.Services.AddSwarmUi(builder.Configuration);
builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService<IHostEnvironment>(), sp.GetRequiredService<ILoggerFactory>().CreateLogger("SwarmUiSettings")));
builder.Services.AddSingleton<PortraitService>();
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName));
@@ -55,10 +60,16 @@ app.MapSchoolEndpoints();
app.MapTimetableEndpoints();
app.MapModEndpoints();
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) =>
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients, IOptions<SwarmUiOptions> swarm) =>
{
var state = loop.SchoolsState;
return new GameStatusResponse(loop.CurrentTick, loop.Options.TickRate, state.Schools.Count, state.MaxSchools, clients.Count);
return new GameStatusResponse(
loop.CurrentTick,
loop.Options.TickRate,
state.Schools.Count,
state.MaxSchools,
clients.Count,
!string.IsNullOrWhiteSpace(swarm.Value.BaseUrl));
})
.WithName("GetGameStatus");
@@ -102,7 +113,13 @@ app.UseFileServer();
app.Run();
/// <summary>Loop health for dashboards and integration tests.</summary>
internal sealed record GameStatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
internal sealed record GameStatusResponse(
uint Tick,
int TickRate,
int Schools,
int MaxSchools,
int Connections,
bool SwarmUiConfigured);
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
public partial class Program;
+14
View File
@@ -0,0 +1,14 @@
namespace HSchool.Server;
/// <summary>Connection to a local SwarmUI instance. Empty <see cref="BaseUrl"/> disables generation.</summary>
internal sealed class SwarmUiOptions
{
public const string SectionName = "SwarmUi";
public string BaseUrl { get; set; } = "";
/// <summary>Optional bearer token when SwarmUI requires authorization.</summary>
public string Authorization { get; set; } = "";
public int TimeoutSeconds { get; set; } = 180;
}
+5
View File
@@ -6,6 +6,11 @@
}
},
"AllowedHosts": "*",
"SwarmUi": {
"BaseUrl": "http://127.0.0.1:7801",
"Authorization": "",
"TimeoutSeconds": 180
},
"Simulation": {
"TickRate": 20,
"MaxSchools": 6,
+20
View File
@@ -0,0 +1,20 @@
{
"model": "pornmasterFlux2Klein_v4TurboFp8.safetensors",
"steps": 8,
"cfgScale": 1,
"clipSkip": 1,
"sampler": "euler",
"seed": -1,
"positive": "School portrait photograph, neutral background, natural lighting, realistic, sharp focus.",
"negative": "nsfw, nude, naked, explicit, blurry, deformed, extra limbs, bad anatomy, watermark, text, logo",
"avatar": {
"width": 512,
"height": 512,
"positive": "Head and shoulders portrait, facing the camera, upper body visible."
},
"fullBody": {
"width": 768,
"height": 1024,
"positive": "Full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
}
}
@@ -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));
}
}
}