Add portrait settings support to school creation and update related API and UI components. Enhance error handling for invalid presets and ensure proper cloning of settings. Update tests to validate new functionality.
ci / server (push) Failing after 3m40s
ci / client (push) Failing after 14s

This commit is contained in:
Leonid Pershin
2026-08-20 08:17:58 +03:00
parent db95de0ddf
commit 9d96108516
20 changed files with 293 additions and 40 deletions
-4
View File
@@ -466,10 +466,6 @@ const en: Messages = {
editPortraitPresets: 'Configure models',
portraitPresetsHint: 'The model and prompts will be stored with this school.',
portraitPresetsEdited: 'Set for this school.',
portraitPresets: 'Portrait presets',
editPortraitPresets: 'Configure models',
portraitPresetsHint: 'The model and prompts will be stored with this school.',
portraitPresetsEdited: 'Set for this school.',
schoolSeed: 'Seed {seed}',
schoolSeedLabel: 'Seed',
schoolSeedHint: 'Optional. A shared seed recreates the same people; leave blank to roll one.',
+2
View File
@@ -56,6 +56,7 @@ export interface CreateSchoolOptions {
readonly countryId?: string;
readonly nativeLanguage?: string;
readonly seed?: number;
readonly portraitSettings?: SwarmUiSettingsFile;
}
export async function createSchool(
@@ -74,6 +75,7 @@ export async function createSchool(
countryId: extras.countryId ?? null,
nativeLanguage: extras.nativeLanguage ?? null,
seed: extras.seed ?? null,
portraitSettings: extras.portraitSettings ?? null,
}),
});
}
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
fetchCatalog,
fetchMods,
fetchSwarmUiSettings,
type CatalogResponse,
type MapLayout,
type School,
@@ -20,6 +21,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
...actual,
fetchCatalog: vi.fn(),
fetchMods: vi.fn(),
fetchSwarmUiSettings: vi.fn(),
};
});
@@ -108,6 +110,31 @@ describe('createSchoolDialog', () => {
{ id: 'example', required: false, label: 'Example', version: '1.0', requires: ['core'] },
]);
vi.mocked(fetchCatalog).mockResolvedValue(catalog());
vi.mocked(fetchSwarmUiSettings).mockReset();
vi.mocked(fetchSwarmUiSettings).mockResolvedValue({
activePresetId: 'default',
presets: [
{
id: 'default',
label: 'Default',
model: 'template.safetensors',
steps: 4,
cfgScale: 1,
clipSkip: 1,
sampler: 'euler',
scheduler: 'normal',
seed: -1,
positive: 'base',
negative: 'neg',
positiveLoras: [],
negativeLoras: [],
avatar: { width: 512, height: 512, positive: '' },
custom: { width: 512, height: 512, positive: '' },
fullBody: { width: 512, height: 512, positive: '' },
},
],
ageRules: [],
});
});
afterEach(() => {
@@ -291,4 +318,22 @@ describe('map reset from the create editor', () => {
await vi.waitFor(() => expect(hint?.textContent).toBe(t('mapDefaultHint')));
void opened;
});
it('shows a portrait-preset button that stores settings on the school', async () => {
const opened = createSchoolDialog({
defaultStartDate: new Date('2024-09-01T00:00:00.000Z'),
suggestName: async () => 'North',
create: async () => school(),
});
await vi.waitFor(() => expect(fetchCatalog).toHaveBeenCalled());
const dialog = document.querySelector('dialog');
if (dialog === null) {
throw new Error('create dialog is missing');
}
const presets = byText(dialog, 'button', t('editPortraitPresets'));
expect(presets.nextElementSibling?.textContent).toBe(t('portraitPresetsHint'));
await vi.waitFor(() => expect(fetchSwarmUiSettings).toHaveBeenCalled());
void opened;
});
});
@@ -2,10 +2,12 @@ import {
ApiError,
fetchCatalog,
fetchMods,
fetchSwarmUiSettings,
type CatalogResponse,
type CreateSchoolOptions as CreateExtras,
type MapLayout,
type School,
type SwarmUiSettingsFile,
} from '../net/api.ts';
import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts';
import { getLocale } from '../i18n/locale.ts';
@@ -13,6 +15,7 @@ import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { mapEditorDialog } from './mapEditorDialog.ts';
import { Modal } from './modal.ts';
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
interface CreateSchoolOptions {
/** Prefilled start of the school year, straight from the server config. */
@@ -34,6 +37,8 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
let currentMap: MapLayout | null = null;
let countryId: string | null = null;
let nativeLanguageId: string | null = null;
let portraitSettings: SwarmUiSettingsFile | null = null;
let portraitEdited = false;
const modsField = el('div', { class: 'field' });
const modsLabel = el('span', { class: 'field__label' });
@@ -80,6 +85,16 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
el('div', { class: 'field__row' }, editMapButton, mapHint),
);
const presetsLabel = el('span', { class: 'field__label' });
const presetsButton = el('button', { class: 'button', type: 'button' });
const presetsHint = el('p', { class: 'hint' });
const presetsField = el(
'div',
{ class: 'field' },
presetsLabel,
el('div', { class: 'field__row' }, presetsButton, presetsHint),
);
const nameInput = el('input', { class: 'input', type: 'text' });
nameInput.maxLength = 40;
nameInput.required = true;
@@ -113,6 +128,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
countryField,
nativeField,
mapField,
presetsField,
seedField,
error,
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
@@ -127,6 +143,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
randomButton.toggleAttribute('disabled', value);
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, countryId).length <= 1);
editMapButton.toggleAttribute('disabled', waiting);
presetsButton.toggleAttribute('disabled', waiting);
seedInput.toggleAttribute('disabled', value);
};
@@ -135,6 +152,10 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
error.hidden = false;
};
const paintPresetsHint = (): void => {
presetsHint.textContent = portraitEdited ? t('portraitPresetsEdited') : t('portraitPresetsHint');
};
const paintMapHint = (): void => {
if (catalog === null || currentMap === null) {
mapHint.textContent = '';
@@ -253,6 +274,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
startLabel.textContent = t('gameStart');
mapLabel.textContent = t('mapEditorTitle');
editMapButton.textContent = t('editMap');
presetsLabel.textContent = t('portraitPresets');
presetsButton.textContent = t('editPortraitPresets');
paintPresetsHint();
nameInput.placeholder = t('schoolNamePlaceholder');
randomButton.textContent = t('randomName');
randomButton.title = t('randomNameTitle');
@@ -279,6 +303,23 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
});
});
presetsButton.addEventListener('click', () => {
if (busy) {
return;
}
void swarmUiSettingsDialog(portraitSettings ?? undefined).then((next) => {
if (next === null) {
return;
}
portraitSettings = next;
portraitEdited = true;
paintPresetsHint();
presetsButton.focus();
});
});
nativeRandomButton.addEventListener('click', () => {
if (busy || catalog === null) {
return;
@@ -335,6 +376,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
countryId: countryId ?? undefined,
nativeLanguage: nativeLanguageId ?? undefined,
seed: seed ?? undefined,
portraitSettings: portraitSettings ?? undefined,
})
.then((school) => modal.close(school))
.catch((reason: unknown) => {
@@ -345,6 +387,15 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
modal.element.append(title, form);
void paintMods();
void fetchSwarmUiSettings()
.then((settings) => {
if (portraitSettings === null) {
portraitSettings = settings;
}
})
.catch(() => {
/* Server copies swarmui.json at create if the form sends nothing. */
});
return modal.open(nameInput);
}
@@ -405,6 +456,8 @@ function describe(reason: unknown): string {
return t('errorUnknownCountry');
case 'unknown-native-language':
return t('errorUnknownNativeLanguage');
case 'invalid-portrait-settings':
return t('errorInvalidPortraitSettings');
default:
return reason.message;
}
+1 -8
View File
@@ -11,7 +11,6 @@ import { schoolWord, t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { confirmDialog } from './confirmDialog.ts';
import { createSchoolDialog } from './createSchoolDialog.ts';
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
import { SchoolCard } from './schoolCard.ts';
interface MainMenuOptions {
@@ -37,10 +36,6 @@ export class MainMenu {
class: 'button button--primary',
type: 'button',
});
private readonly settingsButton = el('button', {
class: 'button',
type: 'button',
});
private readonly logoutButton = el('button', {
class: 'button',
type: 'button',
@@ -57,7 +52,6 @@ export class MainMenu {
constructor(private readonly options: MainMenuOptions) {
this.createButton.addEventListener('click', () => void this.openCreateDialog());
this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog());
this.logoutButton.addEventListener('click', () => this.options.onLogout());
this.status.hidden = true;
@@ -66,7 +60,7 @@ export class MainMenu {
'header',
{ class: 'screen__header' },
this.title,
el('div', { class: 'screen__actions' }, this.logoutButton, this.settingsButton, this.createButton),
el('div', { class: 'screen__actions' }, this.logoutButton, this.createButton),
),
this.limitHint,
this.status,
@@ -85,7 +79,6 @@ export class MainMenu {
localize(): void {
this.title.textContent = t('schoolsTitle');
this.createButton.textContent = t('createSchool');
this.settingsButton.textContent = t('settings');
this.logoutButton.textContent = t('sessionLogout');
this.emptyHint.textContent = t('emptySchools');
@@ -1,9 +1,7 @@
import {
ApiError,
fetchGameStatus,
fetchSwarmUiDiscovery,
fetchSwarmUiSettings,
saveSwarmUiSettings,
type SwarmUiDiscovery,
type SwarmUiLoraEntry,
type SwarmUiPresetDefinition,
@@ -13,8 +11,8 @@ import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { Modal } from './modal.ts';
export function swarmUiSettingsDialog(): Promise<boolean> {
const modal = new Modal<boolean>(false);
export function swarmUiSettingsDialog(initial?: SwarmUiSettingsFile): Promise<SwarmUiSettingsFile | null> {
const modal = new Modal<SwarmUiSettingsFile | null>(null);
const error = el('p', { class: 'dialog__error' });
error.hidden = true;
@@ -28,7 +26,7 @@ export function swarmUiSettingsDialog(): Promise<boolean> {
let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], samplers: [], schedulers: [] };
let editingPresetId = '';
const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(false) });
const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(null) });
const saveButton = el('button', { class: 'button button--primary', type: 'submit' });
const addPresetButton = el('button', { class: 'button button--small', type: 'button' });
const addAgeRuleButton = el('button', { class: 'button button--small', type: 'button' });
@@ -116,7 +114,7 @@ export function swarmUiSettingsDialog(): Promise<boolean> {
localize();
try {
const [settings, gameStatus, lists] = await Promise.all([
fetchSwarmUiSettings(),
initial === undefined ? fetchSwarmUiSettings() : Promise.resolve(structuredClone(initial)),
fetchGameStatus(),
fetchSwarmUiDiscovery(),
]);
@@ -153,14 +151,7 @@ export function swarmUiSettingsDialog(): Promise<boolean> {
syncActivePresetFromForm();
saveButton.disabled = true;
error.hidden = true;
try {
await saveSwarmUiSettings(config);
modal.close(true);
} catch (err) {
showError(err instanceof ApiError ? err.message : t('settingsSaveFailed'));
} finally {
saveButton.disabled = false;
}
modal.close(structuredClone(config));
}
function showError(message: string): void {