mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
4963bf2b2e
Replace the post-run toast cascade and the standalone L4 results modal with a summary modal modeled on the batch download summary: 3-state header, stat cards (matched / needs review / unresolved / errors), an L4 review table with per-entry undo, and a copyable report. Wired into the global, bulk and single-recipe rematch entries; complete no-op runs keep the lightweight toast. Obsolete results-modal code, styles and i18n keys are removed.
234 lines
9.7 KiB
JavaScript
234 lines
9.7 KiB
JavaScript
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
|
|
const showToastMock = vi.fn();
|
|
const translateMock = vi.fn((key, params, fallback) => {
|
|
if (typeof fallback === 'string') {
|
|
// Apply {param} interpolation so counts remain assertable.
|
|
return Object.entries(params || {}).reduce(
|
|
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
|
|
fallback
|
|
);
|
|
}
|
|
return key;
|
|
});
|
|
|
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
|
translate: translateMock,
|
|
}));
|
|
|
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
|
showToast: showToastMock,
|
|
}));
|
|
|
|
async function getShowRematchSummary() {
|
|
const { showRematchSummary } = await import(
|
|
'../../../static/js/components/RematchSummaryModal.js'
|
|
);
|
|
return showRematchSummary;
|
|
}
|
|
|
|
const L4_LORA = { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 };
|
|
const L4_CHECKPOINT = { recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' };
|
|
|
|
describe('RematchSummaryModal', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
document.body.innerHTML = '';
|
|
global.fetch = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
delete global.fetch;
|
|
delete navigator.clipboard;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('renders a success header when everything matched cleanly', async () => {
|
|
const showRematchSummary = await getShowRematchSummary();
|
|
showRematchSummary({ scope: 'global', total: 10, matchedRecipes: 2, matchedEntries: 3 });
|
|
|
|
const modal = document.getElementById('rematchSummaryModal');
|
|
expect(modal).not.toBeNull();
|
|
expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true);
|
|
expect(modal.querySelector('.summary-title').textContent).toBe('Matched 3 entries');
|
|
expect(modal.querySelector('.failure-table')).toBeNull();
|
|
});
|
|
|
|
it('renders an error header when nothing matched and errors occurred', async () => {
|
|
const showRematchSummary = await getShowRematchSummary();
|
|
showRematchSummary({ scope: 'global', total: 3, errors: 3 });
|
|
|
|
const modal = document.getElementById('rematchSummaryModal');
|
|
expect(modal.querySelector('.summary-header').classList.contains('error')).toBe(true);
|
|
expect(modal.querySelector('.summary-title').textContent).toBe('Rematch failed');
|
|
});
|
|
|
|
it('renders a warning header for unresolved entries, L4 matches, or cancellations', async () => {
|
|
const showRematchSummary = await getShowRematchSummary();
|
|
|
|
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 1, unresolvedEntries: 1, unresolvedRecipes: 1 });
|
|
expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true);
|
|
|
|
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA] });
|
|
expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true);
|
|
|
|
showRematchSummary({ scope: 'global', total: 5, matchedEntries: 2, cancelled: true });
|
|
const modal = document.getElementById('rematchSummaryModal');
|
|
expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
|
expect(modal.querySelector('.rematch-cancelled-note')).not.toBeNull();
|
|
});
|
|
|
|
it('renders the four stat cards in order', async () => {
|
|
const showRematchSummary = await getShowRematchSummary();
|
|
showRematchSummary({
|
|
scope: 'bulk',
|
|
total: 4,
|
|
matchedRecipes: 1,
|
|
matchedEntries: 2,
|
|
unresolvedEntries: 3,
|
|
errors: 1,
|
|
l4Matches: [L4_LORA],
|
|
});
|
|
|
|
const modal = document.getElementById('rematchSummaryModal');
|
|
const values = Array.from(modal.querySelectorAll('.stat-card-value')).map(el => el.textContent);
|
|
expect(values).toEqual(['2', '1', '3', '1']);
|
|
const labels = Array.from(modal.querySelectorAll('.stat-card-label')).map(el => el.textContent);
|
|
expect(labels).toEqual(['Matched entries', 'Needs review', 'Unresolved', 'Errors']);
|
|
});
|
|
|
|
it('renders the L4 review table only when matches exist', async () => {
|
|
const showRematchSummary = await getShowRematchSummary();
|
|
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA, L4_CHECKPOINT] });
|
|
|
|
const modal = document.getElementById('rematchSummaryModal');
|
|
const rows = modal.querySelectorAll('.failure-table tbody tr');
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows[0].textContent).toContain('r1');
|
|
expect(rows[0].textContent).toContain('old.safetensors');
|
|
expect(rows[0].textContent).toContain('new.safetensors');
|
|
expect(rows[1].textContent).toContain('cp-new.safetensors');
|
|
expect(modal.querySelectorAll('.rematch-undo-btn')).toHaveLength(2);
|
|
});
|
|
|
|
it('undo posts to the lora restore endpoint, then strikes and disables the row', async () => {
|
|
const showRematchSummary = await getShowRematchSummary();
|
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
|
|
|
|
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] });
|
|
|
|
const modal = document.getElementById('rematchSummaryModal');
|
|
const row = modal.querySelector('tr[data-l4-index="0"]');
|
|
const button = row.querySelector('.rematch-undo-btn');
|
|
button.click();
|
|
await vi.waitFor(() => expect(button.disabled).toBe(true));
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/lora/restore', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ recipe_id: 'r1', lora_index: 2 }),
|
|
});
|
|
expect(row.classList.contains('undone')).toBe(true);
|
|
expect(button.textContent).toBe('Undone');
|
|
});
|
|
|
|
it('undo posts to the checkpoint restore endpoint with recipe_id only', async () => {
|
|
const showRematchSummary = await getShowRematchSummary();
|
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
|
|
|
|
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_CHECKPOINT] });
|
|
|
|
const button = document.querySelector('.rematch-undo-btn');
|
|
button.click();
|
|
await vi.waitFor(() => expect(button.disabled).toBe(true));
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/checkpoint/restore', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ recipe_id: 'r2' }),
|
|
});
|
|
});
|
|
|
|
it('keeps the row actionable and toasts when undo fails', async () => {
|
|
const showRematchSummary = await getShowRematchSummary();
|
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: false, error: 'no snapshot' }) });
|
|
|
|
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] });
|
|
|
|
const row = document.querySelector('tr[data-l4-index="0"]');
|
|
const button = row.querySelector('.rematch-undo-btn');
|
|
button.click();
|
|
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
|
|
|
|
expect(button.disabled).toBe(false);
|
|
expect(row.classList.contains('undone')).toBe(false);
|
|
expect(showToastMock).toHaveBeenCalledWith(
|
|
'modals.rematchResults.undoFailed',
|
|
{ message: 'no snapshot' },
|
|
'error'
|
|
);
|
|
});
|
|
|
|
it('copy report includes scope, counts and the L4 list with undo status', async () => {
|
|
const showRematchSummary = await getShowRematchSummary();
|
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
|
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
navigator.clipboard = { writeText };
|
|
|
|
showRematchSummary({
|
|
scope: 'bulk',
|
|
total: 2,
|
|
matchedRecipes: 1,
|
|
matchedEntries: 2,
|
|
unresolvedEntries: 1,
|
|
unresolvedRecipes: 1,
|
|
skipped: 0,
|
|
errors: 0,
|
|
l4Matches: [L4_LORA, L4_CHECKPOINT],
|
|
});
|
|
|
|
// Undo the first row before copying so the report carries its status.
|
|
const undoButton = document.querySelector('tr[data-l4-index="0"] .rematch-undo-btn');
|
|
undoButton.click();
|
|
await vi.waitFor(() => expect(undoButton.disabled).toBe(true));
|
|
|
|
document.querySelector('[data-action="copy-report"]').click();
|
|
await vi.waitFor(() => expect(writeText).toHaveBeenCalled());
|
|
|
|
const report = writeText.mock.calls[0][0];
|
|
expect(report).toContain('Scope: Selected recipes');
|
|
expect(report).toContain('Total recipes: 2');
|
|
expect(report).toContain('Matched entries: 2');
|
|
expect(report).toContain('Needs review (filename matches): 2');
|
|
expect(report).toContain('Unresolved entries: 1 (in 1 recipes)');
|
|
expect(report).toContain('[r1] old.safetensors -> new.safetensors [undone]');
|
|
expect(report).toContain('[r2] cp-old -> cp-new.safetensors');
|
|
// The success toast fires in the writeText .then() microtask.
|
|
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success'));
|
|
});
|
|
|
|
it('close removes the modal from the DOM', async () => {
|
|
const showRematchSummary = await getShowRematchSummary();
|
|
showRematchSummary({ scope: 'single', total: 1, matchedEntries: 1 });
|
|
|
|
expect(document.getElementById('rematchSummaryModal')).not.toBeNull();
|
|
document.querySelector('[data-action="close-modal"].cancel-btn').click();
|
|
expect(document.getElementById('rematchSummaryModal')).toBeNull();
|
|
});
|
|
|
|
it('escapes HTML in L4 row fields', async () => {
|
|
const showRematchSummary = await getShowRematchSummary();
|
|
showRematchSummary({
|
|
scope: 'bulk',
|
|
total: 1,
|
|
matchedEntries: 1,
|
|
l4Matches: [{ recipe_id: 'r<x>', type: 'lora', entry: '<img src=x>', file_name: 'f.safetensors', lora_index: 0 }],
|
|
});
|
|
|
|
const modal = document.getElementById('rematchSummaryModal');
|
|
expect(modal.querySelector('.failure-table img')).toBeNull();
|
|
expect(modal.querySelector('.failure-table tbody tr').textContent).toContain('<img src=x>');
|
|
});
|
|
});
|