// @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/ 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 });