// @ts-check // Global cast view (sidebar) vs. per-book characters inside Studio. const { test, expect } = require('@playwright/test'); 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/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(); });