// @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/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' && typeof window.navLibraryView === '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.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 }); await page.locator('#nav-library-head').evaluate(el => el.click()); 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 }); // 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._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.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 }); await page.waitForSelector('#lib-chars-list', { state: 'attached', 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.waitForFunction(() => typeof window._charDetailPage === 'function', null, { timeout: 15_000 }); await page.waitForSelector('#lib-chars-list', { state: 'attached', 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(''); });