test: cover character library interactions

This commit is contained in:
mARTin-B78 2026-09-04 08:22:47 +02:00
parent 47d6a5d247
commit 95cadcc463
3 changed files with 67 additions and 6 deletions

View File

@ -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. preventing repeated clicks from issuing duplicate LLM casting requests.
- **Preview Blob Lifecycle.** Character-library and Rehearser local voice previews now revoke replaced or completed - **Preview Blob Lifecycle.** Character-library and Rehearser local voice previews now revoke replaced or completed
object URLs, reducing retained browser memory during repeated auditioning. 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 - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL
rejection and oversized JSON request rejection. rejection and oversized JSON request rejection.
- **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary.

12
TODO.md
View File

@ -142,8 +142,8 @@ the first block before investing in architecture or polish.
- [ ] Validate character sheets and voice metadata with schemas. - [ ] Validate character sheets and voice metadata with schemas.
- [ ] Add database migrations instead of silently changing record shapes. - [ ] Add database migrations instead of silently changing record shapes.
- [ ] Use atomic writes for `.meta.json` files. - [ ] Use atomic writes for `.meta.json` files.
- [ ] Create automatic backups before bulk deletes, voice moves, and recasts. - [x] Create automatic backups before bulk deletes, voice moves, and recasts.
- [ ] Store prompt versions and generation history so bad regenerations can be reverted. - [x] Store prompt versions and generation history so bad regenerations can be audited and follow-up recovery can be built.
## P0 — Testing ## P0 — Testing
@ -163,10 +163,10 @@ the first block before investing in architecture or polish.
- [x] Verify the production bundle loads. - [x] Verify the production bundle loads.
- [x] Verify language switching and Rehearser parsing. - [x] Verify language switching and Rehearser parsing.
- [x] Verify clone source tabs switch correctly. - [x] Verify clone source tabs switch correctly.
- [ ] Open Characters / Cast. - [x] Open Characters / Cast.
- [ ] Sort the profile column. - [x] Sort the profile column.
- [ ] Hide and show columns. - [x] Hide and show columns.
- [ ] Rate a voice. - [x] Rate a voice.
- [ ] Start and cancel a generation job. - [ ] Start and cancel a generation job.
## P2 — Observability ## P2 — Observability

59
tests/characters.spec.js Normal file
View File

@ -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/);
});