tts-voice-creator-clone-and.../tests/characters-scope.spec.js
mARTin-B78 e8c0df027e test: fail any spec that would write to the live API
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 21:00:25 +02:00

117 lines
7.1 KiB
JavaScript

// @ts-check
// Global cast view (sidebar) vs. per-book characters inside Studio.
const { test, expect } = require('./_guard');
const mk = (book, n, prefix) => Array.from({ length: n }, (_, i) => ({
id: `${prefix}-${i}`, book, name: `${prefix}Hero${i}`, tags: '', voice: null,
sheet: { gender: i % 2 ? 'F' : 'M', archetype: i === 0 ? 'Rogue' : '' },
}));
const BOOK_A = 'Alpha Saga 01';
const BOOK_B = 'Beta Saga 02';
const BOOK_C = 'Gamma Saga 03';
const CHARACTERS = [...mk(BOOK_A, 3, 'Al'), ...mk(BOOK_B, 4, 'Be'), ...mk(BOOK_C, 2, 'Ga')];
async function setup(page, { resume } = {}) {
await page.route('**/api/characters*', route => route.fulfill({ json: { characters: CHARACTERS } }));
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
await page.route('**/api/reader/docs/*/progress', route => route.fulfill({ json: { ok: true } }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
await page.route('**/api/reader/docs/book-b', route => route.fulfill({ json: { id: 'book-b', title: BOOK_B, sentences: [] } }));
await page.addInitScript(r => {
try { localStorage.clear(); if (r) localStorage.setItem('ttsvc_studio_resume', JSON.stringify(r)); } catch (_) { /* ignore */ }
}, resume || null);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === '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 });
}
async function openGlobal(page) {
await page.evaluate(() => window.navTo('s-library'));
await page.waitForFunction(() => typeof window.navLibraryView === 'function' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
await expect(page.locator('#lib-chars-global-title')).toHaveText(/All characters \(all books\)/, { timeout: 15_000 });
}
test('global cast view groups all books in collapsible, lazily rendered bands', async ({ page }) => {
await setup(page);
await openGlobal(page);
const bands = page.locator('#lib-chars-list .lib-chars-production');
await expect(bands).toHaveCount(3);
await expect(page.locator('#lib-chars-global-count')).toContainText('9 characters in 3 books');
await expect(page.locator('#lib-chars-list h3.lib-chars-prod-title')).toHaveCount(3);
// First band open, the others collapsed and not rendered at all.
const first = bands.nth(0);
const second = bands.nth(1);
await expect(first.locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'true');
await expect(second.locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'false');
await expect(first.locator('.lib-char-card')).toHaveCount(4); // 3 + synthetic Narrator
await expect(second.locator('.lib-char-card')).toHaveCount(0);
await expect(first.locator('.lib-chars-prod-count')).toHaveText('4');
// Opening a collapsed band renders its characters.
await second.locator('.lib-chars-prod-collapse-btn').click();
await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-char-card')).toHaveCount(5);
await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'true');
// Collapsing again toggles without a rebuild.
await page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn').click();
await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'false');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
});
test('global cast view searches across all books', async ({ page }) => {
await setup(page);
await openGlobal(page);
const search = page.locator('#lib-chars-search');
await search.fill('GaHero1');
const bands = page.locator('#lib-chars-list .lib-chars-production');
await expect(bands).toHaveCount(1);
await expect(bands.first().locator('.lib-chars-prod-name')).toHaveText(BOOK_C);
await expect(bands.first().locator('.lib-char-card')).toHaveCount(1);
await expect(search).toBeFocused();
await expect(bands.first().locator('.lib-chars-prod-count')).toHaveText('1 of 3');
await search.fill('rogue'); // archetype of the first character of every book
await expect(bands).toHaveCount(3);
await search.fill('nobody-here');
await expect(page.locator('.lib-chars-search-empty')).toBeVisible();
await search.fill('');
await expect(bands).toHaveCount(3);
});
test('Studio Voices shows only the picked book and links to the global view', async ({ page }) => {
await setup(page, { resume: { phase: 3, bookId: 'book-b', bookTitle: BOOK_B } });
await page.evaluate(() => window.navTo('s-caststudio'));
await expect(page.locator('#stu-voices-slot #lib-chars-book-chip')).toContainText(`Book: ${BOOK_B}`, { timeout: 20_000 });
const bands = page.locator('#stu-voices-slot .lib-chars-production');
await expect(bands).toHaveCount(1);
await expect(bands.first().locator('.lib-chars-prod-name')).toHaveText(BOOK_B);
await expect(page.locator('#stu-voices-slot')).not.toContainText(BOOK_A);
await expect(page.locator('#stu-voices-slot .lib-char-card')).toHaveCount(5);
// Per-book actions stay in the band header.
await expect(bands.first().locator('.lib-chars-bulk-voice-btn')).toBeVisible();
await expect(bands.first().locator('.lib-chars-select-all-btn')).toBeVisible();
await page.locator('#lib-chars-show-all').click();
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
await expect(page.locator('#lib-chars-global-title')).toBeVisible();
await expect(page.locator('#lib-chars-list .lib-chars-production')).toHaveCount(3);
});
test('Studio Characters step shows the book chip', async ({ page }) => {
await setup(page, { resume: { phase: 2, bookId: 'book-b', bookTitle: BOOK_B } });
await page.evaluate(() => window.navTo('s-caststudio'));
await expect(page.locator('#stu-book-chip')).toContainText(`Book: ${BOOK_B}`, { timeout: 20_000 });
await expect(page.locator('#stu-show-all-chars-btn')).toBeVisible();
});
test('Studio Voices without a picked book offers to pick one instead of listing all books', async ({ page }) => {
await setup(page);
await page.evaluate(() => { window.navTo('s-caststudio'); });
await page.waitForFunction(() => typeof window.showStudioPhase === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.showStudioPhase(3));
await expect(page.locator('#stu-voices-slot .lib-chars-scope-prompt')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('#stu-voices-slot .lib-chars-production')).toHaveCount(0);
await expect(page.locator('#stu-voices-slot #lib-chars-show-all')).toBeVisible();
await page.locator('#stu-voices-slot #lib-chars-pick-book').click();
await expect(page.locator('#stu-phase-1')).toBeVisible();
});