tts-voice-creator-clone-and.../tests/characters.spec.js

157 lines
9.7 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' && 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.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
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._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('');
});
test('profile completeness counter distinguishes empty, partial, and full sheets', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
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);
});