414 lines
27 KiB
JavaScript
414 lines
27 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
|
|
});
|