tts-voice-creator-clone-and.../tests/characters.spec.js
mARTin-B78 5877aa2edc fix(characters): persist base-appearance image pairs, deterministic prompts, form-aware review; add forms panel
- fix: base-appearance matched pair lost its concept image + metadata on reload; visual save
  wrote base concept art to the record root; one save path for base and forms now
- fix: extraction no longer lets the LLM author image_prompt (source of avatar/concept drift);
  avatar-only generation uses the deterministic prompt and flags an existing pair
- fix: prompt writes and Physical/Clothing edits go to the active form; review endpoint checks
  each form's own pair; fingerprint parity with the browser incl. visual_* overrides
- feat: appearance forms panel (add/rename/delete/show, per-form stale flag, generate one/all)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 15:27:21 +02:00

412 lines
27 KiB
JavaScript

// @ts-check
// Character-library interaction coverage uses deterministic API fixtures so it
// can run without a populated host voice directory or a configured provider.
const { test, expect } = require('@playwright/test');
const VOICES = [
{ id: 'de_alice', name: 'Alice', enabled: true, has_ref: true, lang: 'de', gender: 'F', rating: 3 },
{ id: 'de_bob', name: 'Bob', enabled: true, has_ref: true, lang: 'de', gender: 'M', rating: 2 },
];
const CHARACTERS = [
{ id: 'c-alice', book: 'Smoke Book', name: 'Alice', tags: '', voice: 'de_alice', sheet: { gender: 'F' } },
{ id: 'c-bob', book: 'Smoke Book', name: 'Bob', tags: '', voice: 'de_bob', sheet: { gender: 'M', age: '42', profession: 'captain', physical: 'tall' } },
];
test('Characters / Cast supports profile sorting, column visibility, and voice rating', async ({ page }) => {
await page.route('**/api/characters*', route => route.fulfill({ json: { characters: CHARACTERS } }));
await page.route('**/api/characters/visual-pair-review', route => route.fulfill({ json: { ok: true, checked: 2, issues: [] } }));
await page.route('**/api/voices*', route => route.fulfill({ json: VOICES }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.route('**/api/voice/meta', route => route.fulfill({ json: { ok: true } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
// The loader injects inactive section HTML in the background; wait until
// the Library panel exists before switching its sub-view.
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
await page.waitForFunction(() => typeof window.navLibraryView === 'function' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
const tableToggle = page.locator('.lib-chars-view-toggle button[data-view="table"]');
await expect(tableToggle).toBeVisible({ timeout: 15_000 });
await page.locator('#lib-chars-review-visual').click();
await expect(page.locator('#lib-chars-visual-review')).toContainText('passed');
// Switch through the same persisted view state used by the UI, then wait
// for the asynchronous character rebuild to finish before asserting rows.
await page.evaluate(() => { localStorage.setItem('ttsvc_libchars_view', 'table'); return window.libraryRenderCharacters(); });
await page.waitForFunction(() => {
const table = document.querySelector('#lib-chars-list .lib-chars-tbl');
return !!table && getComputedStyle(table).display !== 'none' && table.offsetParent !== null;
}, null, { timeout: 15_000 });
const table = page.locator('#lib-chars-list .lib-chars-tbl');
await expect(table).toBeVisible({ timeout: 15_000 });
await expect(table.locator('tbody tr')).toHaveCount(3); // includes the synthetic Narrator row
const beforeSort = await table.locator('tbody tr').allTextContents();
await page.locator('select#lib-chars-sort-sel').selectOption('profile');
await expect.poll(async () => table.locator('tbody tr').allTextContents()).not.toEqual(beforeSort);
await expect(table.locator('tbody tr').nth(1)).toContainText('Bob');
await page.locator('.lib-chars-column-button').click();
const profileColumn = page.locator('.lib-chars-column-menu input[data-column-key="profile"]');
await expect(profileColumn).toBeChecked();
await profileColumn.uncheck();
await expect(table.locator('th[data-sort-key="profile"]')).toHaveCSS('display', 'none');
const aliceRating = table.locator('.lib-chars-tbl-rating-star[data-voice-id="de_alice"][data-rating="5"]');
await aliceRating.click();
await expect(aliceRating).toHaveClass(/is-on/);
});
test('consistent image generation can be cancelled from its progress panel', async ({ page }) => {
let cancelled = false;
let cancelRequests = 0;
await page.route('**/api/characters*', route => route.fulfill({ json: { characters: [] } }));
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.route('**/api/character-generate-consistent-images', route => route.fulfill({ json: { job_id: 'job-cancel' } }));
await page.route('**/api/character-generate-consistent-images/cancel/job-cancel', async route => {
cancelRequests += 1;
cancelled = true;
await route.fulfill({ json: { ok: true, cancelled: true } });
});
await page.route('**/api/character-generate-consistent-images/status/job-cancel', route => route.fulfill({
json: cancelled
? { stage: 'cancelled', detail: 'Cancelled', elapsed: 1, stale_seconds: 0, done: true, cancelled: true, error: 'Generation cancelled', result: null }
: { stage: 'identity', detail: 'Generating shared identity portrait…', elapsed: 0, stale_seconds: 0, done: false, error: null, result: null },
}));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
await page.waitForFunction(() => typeof window._charAutoGenerateConsistentImages === 'function', null, { timeout: 15_000 });
await page.evaluate(() => {
window.__cancelRun = window._charAutoGenerateConsistentImages({
id: 'cancel-character', name: 'Cancel Character', book: 'Smoke Book',
sheet: { image_prompt: 'portrait', concept_art_prompt: 'reference' },
}, 'cinematic').catch(() => null);
});
await expect(page.locator('#cij-progress-panel')).toBeVisible({ timeout: 10_000 });
await page.locator('#cij-progress-panel .cij-progress-cancel').click();
await expect.poll(() => cancelRequests).toBe(1);
await expect(page.locator('#cij-progress-panel')).toHaveClass(/cij-progress-err/, { timeout: 5_000 });
});
test('current-version image pairs marked inconsistent show a review warning', async ({ page }) => {
await page.route('**/api/characters*', route => route.fulfill({ json: {
characters: [{
id: 'c-mismatch', book: 'Smoke Book', name: 'Mismatch', image: '/profile.png',
sheet: {
image_prompt: 'portrait', concept_art_prompt: 'reference', concept_art_image: '/reference.png',
consistent_prompt_version: 'v3', consistent_identity_fingerprint: 'same-current-version',
identity_consistent: false,
},
}],
} }));
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.locator('#nav-library-head').evaluate(el => el.click());
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
await page.locator('.lib-char-card[data-char-id="c-mismatch"]').click();
await expect(page.locator('.lcd-conceptart .note')).toContainText('konsistente Identität', { timeout: 15_000 });
});
test('character appearance forms stay in one record and switch the detail view', async ({ page }) => {
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
await page.waitForFunction(() => typeof window._charDetailPage === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window._charDetailPage({
id: 'c-forms', book: 'Smoke Book', name: 'Zerwas', voice: 'de_bob',
image: '/human.png', sheet: {
physical: 'human with dark hair', image_prompt: 'human portrait', concept_art_prompt: 'human reference',
forms: [{ id: 'wolf', name: 'Wolf form', physical: 'large black wolf', image_prompt: 'black wolf portrait', image: '/wolf.png', concept_art_image: '/wolf-sheet.png' }],
active_form_id: 'wolf',
},
}, [], { container: document.querySelector('#lib-chars-list') }));
await expect(page.locator('.lcd-form-switcher select')).toHaveValue('wolf');
await expect(page.locator('.lcd-avatar img')).toHaveAttribute('src', '/wolf.png');
await page.locator('.lcd-form-switcher select').evaluate(select => {
select.value = '';
select.dispatchEvent(new Event('change', { bubbles: true }));
});
await expect(page.locator('.lcd-form-switcher select')).toHaveValue('');
await expect.poll(() => page.evaluate(() => window._libDetailRec?.sheet?.active_form_id || '')).toBe('');
});
test('profile completeness counter distinguishes empty, partial, and full sheets', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
await page.waitForFunction(() => typeof window._charProfileCompleteness === 'function', null, { timeout: 15_000 });
const counts = await page.evaluate(() => ({
empty: window._charProfileCompleteness({}),
partial: window._charProfileCompleteness({ gender: 'F', profession: 'captain', inventory: ['lantern'] }),
full: window._charProfileCompleteness(Object.fromEntries(window._CHAR_PROFILE_COMPLETENESS_FIELDS.map(key => [key, 'filled']))),
}));
expect(counts.empty.filled).toBe(0);
expect(counts.partial.filled).toBe(3);
expect(counts.full.filled).toBe(counts.full.total);
});
// ── Visual persistence (base appearance and forms) ───────────────────────────
// A stateful stand-in for /api/characters/<id> so these tests can assert what was
// actually written back, not just what the page shows.
async function mockRecord(page, initial) {
const state = { rec: JSON.parse(JSON.stringify(initial)), puts: [] };
await page.route(`**/api/characters/${encodeURIComponent(initial.id)}`, route => {
if (route.request().method() === 'PUT') {
state.rec = route.request().postDataJSON();
state.puts.push(state.rec);
return route.fulfill({ json: state.rec });
}
return route.fulfill({ json: state.rec });
});
await page.route('**/api/characters', route => route.fulfill({ json: { characters: [state.rec] } }));
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
return state;
}
async function openLibrary(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
await page.waitForFunction(() => typeof window._charAutoGenerateImage === 'function' && typeof window._libSetSheetPrompt === 'function', null, { timeout: 15_000 });
}
const PNG_A = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
test('a matched image pair for the base appearance is fully persisted', async ({ page }) => {
const state = await mockRecord(page, { id: 'c-base', book: 'Smoke Book', name: 'Base', tags: '', sheet: { physical: 'tall', image_prompt: 'p', concept_art_prompt: 'r' } });
await page.route('**/api/character-generate-consistent-images', route => route.fulfill({ json: { job_id: 'job-ok' } }));
await page.route('**/api/character-generate-consistent-images/status/job-ok', route => route.fulfill({ json: {
stage: 'done', detail: 'Done', elapsed: 1, stale_seconds: 0, done: true, error: null,
result: { profile: PNG_A, reference: PNG_A + 'AA', prompt_version: 'v3', identity_fingerprint: 'abc123', profile_asset_digest: 'pd', reference_asset_digest: 'rd', identity_consistent: true },
} }));
await openLibrary(page);
await page.evaluate(() => window._charAutoGenerateConsistentImages({ id: 'c-base', book: 'Smoke Book', name: 'Base', sheet: { physical: 'tall', image_prompt: 'p', concept_art_prompt: 'r' } }, 'cinematic'));
const saved = state.puts[state.puts.length - 1];
expect(saved.image).toBe(PNG_A);
expect(saved.sheet.concept_art_image).toBe(PNG_A + 'AA');
expect(saved.sheet.consistent_identity_fingerprint).toBe('abc123');
expect(saved.sheet.consistent_prompt_version).toBe('v3');
expect(saved.sheet.profile_asset_digest).toBe('pd');
expect(saved.sheet.reference_asset_digest).toBe('rd');
expect(saved.sheet.identity_consistent).toBe(true);
expect(saved.concept_art_image).toBeUndefined();
});
test('regenerating only the avatar uses the deterministic prompt and flags an existing pair', async ({ page }) => {
const state = await mockRecord(page, { id: 'c-av', book: 'Smoke Book', name: 'Avatar', tags: '', image: PNG_A,
sheet: { physical: 'a tall woman with copper hair and green eyes', archetype: 'cartographer', image_prompt: 'LLM-AUTHORED', concept_art_image: PNG_A + 'AA', identity_consistent: true } });
let promptSent = '';
await page.route('**/api/character-generate-prompts', route => route.fulfill({ json: { image_prompt: 'DETERMINISTIC-PROMPT' } }));
await page.route('**/api/character-generate-image', route => { promptSent = route.request().postDataJSON().prompt; return route.fulfill({ json: { image: PNG_A + 'BB' } }); });
await openLibrary(page);
await page.evaluate(() => window._charAutoGenerateImage(JSON.parse(JSON.stringify({ id: 'c-av', book: 'Smoke Book', name: 'Avatar',
sheet: { physical: 'a tall woman with copper hair and green eyes', archetype: 'cartographer', image_prompt: 'LLM-AUTHORED', concept_art_image: 'x' } }))));
expect(promptSent).toBe('DETERMINISTIC-PROMPT');
const saved = state.puts[state.puts.length - 1];
expect(saved.image).toBe(PNG_A + 'BB');
expect(saved.sheet.identity_consistent).toBe(false);
});
test('a hand-edited portrait prompt is still honoured for avatar-only generation', async ({ page }) => {
await mockRecord(page, { id: 'c-ed', book: 'Smoke Book', name: 'Edited', tags: '', sheet: { physical: 'a tall woman with copper hair', image_prompt: 'MY-OWN-PROMPT', image_prompt_user_edited: true } });
let promptSent = '';
let generatedPrompts = 0;
await page.route('**/api/character-generate-prompts', route => { generatedPrompts += 1; return route.fulfill({ json: { image_prompt: 'DETERMINISTIC-PROMPT' } }); });
await page.route('**/api/character-generate-image', route => { promptSent = route.request().postDataJSON().prompt; return route.fulfill({ json: { image: PNG_A } }); });
await openLibrary(page);
await page.evaluate(() => window._charAutoGenerateImage({ id: 'c-ed', book: 'Smoke Book', name: 'Edited', sheet: { physical: 'a tall woman with copper hair', image_prompt: 'MY-OWN-PROMPT', image_prompt_user_edited: true } }));
expect(promptSent).toBe('MY-OWN-PROMPT');
expect(generatedPrompts).toBe(0);
});
test('appearance prompts are written to the active form, character-wide prompts to the base', async ({ page }) => {
await mockRecord(page, { id: 'c-form', book: 'b', name: 'Form', tags: '', sheet: {} });
await openLibrary(page);
const out = await page.evaluate(() => {
const rec = { id: 'c-form', sheet: { image_prompt: 'base-img', voice_design_prompt: 'base-voice', active_form_id: 'wolf',
forms: [{ id: 'wolf', name: 'Wolf', image_prompt: 'old-wolf' }, { id: 'bat', name: 'Bat', image_prompt: 'bat-img' }] } };
window._libSetSheetPrompt(rec, 'image_prompt', 'new-wolf', false);
window._libSetSheetPrompt(rec, 'voice_design_prompt', 'new-voice', true);
return rec.sheet;
});
expect(out.image_prompt).toBe('base-img');
expect(out.forms.find(f => f.id === 'wolf').image_prompt).toBe('new-wolf');
expect(out.forms.find(f => f.id === 'bat').image_prompt).toBe('bat-img');
expect(out.voice_design_prompt).toBe('new-voice');
expect(out.voice_design_prompt_user_edited).toBe(true);
});
// ── Appearance forms: management ─────────────────────────────────────────────
test('form helpers add, rename, delete and judge image pairs', async ({ page }) => {
await mockRecord(page, { id: 'c-h', book: 'b', name: 'H', tags: '', sheet: {} });
await openLibrary(page);
const r = await page.evaluate(() => {
const out = {};
const sheet = { physical: 'human', clothing: 'coat', forms: [{ id: 'wolf', name: 'Wolf', physical: 'fur' }] };
const added = window._libAddForm(sheet, ' Bat form ');
out.added = { name: added.forms[1].name, id: added.forms[1].id, physical: added.forms[1].physical, clothing: added.forms[1].clothing, active: added.active_form_id, base: sheet.forms.length };
out.errors = [];
for (const bad of ['', ' ', 'wolf', 'x'.repeat(161)]) { try { window._libAddForm(sheet, bad); out.errors.push('no error for ' + JSON.stringify(bad.slice(0, 5))); } catch (e) { out.errors.push(e.message); } }
let full = { forms: [] };
for (let i = 0; i < 8; i++) full = window._libAddForm(full, 'form ' + i);
try { window._libAddForm(full, 'one too many'); out.errors.push('no error at 9'); } catch (e) { out.errors.push(e.message); }
out.ids = new Set(full.forms.map(f => f.id)).size;
out.renamed = window._libRenameForm(sheet, 'wolf', 'Werewolf').forms[0].name;
try { window._libRenameForm(added, 'wolf', 'bat form'); out.errors.push('no error on duplicate rename'); } catch (e) { out.errors.push(e.message); }
const deleted = window._libDeleteForm({ ...added, active_form_id: 'wolf' }, 'wolf');
out.deleted = { n: deleted.forms.length, active: deleted.active_form_id };
const keepActive = window._libDeleteForm(added, 'wolf');
out.keepActive = keepActive.active_form_id === added.active_form_id;
// pair state
const base = { physical: 'human' };
const fp = window._libIdentityFingerprint({ ...base, physical: 'fur' });
const okForm = { id: 'f', physical: 'fur', image: 'i', concept_art_image: 'c', consistent_prompt_version: 'v3', consistent_identity_fingerprint: fp, identity_consistent: true };
out.state = {
none: window._libFormPairState(base, { id: 'f', physical: 'fur' }),
ok: window._libFormPairState(base, okForm),
stale: window._libFormPairState(base, { ...okForm, consistent_identity_fingerprint: 'old' }),
broken: window._libFormPairState(base, { ...okForm, identity_consistent: false }),
legacy: window._libFormPairState(base, { ...okForm, consistent_prompt_version: 'v2' }),
};
return out;
});
expect(r.added).toEqual({ name: 'Bat form', id: 'bat-form', physical: 'human', clothing: 'coat', active: 'bat-form', base: 1 });
expect(r.errors).toEqual(['Give the form a name.', 'Give the form a name.', 'Another form already uses that name.', 'The form name is too long (max 160 characters).', 'A character can have at most 8 forms.', 'Another form already uses that name.']);
expect(r.ids).toBe(8);
expect(r.renamed).toBe('Werewolf');
expect(r.deleted).toEqual({ n: 1, active: '' });
expect(r.keepActive).toBe(true);
expect(r.state).toEqual({ none: 'none', ok: 'ok', stale: 'review', broken: 'review', legacy: 'review' });
});
test('editing Physical text while a form is active changes only that form', async ({ page }) => {
await mockRecord(page, { id: 'c-f', book: 'b', name: 'F', tags: '', sheet: {} });
await openLibrary(page);
const r = await page.evaluate(() => {
const rec = { id: 'c-f', sheet: { physical: 'human', active_form_id: 'wolf', forms: [{ id: 'wolf', name: 'Wolf', physical: 'fur' }, { id: 'bat', name: 'Bat', physical: 'wings' }] } };
window._libSetFormField(rec, 'physical', 'grey fur, yellow eyes');
const withForm = JSON.parse(JSON.stringify(rec.sheet));
const base = { id: 'c-f', sheet: { physical: 'human', forms: [{ id: 'wolf', name: 'Wolf' }] } };
window._libSetFormField(base, 'physical', 'tall');
return { withForm, base: base.sheet };
});
expect(r.withForm.physical).toBe('human');
expect(r.withForm.forms.find(f => f.id === 'wolf').physical).toBe('grey fur, yellow eyes');
expect(r.withForm.forms.find(f => f.id === 'bat').physical).toBe('wings');
expect(r.base.physical).toBe('tall');
});
test('generating images for several forms rebuilds each form\'s prompts, saves each on its own form and restores the active appearance', async ({ page }) => {
const state = await mockRecord(page, { id: 'c-g', book: 'Smoke Book', name: 'Gen', tags: '', sheet: {
physical: 'human', active_form_id: '', forms: [{ id: 'wolf', name: 'Wolf', physical: 'fur' }, { id: 'bat', name: 'Bat', physical: 'wings', concept_art_prompt: 'HAND-MADE', concept_art_prompt_user_edited: true }] } });
const consistentBodies = [];
let job = 0;
await page.route('**/api/character-generate-prompts', route => {
const b = route.request().postDataJSON();
return route.fulfill({ json: { image_prompt: 'PORTRAIT[' + b.sheet.active_form_id + ']', concept_art_prompt: 'SHEET[' + b.sheet.active_form_id + ']' } });
});
await page.route('**/api/character-generate-consistent-images', route => {
consistentBodies.push(route.request().postDataJSON());
job += 1;
return route.fulfill({ json: { job_id: 'job-' + job } });
});
await page.route('**/api/character-generate-consistent-images/status/*', route => {
const id = route.request().url().split('/').pop();
return route.fulfill({ json: { stage: 'done', detail: 'Done', elapsed: 1, stale_seconds: 0, done: true, error: null,
result: { profile: PNG_A + id, reference: PNG_A + id + 'R', prompt_version: 'v3', identity_fingerprint: 'fp-' + id, profile_asset_digest: 'p', reference_asset_digest: 'r', identity_consistent: true } } });
});
await openLibrary(page);
const progress = [];
await page.exposeFunction('__progress', (n, t, name) => progress.push(n + '/' + t + ' ' + name));
await page.evaluate(async () => {
const rec = await (await fetch('/api/characters/c-g')).json();
await window._libGenerateFormImages(rec, ['wolf', 'bat'], 'cinematic', (n, t, name) => window.__progress(n, t, name));
});
expect(progress).toEqual(['1/2 Wolf', '2/2 Bat']);
expect(consistentBodies.map(b => b.profile_prompt)).toEqual(['PORTRAIT[wolf]', 'PORTRAIT[bat]']);
// the hand-made reference prompt of the second form is kept, the first is rebuilt
expect(consistentBodies.map(b => b.reference_prompt)).toEqual(['SHEET[wolf]', 'HAND-MADE']);
const forms = state.rec.sheet.forms;
expect(forms.find(f => f.id === 'wolf').image).toBe(PNG_A + 'job-1');
expect(forms.find(f => f.id === 'bat').image).toBe(PNG_A + 'job-2');
expect(forms.find(f => f.id === 'wolf').consistent_identity_fingerprint).toBe('fp-job-1');
expect(state.rec.sheet.active_form_id).toBe('');
expect(state.rec.image).toBeFalsy(); // the base look was not touched
});
test('the forms panel adds, renames and deletes a form and saves each change', async ({ page }) => {
const state = await mockRecord(page, { id: 'c-ui', book: 'Smoke Book', name: 'Zerwas', tags: '', voice: 'de_bob', image: '/human.png', sheet: {
physical: 'human with dark hair', clothing: 'black coat',
forms: [{ id: 'wolf', name: 'Wolf form', physical: 'large black wolf', image: '/wolf.png', concept_art_image: '/wolf-sheet.png' }] } });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
await page.waitForFunction(() => typeof window._charDetailPage === 'function' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
await page.waitForSelector('.lib-char-card[data-char-id="c-ui"]', { timeout: 15_000 });
await page.evaluate(async () => {
const rec = await (await fetch('/api/characters/c-ui')).json();
window._charDetailPage(rec, [], { container: document.querySelector('#lib-chars-list') });
});
const panel = page.locator('.lcd-forms-manage');
await expect(panel).toContainText('Appearance forms (1)');
// A form with images made before fingerprints existed is flagged for review.
await expect(panel.locator('li[data-form-id="wolf"] .lcd-form-flag')).toContainText('review');
// add
await panel.locator('.lcd-form-new-name').fill('Bat form');
await panel.locator('.lcd-form-add-btn').click();
await expect(page.locator('.lcd-forms-manage')).toContainText('Appearance forms (2)');
let saved = state.puts[state.puts.length - 1].sheet;
expect(saved.forms.map(f => f.name)).toEqual(['Wolf form', 'Bat form']);
expect(saved.active_form_id).toBe('bat-form');
expect(saved.forms[1].physical).toBe('human with dark hair'); // starts as a copy of the base look
expect(saved.physical).toBe('human with dark hair'); // base untouched
// adding a duplicate name is refused with a message and saves nothing
const putsBefore = state.puts.length;
await page.locator('.lcd-forms-manage .lcd-form-new-name').fill('wolf FORM');
await page.locator('.lcd-forms-manage .lcd-form-add-btn').click();
await expect(page.locator('#toast')).toContainText('already uses that name');
expect(state.puts.length).toBe(putsBefore);
// rename
const wolfName = page.locator('.lcd-forms-manage li[data-form-id="wolf"] .lcd-form-name');
await wolfName.fill('Werewolf');
await wolfName.blur();
await expect(page.locator('.lcd-forms-manage li[data-form-id="wolf"] .lcd-form-name')).toHaveValue('Werewolf');
expect(state.puts[state.puts.length - 1].sheet.forms[0].name).toBe('Werewolf');
// delete (confirm dialog)
await page.locator('.lcd-forms-manage li[data-form-id="bat-form"] .lcd-form-del').click();
await page.locator('#cd-ok').click();
await expect(page.locator('.lcd-forms-manage')).toContainText('Appearance forms (1)');
saved = state.puts[state.puts.length - 1].sheet;
expect(saved.forms.map(f => f.id)).toEqual(['wolf']);
expect(saved.active_form_id).toBe('');
expect(state.rec.voice).toBe('de_bob'); // the character and its voice are untouched
});