diff --git a/locales/de.json b/locales/de.json index 965faff9..5d7c063d 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1510,6 +1510,9 @@ "nameUpdated": "Rezeptname erfolgreich aktualisiert", "tagsUpdated": "Rezept-Tags erfolgreich aktualisiert", "sourceUrlUpdated": "Quell-URL erfolgreich aktualisiert", + "promptUpdated": "Prompt erfolgreich aktualisiert", + "negativePromptUpdated": "Negativer Prompt erfolgreich aktualisiert", + "promptEditorHint": "Drücken Sie Enter zum Speichern, Shift+Enter für neue Zeile", "noRecipeId": "Keine Rezept-ID verfügbar", "sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}", "copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}", diff --git a/locales/en.json b/locales/en.json index c20a97a0..c2fc9c65 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1510,6 +1510,9 @@ "nameUpdated": "Recipe name updated successfully", "tagsUpdated": "Recipe tags updated successfully", "sourceUrlUpdated": "Source URL updated successfully", + "promptUpdated": "Prompt updated successfully", + "negativePromptUpdated": "Negative prompt updated successfully", + "promptEditorHint": "Press Enter to save, Shift+Enter for new line", "noRecipeId": "No recipe ID available", "sendToWorkflowFailed": "Failed to send recipe to workflow: {message}", "copyFailed": "Error copying recipe syntax: {message}", diff --git a/locales/es.json b/locales/es.json index 8ecbd480..2c0224eb 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1510,6 +1510,9 @@ "nameUpdated": "Nombre de receta actualizado exitosamente", "tagsUpdated": "Etiquetas de receta actualizadas exitosamente", "sourceUrlUpdated": "URL de origen actualizada exitosamente", + "promptUpdated": "Prompt actualizado exitosamente", + "negativePromptUpdated": "Prompt negativo actualizado exitosamente", + "promptEditorHint": "Presiona Enter para guardar, Shift+Enter para nueva línea", "noRecipeId": "No hay ID de receta disponible", "sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}", "copyFailed": "Error copiando sintaxis de receta: {message}", diff --git a/locales/fr.json b/locales/fr.json index 74fd1c45..8bccc171 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1510,6 +1510,9 @@ "nameUpdated": "Nom de la recipe mis à jour avec succès", "tagsUpdated": "Tags de la recipe mis à jour avec succès", "sourceUrlUpdated": "URL source mise à jour avec succès", + "promptUpdated": "Prompt mis à jour avec succès", + "negativePromptUpdated": "Prompt négatif mis à jour avec succès", + "promptEditorHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne", "noRecipeId": "Aucun ID de recipe disponible", "sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}", "copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}", diff --git a/locales/he.json b/locales/he.json index bcb64af9..da09c0c3 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1510,6 +1510,9 @@ "nameUpdated": "שם המתכון עודכן בהצלחה", "tagsUpdated": "תגיות המתכון עודכנו בהצלחה", "sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה", + "promptUpdated": "הפרומפט עודכן בהצלחה", + "negativePromptUpdated": "הפרומפט השלילי עודכן בהצלחה", + "promptEditorHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה", "noRecipeId": "אין מזהה מתכון זמין", "sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}", "copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}", diff --git a/locales/ja.json b/locales/ja.json index b1dcf57c..6c6b2e79 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1510,6 +1510,9 @@ "nameUpdated": "レシピ名が正常に更新されました", "tagsUpdated": "レシピタグが正常に更新されました", "sourceUrlUpdated": "ソースURLが正常に更新されました", + "promptUpdated": "プロンプトが正常に更新されました", + "negativePromptUpdated": "ネガティブプロンプトが正常に更新されました", + "promptEditorHint": "Enterキーで保存、Shift+Enterで改行", "noRecipeId": "レシピIDが利用できません", "sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}", "copyFailed": "レシピ構文のコピーエラー:{message}", diff --git a/locales/ko.json b/locales/ko.json index 4e7ddf76..717700c7 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1510,6 +1510,9 @@ "nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다", "tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다", "sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다", + "promptUpdated": "프롬프트가 성공적으로 업데이트되었습니다", + "negativePromptUpdated": "네거티브 프롬프트가 성공적으로 업데이트되었습니다", + "promptEditorHint": "Enter 키를 눌러 저장, Shift+Enter로 새 줄", "noRecipeId": "사용 가능한 레시피 ID가 없습니다", "sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}", "copyFailed": "레시피 문법 복사 오류: {message}", diff --git a/locales/ru.json b/locales/ru.json index 12a40f87..920489d4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1510,6 +1510,9 @@ "nameUpdated": "Название рецепта успешно обновлено", "tagsUpdated": "Теги рецепта успешно обновлены", "sourceUrlUpdated": "Исходный URL успешно обновлен", + "promptUpdated": "Промпт успешно обновлён", + "negativePromptUpdated": "Негативный промпт успешно обновлён", + "promptEditorHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки", "noRecipeId": "ID рецепта недоступен", "sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}", "copyFailed": "Ошибка копирования синтаксиса рецепта: {message}", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index b72e7a28..6ccfe49e 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1510,6 +1510,9 @@ "nameUpdated": "配方名称更新成功", "tagsUpdated": "配方标签更新成功", "sourceUrlUpdated": "来源 URL 更新成功", + "promptUpdated": "提示词更新成功", + "negativePromptUpdated": "负面提示词更新成功", + "promptEditorHint": "按 Enter 保存,Shift+Enter 换行", "noRecipeId": "无配方 ID", "sendToWorkflowFailed": "发送配方到工作流失败:{message}", "copyFailed": "复制配方语法出错:{message}", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index ee284646..9a7fe49f 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1510,6 +1510,9 @@ "nameUpdated": "配方名稱已更新", "tagsUpdated": "配方標籤已更新", "sourceUrlUpdated": "來源網址已更新", + "promptUpdated": "提示詞更新成功", + "negativePromptUpdated": "負面提示詞更新成功", + "promptEditorHint": "按 Enter 儲存,Shift+Enter 換行", "noRecipeId": "無配方 ID", "sendToWorkflowFailed": "傳送配方到工作流失敗:{message}", "copyFailed": "複製配方語法錯誤:{message}", diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index e1c7ae15..be307f3b 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -173,11 +173,23 @@ class RecipePersistenceService: async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult: """Update persisted metadata for a recipe.""" - if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")): + allowed_fields = ( + "title", + "tags", + "source_path", + "preview_nsfw_level", + "favorite", + "gen_params", + ) + + if not any(key in updates for key in allowed_fields): raise RecipeValidationError( - "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite)" + "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params)" ) + if "gen_params" in updates and not isinstance(updates["gen_params"], dict): + raise RecipeValidationError("gen_params must be an object") + success = await recipe_scanner.update_recipe_metadata(recipe_id, updates) if not success: raise RecipeNotFoundError("Recipe not found or update failed") diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css index 07e9240f..32dcfd4c 100644 --- a/static/css/components/recipe-modal.css +++ b/static/css/components/recipe-modal.css @@ -424,6 +424,7 @@ display: flex; justify-content: space-between; align-items: center; + gap: 8px; } .param-header label { @@ -431,7 +432,14 @@ color: var(--text-color); } -.copy-btn { +.param-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.copy-btn, +.edit-btn { background: none; border: none; color: var(--text-color); @@ -442,7 +450,8 @@ transition: all 0.2s; } -.copy-btn:hover { +.copy-btn:hover, +.edit-btn:hover { opacity: 1; background: var(--lora-surface); } @@ -461,6 +470,48 @@ word-break: break-word; } +.param-content.hide { + display: none; +} + +.param-content.is-placeholder { + color: color-mix(in oklch, var(--text-color), transparent 35%); + font-style: italic; +} + +.param-editor { + display: none; + flex-direction: column; + gap: 10px; +} + +.param-editor.active { + display: flex; +} + +.param-textarea { + width: 100%; + max-width: 100%; + min-height: 140px; + resize: vertical; + background: var(--bg-color); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-xs); + padding: 10px 12px; + font-size: 0.9em; + line-height: 1.5; + color: var(--text-color); + font-family: inherit; + box-sizing: border-box; + overflow-x: hidden; +} + +.param-editor-hint { + font-size: 0.78em; + line-height: 1.4; + color: color-mix(in oklch, var(--text-color), transparent 35%); +} + /* Other Parameters */ .other-params { display: flex; diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 27a0aad7..d0c94722 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -9,11 +9,13 @@ import { MODEL_TYPES } from '../api/apiConfig.js'; class RecipeModal { constructor() { + this.promptEditorState = {}; this.init(); } init() { this.setupCopyButtons(); + this.setupPromptEditors(); // Set up tooltip positioning handlers after DOM is ready document.addEventListener('DOMContentLoaded', () => { this.setupTooltipPositioning(); @@ -87,6 +89,7 @@ class RecipeModal { showRecipeDetails(recipe) { // Store the full recipe for editing this.currentRecipe = recipe; + this.resetPromptEditors(); // Set modal title with edit icon const modalTitle = document.getElementById('recipeModalTitle'); @@ -300,20 +303,19 @@ class RecipeModal { const promptElement = document.getElementById('recipePrompt'); const negativePromptElement = document.getElementById('recipeNegativePrompt'); const otherParamsElement = document.getElementById('recipeOtherParams'); + const promptInput = document.getElementById('recipePromptInput'); + const negativePromptInput = document.getElementById('recipeNegativePromptInput'); if (recipe.gen_params) { - // Set prompt - if (promptElement && recipe.gen_params.prompt) { - promptElement.textContent = recipe.gen_params.prompt; - } else if (promptElement) { - promptElement.textContent = 'No prompt information available'; + this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available'); + this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available'); + + if (promptInput) { + promptInput.value = recipe.gen_params.prompt || ''; } - // Set negative prompt - if (negativePromptElement && recipe.gen_params.negative_prompt) { - negativePromptElement.textContent = recipe.gen_params.negative_prompt; - } else if (negativePromptElement) { - negativePromptElement.textContent = 'No negative prompt information available'; + if (negativePromptInput) { + negativePromptInput.value = recipe.gen_params.negative_prompt || ''; } // Set other parameters @@ -343,8 +345,10 @@ class RecipeModal { } } else { // No generation parameters available - if (promptElement) promptElement.textContent = 'No prompt information available'; - if (negativePromptElement) promptElement.textContent = 'No negative prompt information available'; + this.renderPromptContent(promptElement, '', 'No prompt information available'); + this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available'); + if (promptInput) promptInput.value = ''; + if (negativePromptInput) negativePromptInput.value = ''; if (otherParamsElement) otherParamsElement.innerHTML = '
No parameters available
'; } @@ -711,16 +715,202 @@ class RecipeModal { } } + setupPromptEditors() { + const promptConfigs = [ + { + editButtonId: 'editPromptBtn', + contentId: 'recipePrompt', + editorId: 'recipePromptEditor', + inputId: 'recipePromptInput', + field: 'prompt', + placeholder: 'No prompt information available', + successKey: 'toast.recipes.promptUpdated', + successFallback: 'Prompt updated successfully', + }, + { + editButtonId: 'editNegativePromptBtn', + contentId: 'recipeNegativePrompt', + editorId: 'recipeNegativePromptEditor', + inputId: 'recipeNegativePromptInput', + field: 'negative_prompt', + placeholder: 'No negative prompt information available', + successKey: 'toast.recipes.negativePromptUpdated', + successFallback: 'Negative prompt updated successfully', + } + ]; + + promptConfigs.forEach((config) => { + const editButton = document.getElementById(config.editButtonId); + const input = document.getElementById(config.inputId); + + if (editButton) { + editButton.addEventListener('click', () => this.showPromptEditor(config)); + } + + if (input) { + input.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + this.cancelPromptEdit(config); + return; + } + + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + event.stopPropagation(); + this.promptEditorState[config.field] = { + ...(this.promptEditorState[config.field] || {}), + skipBlurSave: true, + }; + this.savePromptEdit(config); + } + }); + input.addEventListener('blur', () => { + const promptState = this.promptEditorState[config.field] || {}; + if (promptState.skipBlurSave) { + this.promptEditorState[config.field] = { + ...promptState, + skipBlurSave: false, + }; + return; + } + + this.savePromptEdit(config); + }); + } + }); + } + + renderPromptContent(element, value, placeholder) { + if (!element) { + return; + } + + const text = value || ''; + if (text) { + element.textContent = text; + element.classList.remove('is-placeholder'); + } else { + element.textContent = placeholder; + element.classList.add('is-placeholder'); + } + } + + resetPromptEditors() { + this.hidePromptEditor({ contentId: 'recipePrompt', editorId: 'recipePromptEditor' }); + this.hidePromptEditor({ contentId: 'recipeNegativePrompt', editorId: 'recipeNegativePromptEditor' }); + } + + showPromptEditor(config) { + const content = document.getElementById(config.contentId); + const editor = document.getElementById(config.editorId); + const input = document.getElementById(config.inputId); + + if (!content || !editor || !input) { + return; + } + + const currentValue = this.currentRecipe?.gen_params?.[config.field] || ''; + input.value = currentValue; + this.promptEditorState[config.field] = { + initialValue: currentValue, + skipBlurSave: false, + isSaving: false, + }; + content.classList.add('hide'); + editor.classList.add('active'); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + } + + async savePromptEdit(config) { + const content = document.getElementById(config.contentId); + const editor = document.getElementById(config.editorId); + const input = document.getElementById(config.inputId); + + if (!content || !editor || !input || !this.currentRecipe) { + return; + } + + const promptState = this.promptEditorState[config.field] || {}; + if (promptState.isSaving) { + return; + } + + const currentGenParams = this.currentRecipe.gen_params || {}; + const nextValue = input.value.trim() === '' ? '' : input.value; + const currentValue = currentGenParams[config.field] || ''; + + if (nextValue === currentValue) { + this.hidePromptEditor(config); + return; + } + + const nextGenParams = { + ...currentGenParams, + [config.field]: nextValue, + }; + + try { + this.promptEditorState[config.field] = { + ...promptState, + isSaving: true, + }; + await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }); + this.currentRecipe.gen_params = nextGenParams; + this.renderPromptContent(content, nextValue, config.placeholder); + showToast(config.successKey, {}, 'success', config.successFallback); + } catch (error) { + this.renderPromptContent(content, currentValue, config.placeholder); + input.value = currentValue; + } finally { + this.hidePromptEditor(config); + } + } + + cancelPromptEdit(config) { + const input = document.getElementById(config.inputId); + if (input) { + const initialValue = this.promptEditorState[config.field]?.initialValue; + input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || ''); + } + + this.hidePromptEditor(config); + } + + hidePromptEditor(config) { + const content = document.getElementById(config.contentId); + const editor = document.getElementById(config.editorId); + + if (content) { + content.classList.remove('hide'); + } + + if (editor) { + editor.classList.remove('active'); + } + + delete this.promptEditorState[config.field]; + } + // Setup source URL handlers setupSourceUrlHandlers() { const sourceUrlContainer = document.querySelector('.source-url-container'); const sourceUrlEditor = document.querySelector('.source-url-editor'); + if (!sourceUrlContainer || !sourceUrlEditor) { + return; + } const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text'); const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn'); const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn'); const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn'); const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input'); + if (!sourceUrlText || !sourceUrlEditBtn || !sourceUrlCancelBtn || !sourceUrlSaveBtn || !sourceUrlInput) { + return; + } + // Show editor on edit button click sourceUrlEditBtn.addEventListener('click', () => { sourceUrlContainer.classList.add('hide'); @@ -782,14 +972,14 @@ class RecipeModal { if (copyPromptBtn) { copyPromptBtn.addEventListener('click', () => { - const promptText = document.getElementById('recipePrompt').textContent; + const promptText = this.currentRecipe?.gen_params?.prompt || ''; this.copyToClipboard(promptText, 'Prompt copied to clipboard'); }); } if (copyNegativePromptBtn) { copyNegativePromptBtn.addEventListener('click', () => { - const negativePromptText = document.getElementById('recipeNegativePrompt').textContent; + const negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || ''; this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard'); }); } diff --git a/templates/components/recipe_modal.html b/templates/components/recipe_modal.html index 46628a96..adc292d6 100644 --- a/templates/components/recipe_modal.html +++ b/templates/components/recipe_modal.html @@ -29,22 +29,52 @@
- +
+ + +
+
+ +
+ {{ t('toast.recipes.promptEditorHint') }} +
+
- +
+ + +
+
+ +
+ {{ t('toast.recipes.promptEditorHint') }} +
+
diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index 858c4716..aa0c2525 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -245,16 +245,28 @@ describe('Interaction-level regression coverage', () => {
- +
+ + +
+
+ +
- +
+ + +
+
+ +
@@ -324,6 +336,208 @@ describe('Interaction-level regression coverage', () => { expect(recipeModal.currentRecipe.title).toBe('Updated Title'); }); + it('saves prompt edits on Enter while preserving Shift+Enter for new lines', async () => { + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-2', + file_path: '/recipes/prompt.json', + title: 'Prompt Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: '', + gen_params: { + prompt: 'old prompt', + negative_prompt: 'keep negative', + steps: 30, + cfg_scale: 7, + }, + loras: [], + }); + + document.getElementById('editPromptBtn').click(); + const textarea = document.getElementById('recipePromptInput'); + textarea.value = 'new prompt text'; + textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true })); + await flushAsyncTasks(); + + expect(updateRecipeMetadataMock).not.toHaveBeenCalled(); + + textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await updateRecipeMetadataMock.mock.results[0].value; + await flushAsyncTasks(); + + expect(updateRecipeMetadataMock).toHaveBeenCalledWith('/recipes/prompt.json', { + gen_params: { + prompt: 'new prompt text', + negative_prompt: 'keep negative', + steps: 30, + cfg_scale: 7, + }, + }); + expect(document.getElementById('recipePrompt').textContent).toBe('new prompt text'); + expect(recipeModal.currentRecipe.gen_params.prompt).toBe('new prompt text'); + }); + + it('cancels negative prompt edits on Escape without saving', async () => { + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: 'recipe-3', + file_path: '/recipes/negative.json', + title: 'Negative Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: '', + gen_params: { + prompt: '', + negative_prompt: 'existing negative', + steps: 20, + }, + loras: [], + }); + + document.getElementById('editNegativePromptBtn').click(); + const textarea = document.getElementById('recipeNegativePromptInput'); + textarea.value = 'changed negative'; + textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(updateRecipeMetadataMock).not.toHaveBeenCalled(); + expect(modalManagerMock.closeModal).not.toHaveBeenCalled(); + expect(document.getElementById('recipeNegativePrompt').textContent).toBe('existing negative'); + expect(document.getElementById('recipeNegativePromptEditor').classList.contains('active')).toBe(false); + }); + it('processes global context menu actions for downloads and cleanup', async () => { document.body.innerHTML = `
diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index 55b7b05f..fba9882e 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -132,6 +132,7 @@ class StubPersistenceService: self.save_calls: List[Dict[str, Any]] = [] self.delete_calls: List[str] = [] self.move_calls: List[Dict[str, str]] = [] + self.update_calls: List[Dict[str, Any]] = [] self.save_result = SimpleNamespace( payload={"success": True, "recipe_id": "stub-id"}, status=200 ) @@ -182,7 +183,14 @@ class StubPersistenceService: async def update_recipe( self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any] - ) -> SimpleNamespace: # pragma: no cover - unused by smoke tests + ) -> SimpleNamespace: + self.update_calls.append( + { + "recipe_scanner": recipe_scanner, + "recipe_id": recipe_id, + "updates": updates, + } + ) return SimpleNamespace( payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200, @@ -509,6 +517,33 @@ async def test_import_remote_recipe_falls_back_to_request_base_model( assert provider_calls == ["77"] +async def test_update_recipe_accepts_gen_params(monkeypatch, tmp_path: Path) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + payload = { + "gen_params": { + "prompt": "updated prompt", + "negative_prompt": "updated negative", + "steps": 30, + } + } + + response = await harness.client.put( + "/api/lm/recipe/recipe-42/update", + json=payload, + ) + data = await response.json() + + assert response.status == 200 + assert data["success"] is True + assert harness.persistence.update_calls == [ + { + "recipe_scanner": harness.scanner, + "recipe_id": "recipe-42", + "updates": payload, + } + ] + + async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None: async def fake_get_default_metadata_provider(): return SimpleNamespace(get_model_version_info=lambda id: ({}, None)) diff --git a/tests/services/test_recipe_services.py b/tests/services/test_recipe_services.py index 1db28271..94b3ba59 100644 --- a/tests/services/test_recipe_services.py +++ b/tests/services/test_recipe_services.py @@ -7,7 +7,11 @@ from types import SimpleNamespace import pytest from py.services.recipes.analysis_service import RecipeAnalysisService -from py.services.recipes.errors import RecipeDownloadError, RecipeNotFoundError +from py.services.recipes.errors import ( + RecipeDownloadError, + RecipeNotFoundError, + RecipeValidationError, +) from py.services.recipes.persistence_service import RecipePersistenceService @@ -486,6 +490,50 @@ async def test_move_recipe_updates_paths(tmp_path): assert stored["file_path"] == result.payload["new_file_path"] +@pytest.mark.asyncio +async def test_update_recipe_accepts_gen_params() -> None: + class DummyScanner: + def __init__(self): + self.calls = [] + + async def update_recipe_metadata(self, recipe_id: str, updates: dict[str, object]): + self.calls.append((recipe_id, updates)) + return True + + scanner = DummyScanner() + service = RecipePersistenceService( + exif_utils=DummyExifUtils(), + card_preview_width=512, + logger=logging.getLogger("test"), + ) + + updates = {"gen_params": {"prompt": "updated prompt", "steps": 28}} + result = await service.update_recipe( + recipe_scanner=scanner, + recipe_id="recipe-1", + updates=updates, + ) + + assert result.payload["success"] is True + assert scanner.calls == [("recipe-1", updates)] + + +@pytest.mark.asyncio +async def test_update_recipe_rejects_non_object_gen_params() -> None: + service = RecipePersistenceService( + exif_utils=DummyExifUtils(), + card_preview_width=512, + logger=logging.getLogger("test"), + ) + + with pytest.raises(RecipeValidationError, match="gen_params must be an object"): + await service.update_recipe( + recipe_scanner=SimpleNamespace(), + recipe_id="recipe-1", + updates={"gen_params": "invalid"}, + ) + + @pytest.mark.asyncio async def test_analyze_remote_video(tmp_path): exif_utils = DummyExifUtils()