The workflow card menu reused the model-library copy ("Exclude Model",
"Delete Model", "Copy Model Filename"), and the shared delete dialog
hard-coded "Model:" in its info box.
- Workflow-specific labels for copy-filename / move / delete, and for the
delete confirm dialog (title, message, info-box label, failure alert)
- Move dialog title, root label and hint now go through i18n instead of
hard-coded English; bulk delete dialog switches to workflow copy
- Failed move / nothing-selected toasts use workflow wording
- "Copy Workflow Filename" now copies the file name; it used to click the
card's copy icon, which copies the workflow document (duplicate of
"Copy Workflow")
- Fix "Link to CivitAI" posting to /api/lm/loras/relink-civitai: the
mixin's prefix map was keyed by singular type and missed the plural
page type used by WorkflowContextMenu
Also drops the "Exclude Workflow" action: workflows, like recipes, have
no exclusion concept. The file has no consumer outside LM, and the
excluded view is only reachable from the global context menu, so
excluding a workflow was a one-way hide. The entry is removed from the
card menu, the global menu and the excluded-view banner; the backend
route stays available.
243 lines
8.9 KiB
JavaScript
243 lines
8.9 KiB
JavaScript
import { afterEach, describe, it, beforeEach, expect, vi } from 'vitest';
|
|
|
|
const {
|
|
MODAL_UTILS_MODULE,
|
|
MODAL_MANAGER_MODULE,
|
|
API_FACTORY_MODULE,
|
|
UI_HELPERS_MODULE,
|
|
I18N_MODULE,
|
|
UNDO_HELPERS_MODULE,
|
|
} = vi.hoisted(() => ({
|
|
MODAL_UTILS_MODULE: new URL('../../../static/js/utils/modalUtils.js', import.meta.url).pathname,
|
|
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
|
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
|
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
|
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
|
|
}));
|
|
|
|
const deleteModelMock = vi.fn();
|
|
const resetAndReloadMock = vi.fn();
|
|
const showActionToastMock = vi.fn();
|
|
const handleUndoDeleteMock = vi.fn();
|
|
const translateMock = vi.fn((key) => key);
|
|
const closeModalMock = vi.fn();
|
|
const showModalMock = vi.fn();
|
|
|
|
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
|
modalManager: {
|
|
getModal: vi.fn((id) => ({ element: document.getElementById(id) })),
|
|
showModal: showModalMock,
|
|
closeModal: closeModalMock,
|
|
},
|
|
}));
|
|
|
|
vi.mock(API_FACTORY_MODULE, () => ({
|
|
getModelApiClient: vi.fn(() => ({ deleteModel: deleteModelMock })),
|
|
resetAndReload: resetAndReloadMock,
|
|
}));
|
|
|
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
|
showActionToast: showActionToastMock,
|
|
}));
|
|
|
|
vi.mock(I18N_MODULE, () => ({
|
|
translate: translateMock,
|
|
}));
|
|
|
|
vi.mock(UNDO_HELPERS_MODULE, () => ({
|
|
handleUndoDelete: handleUndoDeleteMock,
|
|
}));
|
|
|
|
describe('modalUtils confirmDelete undo flow', () => {
|
|
beforeEach(() => {
|
|
deleteModelMock.mockReset();
|
|
resetAndReloadMock.mockReset();
|
|
showActionToastMock.mockReset();
|
|
handleUndoDeleteMock.mockReset();
|
|
translateMock.mockClear();
|
|
closeModalMock.mockReset();
|
|
showModalMock.mockReset();
|
|
document.body.innerHTML = `
|
|
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model"></div>
|
|
<div id="deleteModal"><div class="delete-model-info"></div></div>
|
|
`;
|
|
window.modelDuplicatesManager = undefined;
|
|
});
|
|
|
|
it('shows the undo action toast and wires undo to handleUndoDelete + resetAndReload', async () => {
|
|
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-9' });
|
|
|
|
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
|
|
|
showDeleteModal('/models/foo.safetensors');
|
|
await confirmDelete();
|
|
|
|
expect(deleteModelMock).toHaveBeenCalledWith('/models/foo.safetensors');
|
|
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
|
|
|
const [key, params, type, options] = showActionToastMock.mock.calls[0];
|
|
expect(key).toBe('toast.undo.deleted');
|
|
expect(params).toEqual({ name: 'Foo Model' });
|
|
expect(type).toBe('success');
|
|
expect(options.actionText).toBe('toast.undo.action');
|
|
expect(translateMock).toHaveBeenCalledWith('toast.undo.action');
|
|
|
|
// Clicking Undo posts the batch and refreshes the model list
|
|
options.onAction();
|
|
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
|
|
const [batchId, refreshFn] = handleUndoDeleteMock.mock.calls[0];
|
|
expect(batchId).toBe('batch-9');
|
|
expect(typeof refreshFn).toBe('function');
|
|
|
|
refreshFn();
|
|
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it('does not show the action toast when the delete was not staged', async () => {
|
|
deleteModelMock.mockResolvedValue({ success: true, batch_id: null });
|
|
|
|
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
|
|
|
showDeleteModal('/models/foo.safetensors');
|
|
await confirmDelete();
|
|
|
|
expect(showActionToastMock).not.toHaveBeenCalled();
|
|
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
|
});
|
|
|
|
it('falls back to the file name when no card is present', async () => {
|
|
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-10' });
|
|
document.querySelector('.model-card').remove();
|
|
|
|
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
|
|
|
showDeleteModal('/models/bar.safetensors');
|
|
await confirmDelete();
|
|
|
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
|
expect(showActionToastMock.mock.calls[0][1]).toEqual({ name: 'bar.safetensors' });
|
|
});
|
|
|
|
it('refreshes the duplicates badge when the manager is available', async () => {
|
|
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-11' });
|
|
const updateBadge = vi.fn();
|
|
window.modelDuplicatesManager = { updateDuplicatesBadgeAfterRefresh: updateBadge };
|
|
|
|
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
|
|
|
showDeleteModal('/models/foo.safetensors');
|
|
await confirmDelete();
|
|
|
|
expect(updateBadge).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('modalUtils showDeleteModal warning copy and size line', () => {
|
|
beforeEach(() => {
|
|
showModalMock.mockReset();
|
|
closeModalMock.mockReset();
|
|
translateMock.mockClear();
|
|
translateMock.mockImplementation((key) => key);
|
|
document.body.innerHTML = `
|
|
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model" data-file_size="2147483648"></div>
|
|
<div id="deleteModal">
|
|
<div class="delete-model-info"></div>
|
|
<button class="delete-btn">Delete</button>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
function modelInfoHtml() {
|
|
return document.querySelector('#deleteModal .delete-model-info').innerHTML;
|
|
}
|
|
|
|
it('always shows the recoverable warning', async () => {
|
|
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
|
showDeleteModal('/models/foo.safetensors');
|
|
|
|
expect(modelInfoHtml()).toContain('modals.deleteModel.recoverableWarning');
|
|
expect(modelInfoHtml()).not.toContain('modals.deleteModel.permanentWarning');
|
|
});
|
|
|
|
it('appends a formatted "Frees {size}" line when the card carries a file size', async () => {
|
|
translateMock.mockImplementation((key, params) =>
|
|
params && params.size ? `${key} ${params.size}` : key
|
|
);
|
|
|
|
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
|
showDeleteModal('/models/foo.safetensors');
|
|
|
|
expect(modelInfoHtml()).toContain('modals.deleteModel.freesSpace 2.0 GB');
|
|
});
|
|
|
|
it('omits the size line when the card has no file size dataset', async () => {
|
|
document.querySelector('.model-card').removeAttribute('data-file_size');
|
|
|
|
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
|
showDeleteModal('/models/foo.safetensors');
|
|
|
|
expect(modelInfoHtml()).not.toContain('modals.deleteModel.freesSpace');
|
|
});
|
|
});
|
|
|
|
describe('modalUtils workflow wording', () => {
|
|
let state;
|
|
|
|
beforeEach(async () => {
|
|
({ state } = await import('../../../static/js/state/index.js'));
|
|
state.currentPageType = 'workflows';
|
|
showModalMock.mockReset();
|
|
closeModalMock.mockReset();
|
|
translateMock.mockReset();
|
|
translateMock.mockImplementation((key) => key);
|
|
document.body.innerHTML = `
|
|
<div class="model-card" data-filepath="/library/workflows/flow.json" data-name="Flow"></div>
|
|
<div id="deleteModal">
|
|
<h2>Delete Model</h2>
|
|
<p class="delete-message">model copy</p>
|
|
<div class="delete-model-info"></div>
|
|
</div>
|
|
<div id="excludeModal">
|
|
<h2>Exclude Model</h2>
|
|
<p class="delete-message">model copy</p>
|
|
<div class="exclude-model-info"></div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
afterEach(() => {
|
|
state.currentPageType = 'loras';
|
|
});
|
|
|
|
it('rewrites the delete dialog copy for workflows', async () => {
|
|
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
|
showDeleteModal('/library/workflows/flow.json');
|
|
|
|
expect(document.querySelector('#deleteModal h2').textContent).toBe('workflows.modals.delete.title');
|
|
expect(document.querySelector('#deleteModal .delete-message').textContent).toBe('workflows.modals.delete.message');
|
|
expect(document.querySelector('#deleteModal .delete-model-info').innerHTML).toContain('workflows.itemLabel');
|
|
});
|
|
|
|
it('preserves the exclude modal copy on the workflows page', async () => {
|
|
// Workflows have no exclusion concept; the shared exclude dialog stays
|
|
// model-worded because only model pages can reach it.
|
|
const { showExcludeModal } = await import(MODAL_UTILS_MODULE);
|
|
showExcludeModal('/library/workflows/flow.json');
|
|
|
|
expect(document.querySelector('#excludeModal h2').textContent).toBe('Exclude Model');
|
|
expect(document.querySelector('#excludeModal .exclude-model-info').innerHTML).toContain('Model:');
|
|
});
|
|
|
|
it('keeps the model dialog copy on model pages', async () => {
|
|
state.currentPageType = 'loras';
|
|
|
|
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
|
showDeleteModal('/library/workflows/flow.json');
|
|
|
|
expect(document.querySelector('#deleteModal h2').textContent).toBe('Delete Model');
|
|
expect(document.querySelector('#deleteModal .delete-model-info').innerHTML).toContain('Model:');
|
|
});
|
|
});
|