diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 0eecac6c..73aae9f3 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -90,7 +90,7 @@ export class BulkManager { moveAll: true, autoOrganize: false, deleteAll: true, - setContentRating: false, + setContentRating: true, skipMetadataRefresh: false, setFavorite: true, unfavorite: true, @@ -1528,14 +1528,18 @@ export class BulkManager { let failureCount = 0; try { - const apiClient = getModelApiClient(); + const isRecipesPage = state.currentPageType === 'recipes'; for (const filePath of targets) { if (cancelled) { showToast('toast.api.operationCancelled', {}, 'info'); break; } try { - await apiClient.saveModelMetadata(filePath, { preview_nsfw_level: level }); + if (isRecipesPage) { + await updateRecipeMetadata(filePath, { preview_nsfw_level: level }); + } else { + await getModelApiClient().saveModelMetadata(filePath, { preview_nsfw_level: level }); + } successCount++; } catch (error) { failureCount++; diff --git a/tests/frontend/managers/BulkManager.contentRating.test.js b/tests/frontend/managers/BulkManager.contentRating.test.js new file mode 100644 index 00000000..d980a5d6 --- /dev/null +++ b/tests/frontend/managers/BulkManager.contentRating.test.js @@ -0,0 +1,133 @@ +import { describe, it, beforeEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); +const getNSFWLevelNameMock = vi.fn((level) => { + if (level >= 16) return 'XXX'; + if (level >= 8) return 'X'; + if (level >= 4) return 'R'; + if (level >= 2) return 'PG13'; + if (level >= 1) return 'PG'; + return 'Unknown'; +}); + +const loadingManagerStub = { + showSimpleLoading: vi.fn(), + showCancelButton: vi.fn(), + hide: vi.fn(), +}; + +const stateStub = { + currentPageType: 'recipes', + bulkMode: false, + selectedModels: new Set(), + loadingManager: loadingManagerStub, + virtualScroller: { updateSingleItem: vi.fn() }, + global: { settings: {} }, +}; + +const saveModelMetadataMock = vi.fn(); +const getModelApiClientMock = vi.fn(() => ({ saveModelMetadata: saveModelMetadataMock })); +const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true })); + +vi.mock('../../../static/js/state/index.js', () => ({ + state: stateStub, + getCurrentPageState: vi.fn(), +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), + sendEmbeddingToWorkflow: vi.fn(), + buildLoraSyntax: vi.fn(), + getNSFWLevelName: getNSFWLevelNameMock, +})); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + getModelApiClient: getModelApiClientMock, + resetAndReload: vi.fn(), +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + RecipeSidebarApiClient: class {}, + updateRecipeMetadata: updateRecipeMetadataMock, + extractRecipeId: vi.fn(), +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' }, + MODEL_CONFIG: {}, +})); + +vi.mock('../../../static/js/managers/ModalManager.js', () => ({ + modalManager: { showModal: vi.fn(), closeModal: vi.fn() }, +})); + +vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({ + updateCardsForBulkMode: vi.fn(), +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: translateMock, +})); + +vi.mock('../../../static/js/utils/priorityTagHelpers.js', () => ({ + getPriorityTagSuggestions: vi.fn(), +})); + +vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({ + getNsfwLevelSelector: vi.fn(), +})); + +describe('BulkManager bulk content rating', () => { + beforeEach(() => { + vi.clearAllMocks(); + stateStub.currentPageType = 'recipes'; + stateStub.bulkMode = false; + stateStub.selectedModels.clear(); + saveModelMetadataMock.mockResolvedValue(undefined); + updateRecipeMetadataMock.mockResolvedValue({ success: true }); + }); + + async function createBulkManager() { + const { BulkManager } = await import('../../../static/js/managers/BulkManager.js'); + return new BulkManager(); + } + + it('exposes the content rating action on the recipes page action config', async () => { + const bulk = await createBulkManager(); + expect(bulk.actionConfig.recipes.setContentRating).toBe(true); + }); + + it('persists the rating through the recipe API when on the recipes page', async () => { + const bulk = await createBulkManager(); + stateStub.currentPageType = 'recipes'; + stateStub.selectedModels.add('/recipes/test.webp'); + + const ok = await bulk.setBulkContentRating(4, ['/recipes/test.webp']); + + expect(ok).toBe(true); + expect(updateRecipeMetadataMock).toHaveBeenCalledWith('/recipes/test.webp', { preview_nsfw_level: 4 }); + expect(updateRecipeMetadataMock).toHaveBeenCalledTimes(1); + expect(saveModelMetadataMock).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith( + 'toast.models.bulkContentRatingSet', + { count: 1, level: 'R' }, + 'success' + ); + }); + + it('persists the rating through the model API on model pages', async () => { + const bulk = await createBulkManager(); + stateStub.currentPageType = 'loras'; + stateStub.selectedModels.add('/models/test.safetensors'); + + const ok = await bulk.setBulkContentRating(8, ['/models/test.safetensors']); + + expect(ok).toBe(true); + expect(saveModelMetadataMock).toHaveBeenCalledWith('/models/test.safetensors', { preview_nsfw_level: 8 }); + expect(saveModelMetadataMock).toHaveBeenCalledTimes(1); + expect(updateRecipeMetadataMock).not.toHaveBeenCalled(); + }); +});