diff --git a/tests/browser/system-integrations-concept.spec.mjs b/tests/browser/system-integrations-concept.spec.mjs index b9c41cdb..30be7323 100644 --- a/tests/browser/system-integrations-concept.spec.mjs +++ b/tests/browser/system-integrations-concept.spec.mjs @@ -270,7 +270,8 @@ function mapImportConcept(reference, plan) { title: 'Review LoRA import', tree: ['h3', 'dl.meta-grid', 'details', 'p.small', 'div.notice', 'div.dialog-actions'], heading: { ...reference.heading, text: 'LoRA reference data' }, - grid: { ...reference.grid, rows: [['Scope', plan.scope.label], ['Found', '2 metadata files · 2 readable'], + grid: { ...reference.grid, rows: [['LoraManager', 'environment settings · alongside storage · library default'], + ['Scope', plan.scope.label], ['Found', '2 metadata files · 2 LoRAs · 0 with local images'], ['Protected', 'Nothing is protected'], ['Network', 'Downloads up to 2 example images from CivitAI'], ['Backup', `Create and verify before changes (${plan.backup.destination_label})`], ['Media on disk', 'No files are removed; image links are replaced']] }, @@ -312,7 +313,7 @@ async function productionReview(page, request, theme) { for (const theme of THEMES) for (const [width, height] of SIZES) { test(`LoRA import review matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => { await page.setViewportSize({ width, height }); - await fakeLoraManager(request, { loras: 2, civitai: true }); + await fakeLoraManager(request, { loras: 2, civitai: true, preview: false }); const concept = await conceptReview(page, theme); expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']); await page.screenshot({ path: testInfo.outputPath(`concept-plan-review-${theme}-${width}.png`) }); @@ -324,15 +325,15 @@ for (const theme of THEMES) for (const [width, height] of SIZES) { test('LoRA import review comparison detects mutations', async ({ page, request }) => { await page.setViewportSize({ width: 1600, height: 1100 }); - await fakeLoraManager(request, { loras: 2, civitai: true }); + await fakeLoraManager(request, { loras: 2, civitai: true, preview: false }); const concept = await conceptReview(page, 'dark'); const { plan } = await productionReview(page, request, 'dark'); const expected = mapImportConcept(concept, plan); const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path); expect(await paths()).toEqual([]); - await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[3].textContent = 'Downloads'; }); - expect(await paths()).toEqual(['grid.rows.3.0']); - await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[3].textContent = 'Network'; }); + await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[4].textContent = 'Downloads'; }); + expect(await paths()).toEqual(['grid.rows.4.0']); + await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[4].textContent = 'Network'; }); const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' }); expect(await paths()).toEqual(expect.arrayContaining(['grid.columnGap'])); await undoGrid(); diff --git a/tests/browser/workspace-system-integrations.spec.mjs b/tests/browser/workspace-system-integrations.spec.mjs index eded17b6..16787aeb 100644 --- a/tests/browser/workspace-system-integrations.spec.mjs +++ b/tests/browser/workspace-system-integrations.spec.mjs @@ -339,13 +339,17 @@ test('Review import shows replacement, blocked items and network effects with th await expect(dialog(page).locator('#modal-body > h3')).toHaveText('LoRA reference data'); expect(previews).toEqual([{}]); expect(await gridOf(page)).toEqual([ + ['LoraManager', 'environment settings · alongside storage · library default'], ['Scope', 'Replace 2 imported prompts with 3 LoRAs'], - ['Found', '3 metadata files · 3 readable'], + ['Found', '3 metadata files · 3 LoRAs · 0 with local images'], ['Protected', 'Nothing is protected'], ['Network', 'Downloads up to 3 example images from CivitAI'], ['Backup', 'Create and verify before changes (browser.db.backup_)'], ['Media on disk', 'No files are removed; image links are replaced'], ]); + // #355: the settings file the server read is the LoraManager row's hover title. + await expect(dialog(page).locator('dl.meta-grid > div').first().locator('dd')) + .toHaveAttribute('title', /lora-manager-settings[\/\\]settings\.json$/); await expect(dialog(page).locator('details > summary')).toHaveText('LoRAs found (3)'); await expect(dialog(page).locator('.notice h3')).toHaveText('Back up the database first (recommended)'); await expect(dialog(page).locator('p.small').last()).toContainText('stay in the image cache'); @@ -382,7 +386,7 @@ test('saved pairs block the import: the review explains it and offers only Close await enable(page); await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click(); // gridOf reads once; poll until the review has rendered its rows. - await expect.poll(async () => (await gridOf(page))[2]).toEqual(['Protected', '1 imported prompt used by saved pairs']); + await expect.poll(async () => (await gridOf(page))[3]).toEqual(['Protected', '1 imported prompt used by saved pairs']); await expect(dialog(page).locator('p.small')).toHaveText( 'Delete or change the saved pairs that use these prompts, then review the import again. Nothing was changed.'); expect(await dialog(page).locator('.dialog-actions button').allTextContents()).toEqual(['Close']); @@ -390,6 +394,22 @@ test('saved pairs block the import: the review explains it and offers only Close await expect(dialog(page)).toHaveCount(0); }); +test('unusable LoraManager settings: the review says why and offers only Close (#355)', async ({ page, request }) => { + await lora(request, { previous: 1, settings: 'invalid' }); + await openIntegrations(page); + await enable(page); + const before = await serverJson(request, '/__test__/integrations/state'); + await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click(); + await expect.poll(async () => (await gridOf(page))[0]) + .toEqual(['LoraManager', 'Not usable: the settings file is not valid JSON']); + await expect(dialog(page).locator('p.small')).toHaveText( + 'LoraManager settings: not usable: the settings file is not valid JSON. Nothing was changed.'); + expect(await dialog(page).locator('.dialog-actions button').allTextContents()).toEqual(['Close']); + await dialog(page).getByRole('button', { name: 'Close', exact: true }).click(); + await expect(dialog(page)).toHaveCount(0); + expect(await serverJson(request, '/__test__/integrations/state')).toEqual(before); +}); + test('a failed import keeps old imports and its job says so', async ({ page, request }) => { await lora(request, { loras: 2, previous: 2 }); await openIntegrations(page); diff --git a/tests/js/workspace-system-integrations.test.js b/tests/js/workspace-system-integrations.test.js index d8eac71f..2cba2605 100644 --- a/tests/js/workspace-system-integrations.test.js +++ b/tests/js/workspace-system-integrations.test.js @@ -108,13 +108,17 @@ test('host compatibility shows the detected renderer as status (D106)', async () test('the import review shows replacement, blocked items and network effects (D107)', async () => { const { importGrid } = await integrations; - const plan = { lora_files: 3, with_metadata: 2, current_imports: 5, blocked_by_pairs: 0, planned_downloads: 4, + const plan = { lora_files: 2221, with_metadata: 615, with_local_media: 476, skipped: 1606, current_imports: 5, + blocked_by_pairs: 0, planned_downloads: 4, + layout: { source: 'portable', settings_file: '~/ComfyUI/custom_nodes/LM/settings.json', error: null, mode: 'centralized', + library: 'comfyui', example_images: true }, scope: { count: 2, label: 'Replace 5 imported prompts with 2 LoRAs' }, protected: { count: 0, reasons: [] }, backup: { policy: 'on', destination_label: 'prompts.db.backup_' }, network: { planned_downloads: 4, api_key: true }, media: { deletes_files: false, label: 'No files are removed; image links are replaced' } }; assert.deepEqual(importGrid(plan), [ + ['LoraManager', 'portable settings · centralized storage · library comfyui'], ['Scope', 'Replace 5 imported prompts with 2 LoRAs'], - ['Found', '3 metadata files · 2 readable'], + ['Found', '2,221 metadata files · 615 LoRAs · 476 with local images'], ['Protected', 'Nothing is protected'], ['Network', 'Downloads up to 4 example images from CivitAI with your saved key'], ['Backup', 'Create and verify before changes (prompts.db.backup_)'], @@ -122,11 +126,34 @@ test('the import review shows replacement, blocked items and network effects (D1 ]); const blocked = importGrid({ ...plan, planned_downloads: 1, network: { api_key: false }, protected: { count: 2, reasons: ['2 imported prompts used by saved pairs'] } }); - assert.deepEqual(blocked[2], ['Protected', '2 imported prompts used by saved pairs']); - assert.deepEqual(blocked[3], ['Network', 'Downloads up to 1 example image from CivitAI']); + assert.deepEqual(blocked[3], ['Protected', '2 imported prompts used by saved pairs']); + assert.deepEqual(blocked[4], ['Network', 'Downloads up to 1 example image from CivitAI']); + // A plan without a layout (no LoraManager row) and with no counts. assert.deepEqual(importGrid({})[0], ['Scope', '0 LoRAs']); + assert.deepEqual(importGrid({})[1], ['Found', '0 metadata files · 0 LoRAs · 0 with local images']); assert.deepEqual(importGrid({})[3], ['Network', 'No downloads']); assert.deepEqual(importGrid({})[5], ['Media on disk', 'No files are removed']); + assert.deepEqual(importGrid({ ...plan, lora_files: 1, with_metadata: 1, with_local_media: 0 })[2], + ['Found', '1 metadata file · 1 LoRA · 0 with local images']); +}); + +test('the LoraManager row names what the import read, and an unusable layout closes the review (#355)', async () => { + const { layoutText, closeNote, LAYOUT_UNUSABLE, BLOCKED_NOTE, NOTHING_TO_IMPORT } = await integrations; + assert.equal(layoutText({ source: 'environment', mode: 'alongside', library: 'default', error: null }), + 'environment settings · alongside storage · library default'); + assert.equal(layoutText({ source: 'user config', mode: 'alongside', library: 'x', error: 'the settings file is not valid JSON' }), + 'Not usable: the settings file is not valid JSON'); + assert.equal(layoutText(null), null); + assert.equal(layoutText('nope'), null); + const error = 'the sidecar folder does not exist'; + assert.equal(closeNote({ token: null, layout: { error } }), + 'LoraManager settings: not usable: the sidecar folder does not exist. Nothing was changed.'); + assert.equal(LAYOUT_UNUSABLE(error), closeNote({ token: null, layout: { error }, blocked_by_pairs: 2 })); + assert.equal(closeNote({ token: null, blocked_by_pairs: 1, layout: { error: null } }), BLOCKED_NOTE); + assert.equal(closeNote({ token: null }), NOTHING_TO_IMPORT); + assert.equal(closeNote({ token: 't', with_metadata: 0, current_imports: 0 }), NOTHING_TO_IMPORT); + assert.equal(closeNote({ token: 't', with_metadata: 1, current_imports: 0 }), null); + assert.equal(closeNote({ token: 't', with_metadata: 0, current_imports: 3 }), null); }); test('a LoRA import job reads in Activity: tiles, outcomes and the kept collection on failure', async () => { diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index 74ff422c..761c2c6e 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -841,6 +841,11 @@ test('openReviewDialog lays out heading, grid, options, sentence, backup box and [['dt', 'dd'], 'Scope', '2 stale image links'], [['dt', 'dd'], 'Media on disk', 'No source files removed']]); review.setValue('Scope', '3 stale image links'); assert.equal(grid.children[0].children[1].textContent, '3 stale image links'); + // #355: a row's hover title (the LoRA review's settings file); an unknown term changes nothing. + review.setTitle('Media on disk', '~/settings.json'); + review.setTitle('Absent', 'x'); + review.setValue('Absent', 'x'); + assert.equal(grid.children[1].children[1].title, '~/settings.json'); const actions = byId(dom, 'modal-body').children.at(-1); assert.deepEqual(actions.children.map(node => [node.textContent, node.className]), [['Cancel', ''], ['Apply reviewed operation', 'ca-primary']]); diff --git a/web/js/workspace/system-integrations.mjs b/web/js/workspace/system-integrations.mjs index 456bbbbe..4b24a1b0 100644 --- a/web/js/workspace/system-integrations.mjs +++ b/web/js/workspace/system-integrations.mjs @@ -16,6 +16,9 @@ * - "LoRA reference data" → "Review import" (D107; the concept's button only simulated a job): the server * reviews the import (`POST /lora/import/preview`, read-only) and the shared review dialog shows what it replaces, * what saved pairs block, what it downloads and the backup box; Start runs the "LoRA reference import" Activity job. + * #355: its first row, "LoraManager", names the settings source, storage mode and library the server read (the + * settings file is the row's hover title); "Found" counts metadata files, LoRAs and LoRAs with local images. When + * LoraManager's settings cannot be used, the plan has no token and the review says why and offers only Close. */ import { createButton } from '../shared/toolkit/core.js'; import { createBridgeClient } from '../shared/toolkit/prompt-bridge.js'; @@ -55,6 +58,9 @@ export const KEY_COPY = Object.freeze({ saved: 'A key is saved.', none: 'No key export const NOTHING_TO_IMPORT = 'There is nothing to import; nothing was changed.'; export const BLOCKED_NOTE = 'Delete or change the saved pairs that use these prompts, then review the import again. Nothing was changed.'; export const CACHE_NOTE = 'Example images downloaded before a failure stay in the image cache.'; +export const LAYOUT_TERM = 'LoraManager'; +/** #355: the close-only note for LoraManager settings the server cannot use. */ +export const LAYOUT_UNUSABLE = reason => `LoraManager settings: not usable: ${reason}. Nothing was changed.`; const number = value => value.toLocaleString('en-US'); @@ -175,17 +181,38 @@ export function keyText(values, changes) { return values.lora_has_key ? KEY_COPY.saved : KEY_COPY.none; } +/** #355: the "LoraManager" row: which settings, storage mode and library the server read; null without a layout. */ +export function layoutText(layout) { + if (!layout || typeof layout !== 'object') return null; + if (layout.error) return `Not usable: ${layout.error}`; + return `${layout.source} settings · ${layout.mode} storage · library ${layout.library}`; +} + +/** The note of a review that offers only Close, or null when the plan can start. */ +export function closeNote(plan) { + if (!plan.token) { + if (plan.layout?.error) return LAYOUT_UNUSABLE(plan.layout.error); + return plan.blocked_by_pairs ? BLOCKED_NOTE : NOTHING_TO_IMPORT; + } + return !plan.with_metadata && !plan.current_imports ? NOTHING_TO_IMPORT : null; +} + /** D107: the import review's rows from the server's plan. */ export function importGrid(plan) { const downloads = Number.isSafeInteger(plan.planned_downloads) ? plan.planned_downloads : 0; const network = downloads ? `Downloads up to ${number(downloads)} example ${noun(downloads, 'image')} from CivitAI${plan.network?.api_key ? ' with your saved key' : ''}` : 'No downloads'; - const found = Number.isSafeInteger(plan.lora_files) ? plan.lora_files : 0; - const readable = Number.isSafeInteger(plan.with_metadata) ? plan.with_metadata : 0; + const count = value => (Number.isSafeInteger(value) ? value : 0); + const found = count(plan.lora_files); + const readable = count(plan.with_metadata); + const local = count(plan.with_local_media); + const layout = layoutText(plan.layout); return [ + ...(layout ? [[LAYOUT_TERM, layout]] : []), ['Scope', plan.scope?.label || `${number(readable)} ${noun(readable, 'LoRA')}`], - ['Found', `${number(found)} metadata ${noun(found, 'file')} · ${number(readable)} readable`], + ['Found', `${number(found)} metadata ${noun(found, 'file')} · ${number(readable)} ${noun(readable, 'LoRA')} · ` + + `${number(local)} with local images`], ['Protected', protectedText(plan.protected)], ['Network', network], ['Backup', backupText(plan.backup)], @@ -205,11 +232,18 @@ function namesList(plan) { return [list]; } +/** The settings file (home shown as `~`) as the LoraManager row's hover title. */ +function settingsTitle(dd, plan) { + if (typeof plan.layout?.settings_file === 'string') dd.title = plan.layout.settings_file; +} + function closeOnly({ scope, trigger, plan, note }) { const grid = el('dl', undefined, 'meta-grid'); for (const [term, value] of importGrid(plan)) { const entry = el('div'); - entry.append(el('dt', term), el('dd', value)); + const dd = el('dd', value); + if (term === LAYOUT_TERM) settingsTitle(dd, plan); + entry.append(el('dt', term), dd); grid.append(entry); } const close = createButton('Close'); @@ -223,21 +257,19 @@ function closeOnly({ scope, trigger, plan, note }) { /** Show the reviewed import; Start applies exactly this token (D107). */ export function showImportPlan(plan, { api, store, scope, trigger = null, reviewAgain, notify = showWorkspaceToast, navigate = navigateTo, backup = () => downloadDatabaseBackup() }) { - if (!plan.token) { - closeOnly({ scope, trigger, plan, note: plan.blocked_by_pairs ? BLOCKED_NOTE : NOTHING_TO_IMPORT }); - return; - } - if (!plan.with_metadata && !plan.current_imports) { - closeOnly({ scope, trigger, plan, note: NOTHING_TO_IMPORT }); + const note = closeNote(plan); + if (note) { + closeOnly({ scope, trigger, plan, note }); return; } const removesAll = !plan.with_metadata; const sentence = [planNote(plan), removesAll ? 'No LoRA metadata was found: this removes every imported LoRA prompt.' : '', CACHE_NOTE] .filter(Boolean).join(' '); - openReviewDialog({ scope, trigger, title: IMPORT_TITLE, heading: 'LoRA reference data', grid: importGrid(plan), + const review = openReviewDialog({ scope, trigger, title: IMPORT_TITLE, heading: 'LoRA reference data', grid: importGrid(plan), options: namesList(plan), sentence, backup: { copy: BACKUP_COPY, onBackup: backup }, cancelLabel: 'Cancel', primary: { label: IMPORT_START, danger: removesAll }, onStart: () => applyPlan({ api, store, scope, token: plan.token, reviewAgain, notify, navigate, url: IMPORT_APPLY_URL }) }); + if (typeof plan.layout?.settings_file === 'string') review.setTitle(LAYOUT_TERM, plan.layout.settings_file); } /** Review the import (read-only) and show its plan; the pressed button reads "Reviewing…" until it answered. */ diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 610fda41..f6a4af05 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -429,7 +429,7 @@ function requireGrid(grid) { * primary (`ca-primary`, or the concept's `danger` when `primary.danger`). The primary closes the dialog, then calls * `onStart()`; `onDismiss()` runs once when the dialog closes any other way (Cancel, ×, Escape, a replacing dialog, the * page leaving), so a staged review is discarded (S9). Returns `{setValue(term, text)}` for a grid value that follows - * an option. + * an option, and `{setTitle(term, text)}` for a value's hover title. */ export function openReviewDialog({ scope, title, heading = null, grid, options = [], sentence = null, backup = null, cancelLabel, primary, onStart, trigger = null, onDismiss = null }) { @@ -475,6 +475,10 @@ export function openReviewDialog({ scope, title, heading = null, grid, options = const dd = values.get(term); if (dd) dd.textContent = text; }, + setTitle(term, text) { + const dd = values.get(term); + if (dd) dd.title = text; + }, }; }