165 lines
10 KiB
JavaScript
165 lines
10 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);
|
|
});
|