The job checks the portrait crop, then the turnaround, story panel and same-person against the sheet, re-renders on failure (up to 3 attempts) and keeps the attempt with the fewest faults. ReActor transfers the profile's face onto the turnaround and the largest face in the story panel. If the checker is down, retries stop and the pair is marked unverified. The outcome is saved with the pair and shown on the character page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
445 lines
29 KiB
JavaScript
445 lines
29 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 }) => {
|
|
// Switching the form saves the record, so stand in for the API instead of writing to the real library.
|
|
await mockRecord(page, { id: 'c-forms', book: 'Smoke Book', name: 'Zerwas', tags: '', sheet: {} });
|
|
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);
|
|
});
|
|
|
|
|
|
// ── Visual persistence (base appearance and forms) ───────────────────────────
|
|
// A stateful stand-in for /api/characters/<id> so these tests can assert what was
|
|
// actually written back, not just what the page shows.
|
|
async function mockRecord(page, initial) {
|
|
const state = { rec: JSON.parse(JSON.stringify(initial)), puts: [] };
|
|
await page.route(`**/api/characters/${encodeURIComponent(initial.id)}`, route => {
|
|
if (route.request().method() === 'PUT') {
|
|
state.rec = route.request().postDataJSON();
|
|
state.puts.push(state.rec);
|
|
return route.fulfill({ json: state.rec });
|
|
}
|
|
return route.fulfill({ json: state.rec });
|
|
});
|
|
await page.route('**/api/characters', route => route.fulfill({ json: { characters: [state.rec] } }));
|
|
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: {} } }));
|
|
return state;
|
|
}
|
|
|
|
async function openLibrary(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._charAutoGenerateImage === 'function' && typeof window._libSetSheetPrompt === 'function', null, { timeout: 15_000 });
|
|
}
|
|
|
|
const PNG_A = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
|
|
|
test('a matched image pair for the base appearance is fully persisted', async ({ page }) => {
|
|
const state = await mockRecord(page, { id: 'c-base', book: 'Smoke Book', name: 'Base', tags: '', sheet: { physical: 'tall', image_prompt: 'p', concept_art_prompt: 'r' } });
|
|
await page.route('**/api/character-generate-consistent-images', route => route.fulfill({ json: { job_id: 'job-ok' } }));
|
|
await page.route('**/api/character-generate-consistent-images/status/job-ok', route => route.fulfill({ json: {
|
|
stage: 'done', detail: 'Done', elapsed: 1, stale_seconds: 0, done: true, error: null,
|
|
result: { profile: PNG_A, reference: PNG_A + 'AA', prompt_version: 'v3', identity_fingerprint: 'abc123', profile_asset_digest: 'pd', reference_asset_digest: 'rd', identity_consistent: true },
|
|
} }));
|
|
await openLibrary(page);
|
|
await page.evaluate(() => window._charAutoGenerateConsistentImages({ id: 'c-base', book: 'Smoke Book', name: 'Base', sheet: { physical: 'tall', image_prompt: 'p', concept_art_prompt: 'r' } }, 'cinematic'));
|
|
const saved = state.puts[state.puts.length - 1];
|
|
expect(saved.image).toBe(PNG_A);
|
|
expect(saved.sheet.concept_art_image).toBe(PNG_A + 'AA');
|
|
expect(saved.sheet.consistent_identity_fingerprint).toBe('abc123');
|
|
expect(saved.sheet.consistent_prompt_version).toBe('v3');
|
|
expect(saved.sheet.profile_asset_digest).toBe('pd');
|
|
expect(saved.sheet.reference_asset_digest).toBe('rd');
|
|
expect(saved.sheet.identity_consistent).toBe(true);
|
|
expect(saved.concept_art_image).toBeUndefined();
|
|
});
|
|
|
|
test('regenerating only the avatar uses the deterministic prompt and flags an existing pair', async ({ page }) => {
|
|
const state = await mockRecord(page, { id: 'c-av', book: 'Smoke Book', name: 'Avatar', tags: '', image: PNG_A,
|
|
sheet: { physical: 'a tall woman with copper hair and green eyes', archetype: 'cartographer', image_prompt: 'LLM-AUTHORED', concept_art_image: PNG_A + 'AA', identity_consistent: true } });
|
|
let promptSent = '';
|
|
await page.route('**/api/character-generate-prompts', route => route.fulfill({ json: { image_prompt: 'DETERMINISTIC-PROMPT' } }));
|
|
await page.route('**/api/character-generate-image', route => { promptSent = route.request().postDataJSON().prompt; return route.fulfill({ json: { image: PNG_A + 'BB' } }); });
|
|
await openLibrary(page);
|
|
await page.evaluate(() => window._charAutoGenerateImage(JSON.parse(JSON.stringify({ id: 'c-av', book: 'Smoke Book', name: 'Avatar',
|
|
sheet: { physical: 'a tall woman with copper hair and green eyes', archetype: 'cartographer', image_prompt: 'LLM-AUTHORED', concept_art_image: 'x' } }))));
|
|
expect(promptSent).toBe('DETERMINISTIC-PROMPT');
|
|
const saved = state.puts[state.puts.length - 1];
|
|
expect(saved.image).toBe(PNG_A + 'BB');
|
|
expect(saved.sheet.identity_consistent).toBe(false);
|
|
});
|
|
|
|
test('a hand-edited portrait prompt is still honoured for avatar-only generation', async ({ page }) => {
|
|
await mockRecord(page, { id: 'c-ed', book: 'Smoke Book', name: 'Edited', tags: '', sheet: { physical: 'a tall woman with copper hair', image_prompt: 'MY-OWN-PROMPT', image_prompt_user_edited: true } });
|
|
let promptSent = '';
|
|
let generatedPrompts = 0;
|
|
await page.route('**/api/character-generate-prompts', route => { generatedPrompts += 1; return route.fulfill({ json: { image_prompt: 'DETERMINISTIC-PROMPT' } }); });
|
|
await page.route('**/api/character-generate-image', route => { promptSent = route.request().postDataJSON().prompt; return route.fulfill({ json: { image: PNG_A } }); });
|
|
await openLibrary(page);
|
|
await page.evaluate(() => window._charAutoGenerateImage({ id: 'c-ed', book: 'Smoke Book', name: 'Edited', sheet: { physical: 'a tall woman with copper hair', image_prompt: 'MY-OWN-PROMPT', image_prompt_user_edited: true } }));
|
|
expect(promptSent).toBe('MY-OWN-PROMPT');
|
|
expect(generatedPrompts).toBe(0);
|
|
});
|
|
|
|
test('appearance prompts are written to the active form, character-wide prompts to the base', async ({ page }) => {
|
|
await mockRecord(page, { id: 'c-form', book: 'b', name: 'Form', tags: '', sheet: {} });
|
|
await openLibrary(page);
|
|
const out = await page.evaluate(() => {
|
|
const rec = { id: 'c-form', sheet: { image_prompt: 'base-img', voice_design_prompt: 'base-voice', active_form_id: 'wolf',
|
|
forms: [{ id: 'wolf', name: 'Wolf', image_prompt: 'old-wolf' }, { id: 'bat', name: 'Bat', image_prompt: 'bat-img' }] } };
|
|
window._libSetSheetPrompt(rec, 'image_prompt', 'new-wolf', false);
|
|
window._libSetSheetPrompt(rec, 'voice_design_prompt', 'new-voice', true);
|
|
return rec.sheet;
|
|
});
|
|
expect(out.image_prompt).toBe('base-img');
|
|
expect(out.forms.find(f => f.id === 'wolf').image_prompt).toBe('new-wolf');
|
|
expect(out.forms.find(f => f.id === 'bat').image_prompt).toBe('bat-img');
|
|
expect(out.voice_design_prompt).toBe('new-voice');
|
|
expect(out.voice_design_prompt_user_edited).toBe(true);
|
|
});
|
|
|
|
// ── Appearance forms: management ─────────────────────────────────────────────
|
|
test('form helpers add, rename, delete and judge image pairs', async ({ page }) => {
|
|
await mockRecord(page, { id: 'c-h', book: 'b', name: 'H', tags: '', sheet: {} });
|
|
await openLibrary(page);
|
|
const r = await page.evaluate(() => {
|
|
const out = {};
|
|
const sheet = { physical: 'human', clothing: 'coat', forms: [{ id: 'wolf', name: 'Wolf', physical: 'fur' }] };
|
|
const added = window._libAddForm(sheet, ' Bat form ');
|
|
out.added = { name: added.forms[1].name, id: added.forms[1].id, physical: added.forms[1].physical, clothing: added.forms[1].clothing, active: added.active_form_id, base: sheet.forms.length };
|
|
out.errors = [];
|
|
for (const bad of ['', ' ', 'wolf', 'x'.repeat(161)]) { try { window._libAddForm(sheet, bad); out.errors.push('no error for ' + JSON.stringify(bad.slice(0, 5))); } catch (e) { out.errors.push(e.message); } }
|
|
let full = { forms: [] };
|
|
for (let i = 0; i < 8; i++) full = window._libAddForm(full, 'form ' + i);
|
|
try { window._libAddForm(full, 'one too many'); out.errors.push('no error at 9'); } catch (e) { out.errors.push(e.message); }
|
|
out.ids = new Set(full.forms.map(f => f.id)).size;
|
|
out.renamed = window._libRenameForm(sheet, 'wolf', 'Werewolf').forms[0].name;
|
|
try { window._libRenameForm(added, 'wolf', 'bat form'); out.errors.push('no error on duplicate rename'); } catch (e) { out.errors.push(e.message); }
|
|
const deleted = window._libDeleteForm({ ...added, active_form_id: 'wolf' }, 'wolf');
|
|
out.deleted = { n: deleted.forms.length, active: deleted.active_form_id };
|
|
const keepActive = window._libDeleteForm(added, 'wolf');
|
|
out.keepActive = keepActive.active_form_id === added.active_form_id;
|
|
// pair state
|
|
const base = { physical: 'human' };
|
|
const fp = window._libIdentityFingerprint({ ...base, physical: 'fur' });
|
|
const okForm = { id: 'f', physical: 'fur', image: 'i', concept_art_image: 'c', consistent_prompt_version: 'v3', consistent_identity_fingerprint: fp, identity_consistent: true };
|
|
out.state = {
|
|
none: window._libFormPairState(base, { id: 'f', physical: 'fur' }),
|
|
ok: window._libFormPairState(base, okForm),
|
|
stale: window._libFormPairState(base, { ...okForm, consistent_identity_fingerprint: 'old' }),
|
|
broken: window._libFormPairState(base, { ...okForm, identity_consistent: false }),
|
|
legacy: window._libFormPairState(base, { ...okForm, consistent_prompt_version: 'v2' }),
|
|
};
|
|
return out;
|
|
});
|
|
expect(r.added).toEqual({ name: 'Bat form', id: 'bat-form', physical: 'human', clothing: 'coat', active: 'bat-form', base: 1 });
|
|
expect(r.errors).toEqual(['Give the form a name.', 'Give the form a name.', 'Another form already uses that name.', 'The form name is too long (max 160 characters).', 'A character can have at most 8 forms.', 'Another form already uses that name.']);
|
|
expect(r.ids).toBe(8);
|
|
expect(r.renamed).toBe('Werewolf');
|
|
expect(r.deleted).toEqual({ n: 1, active: '' });
|
|
expect(r.keepActive).toBe(true);
|
|
expect(r.state).toEqual({ none: 'none', ok: 'ok', stale: 'review', broken: 'review', legacy: 'review' });
|
|
});
|
|
|
|
test('editing Physical text while a form is active changes only that form', async ({ page }) => {
|
|
await mockRecord(page, { id: 'c-f', book: 'b', name: 'F', tags: '', sheet: {} });
|
|
await openLibrary(page);
|
|
const r = await page.evaluate(() => {
|
|
const rec = { id: 'c-f', sheet: { physical: 'human', active_form_id: 'wolf', forms: [{ id: 'wolf', name: 'Wolf', physical: 'fur' }, { id: 'bat', name: 'Bat', physical: 'wings' }] } };
|
|
window._libSetFormField(rec, 'physical', 'grey fur, yellow eyes');
|
|
const withForm = JSON.parse(JSON.stringify(rec.sheet));
|
|
const base = { id: 'c-f', sheet: { physical: 'human', forms: [{ id: 'wolf', name: 'Wolf' }] } };
|
|
window._libSetFormField(base, 'physical', 'tall');
|
|
return { withForm, base: base.sheet };
|
|
});
|
|
expect(r.withForm.physical).toBe('human');
|
|
expect(r.withForm.forms.find(f => f.id === 'wolf').physical).toBe('grey fur, yellow eyes');
|
|
expect(r.withForm.forms.find(f => f.id === 'bat').physical).toBe('wings');
|
|
expect(r.base.physical).toBe('tall');
|
|
});
|
|
|
|
test('generating images for several forms rebuilds each form\'s prompts, saves each on its own form and restores the active appearance', async ({ page }) => {
|
|
const state = await mockRecord(page, { id: 'c-g', book: 'Smoke Book', name: 'Gen', tags: '', sheet: {
|
|
physical: 'human', active_form_id: '', forms: [{ id: 'wolf', name: 'Wolf', physical: 'fur' }, { id: 'bat', name: 'Bat', physical: 'wings', concept_art_prompt: 'HAND-MADE', concept_art_prompt_user_edited: true }] } });
|
|
const consistentBodies = [];
|
|
let job = 0;
|
|
await page.route('**/api/character-generate-prompts', route => {
|
|
const b = route.request().postDataJSON();
|
|
return route.fulfill({ json: { image_prompt: 'PORTRAIT[' + b.sheet.active_form_id + ']', concept_art_prompt: 'SHEET[' + b.sheet.active_form_id + ']' } });
|
|
});
|
|
await page.route('**/api/character-generate-consistent-images', route => {
|
|
consistentBodies.push(route.request().postDataJSON());
|
|
job += 1;
|
|
return route.fulfill({ json: { job_id: 'job-' + job } });
|
|
});
|
|
await page.route('**/api/character-generate-consistent-images/status/*', route => {
|
|
const id = route.request().url().split('/').pop();
|
|
return route.fulfill({ json: { stage: 'done', detail: 'Done', elapsed: 1, stale_seconds: 0, done: true, error: null,
|
|
result: { profile: PNG_A + id, reference: PNG_A + id + 'R', prompt_version: 'v3', identity_fingerprint: 'fp-' + id, profile_asset_digest: 'p', reference_asset_digest: 'r', identity_consistent: true } } });
|
|
});
|
|
await openLibrary(page);
|
|
const progress = [];
|
|
await page.exposeFunction('__progress', (n, t, name) => progress.push(n + '/' + t + ' ' + name));
|
|
await page.evaluate(async () => {
|
|
const rec = await (await fetch('/api/characters/c-g')).json();
|
|
await window._libGenerateFormImages(rec, ['wolf', 'bat'], 'cinematic', (n, t, name) => window.__progress(n, t, name));
|
|
});
|
|
expect(progress).toEqual(['1/2 Wolf', '2/2 Bat']);
|
|
expect(consistentBodies.map(b => b.profile_prompt)).toEqual(['PORTRAIT[wolf]', 'PORTRAIT[bat]']);
|
|
// the hand-made reference prompt of the second form is kept, the first is rebuilt
|
|
expect(consistentBodies.map(b => b.reference_prompt)).toEqual(['SHEET[wolf]', 'HAND-MADE']);
|
|
const forms = state.rec.sheet.forms;
|
|
expect(forms.find(f => f.id === 'wolf').image).toBe(PNG_A + 'job-1');
|
|
expect(forms.find(f => f.id === 'bat').image).toBe(PNG_A + 'job-2');
|
|
expect(forms.find(f => f.id === 'wolf').consistent_identity_fingerprint).toBe('fp-job-1');
|
|
expect(state.rec.sheet.active_form_id).toBe('');
|
|
expect(state.rec.image).toBeFalsy(); // the base look was not touched
|
|
});
|
|
|
|
test('the forms panel adds, renames and deletes a form and saves each change', async ({ page }) => {
|
|
const state = await mockRecord(page, { id: 'c-ui', book: 'Smoke Book', name: 'Zerwas', tags: '', voice: 'de_bob', image: '/human.png', sheet: {
|
|
physical: 'human with dark hair', clothing: 'black coat',
|
|
forms: [{ id: 'wolf', name: 'Wolf form', physical: 'large black wolf', image: '/wolf.png', concept_art_image: '/wolf-sheet.png' }] } });
|
|
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' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
|
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
|
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
|
|
await page.waitForSelector('.lib-char-card[data-char-id="c-ui"]', { timeout: 15_000 });
|
|
await page.evaluate(async () => {
|
|
const rec = await (await fetch('/api/characters/c-ui')).json();
|
|
window._charDetailPage(rec, [], { container: document.querySelector('#lib-chars-list') });
|
|
});
|
|
const panel = page.locator('.lcd-forms-manage');
|
|
await expect(panel).toContainText('Appearance forms (1)');
|
|
// A form with images made before fingerprints existed is flagged for review.
|
|
await expect(panel.locator('li[data-form-id="wolf"] .lcd-form-flag')).toContainText('review');
|
|
|
|
// add
|
|
await panel.locator('.lcd-form-new-name').fill('Bat form');
|
|
await panel.locator('.lcd-form-add-btn').click();
|
|
await expect(page.locator('.lcd-forms-manage')).toContainText('Appearance forms (2)');
|
|
let saved = state.puts[state.puts.length - 1].sheet;
|
|
expect(saved.forms.map(f => f.name)).toEqual(['Wolf form', 'Bat form']);
|
|
expect(saved.active_form_id).toBe('bat-form');
|
|
expect(saved.forms[1].physical).toBe('human with dark hair'); // starts as a copy of the base look
|
|
expect(saved.physical).toBe('human with dark hair'); // base untouched
|
|
|
|
// adding a duplicate name is refused with a message and saves nothing
|
|
const putsBefore = state.puts.length;
|
|
await page.locator('.lcd-forms-manage .lcd-form-new-name').fill('wolf FORM');
|
|
await page.locator('.lcd-forms-manage .lcd-form-add-btn').click();
|
|
await expect(page.locator('#toast')).toContainText('already uses that name');
|
|
expect(state.puts.length).toBe(putsBefore);
|
|
|
|
// rename
|
|
const wolfName = page.locator('.lcd-forms-manage li[data-form-id="wolf"] .lcd-form-name');
|
|
await wolfName.fill('Werewolf');
|
|
await wolfName.blur();
|
|
await expect(page.locator('.lcd-forms-manage li[data-form-id="wolf"] .lcd-form-name')).toHaveValue('Werewolf');
|
|
expect(state.puts[state.puts.length - 1].sheet.forms[0].name).toBe('Werewolf');
|
|
|
|
// delete (confirm dialog)
|
|
await page.locator('.lcd-forms-manage li[data-form-id="bat-form"] .lcd-form-del').click();
|
|
await page.locator('#cd-ok').click();
|
|
await expect(page.locator('.lcd-forms-manage')).toContainText('Appearance forms (1)');
|
|
saved = state.puts[state.puts.length - 1].sheet;
|
|
expect(saved.forms.map(f => f.id)).toEqual(['wolf']);
|
|
expect(saved.active_form_id).toBe('');
|
|
expect(state.rec.voice).toBe('de_bob'); // the character and its voice are untouched
|
|
});
|
|
|
|
test('the automatic image check result is saved with the pair', async ({ page }) => {
|
|
const state = await mockRecord(page, { id: 'c-check', book: 'Smoke Book', name: 'Checked', tags: '', sheet: { physical: 'tall', image_prompt: 'p', concept_art_prompt: 'r' } });
|
|
const imageCheck = { verified: true, passed: false, attempts: 3, failures: ['turnaround: feet cut off'], checks: {} };
|
|
await page.route('**/api/character-generate-consistent-images', route => route.fulfill({ json: { job_id: 'job-check' } }));
|
|
await page.route('**/api/character-generate-consistent-images/status/job-check', route => route.fulfill({ json: {
|
|
stage: 'done', detail: 'Done', elapsed: 1, stale_seconds: 0, done: true, error: null,
|
|
result: { profile: PNG_A, reference: PNG_A + 'AA', prompt_version: 'v3', identity_fingerprint: 'abc', identity_consistent: true, image_check: imageCheck },
|
|
} }));
|
|
await openLibrary(page);
|
|
await page.evaluate(() => window._charAutoGenerateConsistentImages({ id: 'c-check', book: 'Smoke Book', name: 'Checked', sheet: { physical: 'tall', image_prompt: 'p', concept_art_prompt: 'r' } }, 'cinematic'));
|
|
expect(state.puts[state.puts.length - 1].sheet.image_check).toEqual(imageCheck);
|
|
});
|
|
|
|
test('the image check note tells passed, failed and unverified apart', async ({ page }) => {
|
|
await mockRecord(page, { id: 'c-note', book: 'Smoke Book', name: 'Note', tags: '', sheet: {} });
|
|
await openLibrary(page);
|
|
const notes = await page.evaluate(() => ({
|
|
none: window._libImageCheckNote(null),
|
|
passed: window._libImageCheckNote({ verified: true, passed: true, attempts: 2, failures: [] }),
|
|
failed: window._libImageCheckNote({ verified: true, passed: false, attempts: 3, failures: ['scene: gender: shows male, sheet says female', '<b>x</b>'] }),
|
|
unverified: window._libImageCheckNote({ verified: false, passed: false, attempts: 1, failures: ['portrait: checker unavailable'] }),
|
|
}));
|
|
expect(notes.none).toBe('');
|
|
expect(notes.passed).toContain('keine Fehler');
|
|
expect(notes.passed).toContain('2 Versuche');
|
|
expect(notes.failed).toContain('role="alert"');
|
|
expect(notes.failed).toContain('scene: gender: shows male, sheet says female');
|
|
expect(notes.failed).not.toContain('<b>x</b>'); // failure text is escaped
|
|
expect(notes.unverified).toContain('ungeprüft');
|
|
});
|