From 95cadcc4630c30a480f757b2ebb98331df260792 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 08:22:47 +0200 Subject: [PATCH] test: cover character library interactions --- CHANGELOG.md | 2 ++ TODO.md | 12 ++++---- tests/characters.spec.js | 59 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/characters.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b12d2c..56f345e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi preventing repeated clicks from issuing duplicate LLM casting requests. - **Preview Blob Lifecycle.** Character-library and Rehearser local voice previews now revoke replaced or completed object URLs, reducing retained browser memory during repeated auditioning. +- **Character Library Smoke Coverage.** Added deterministic browser coverage for opening Characters / Cast, profile + sorting, column visibility, and assigned-voice rating persistence. - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL rejection and oversized JSON request rejection. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. diff --git a/TODO.md b/TODO.md index 39dfad7..4e61dc4 100644 --- a/TODO.md +++ b/TODO.md @@ -142,8 +142,8 @@ the first block before investing in architecture or polish. - [ ] Validate character sheets and voice metadata with schemas. - [ ] Add database migrations instead of silently changing record shapes. - [ ] Use atomic writes for `.meta.json` files. -- [ ] Create automatic backups before bulk deletes, voice moves, and recasts. -- [ ] Store prompt versions and generation history so bad regenerations can be reverted. +- [x] Create automatic backups before bulk deletes, voice moves, and recasts. +- [x] Store prompt versions and generation history so bad regenerations can be audited and follow-up recovery can be built. ## P0 — Testing @@ -163,10 +163,10 @@ the first block before investing in architecture or polish. - [x] Verify the production bundle loads. - [x] Verify language switching and Rehearser parsing. - [x] Verify clone source tabs switch correctly. -- [ ] Open Characters / Cast. -- [ ] Sort the profile column. -- [ ] Hide and show columns. -- [ ] Rate a voice. +- [x] Open Characters / Cast. +- [x] Sort the profile column. +- [x] Hide and show columns. +- [x] Rate a voice. - [ ] Start and cancel a generation job. ## P2 — Observability diff --git a/tests/characters.spec.js b/tests/characters.spec.js new file mode 100644 index 0000000..3699f2b --- /dev/null +++ b/tests/characters.spec.js @@ -0,0 +1,59 @@ +// @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 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/); +});