refactor(delete): make undo unconditional, remove undo toggle and button delay

- Remove delete_undo_enabled setting (backend default, frontend state,
  settings modal UI, 10 locales); staged deletes with 30s undo are now
  the only delete path and stale settings keys are silently ignored
- Remove the 1500ms delete-button arm delay (armDeleteButton) from all
  delete modals; misclicks are recoverable via the undo toast
- Delete modal always shows the recoverable warning
- Log the first staged file path in staging log lines for easier support
This commit is contained in:
Will Miao
2026-08-11 21:15:59 +08:00
parent 04d131e9dc
commit 9659df6ad9
37 changed files with 20 additions and 876 deletions
-2
View File
@@ -443,7 +443,6 @@
"label": "Bereits heruntergeladene Modellversionen überspringen",
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "Nach Modell gruppieren",
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
@@ -1281,7 +1280,6 @@
"freesSpace": "[TODO: Translate] Frees {size}",
"title": "Modell löschen",
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
},
"excludeModel": {
-2
View File
@@ -443,7 +443,6 @@
"label": "Skip previously downloaded model versions",
"help": "When enabled, versions downloaded before will be skipped."
},
"deleteUndoEnabled": "Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "Group by Model",
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
@@ -1281,7 +1280,6 @@
"freesSpace": "Frees {size}",
"title": "Delete Model",
"message": "Are you sure you want to delete this model and all associated files?",
"permanentWarning": "This will permanently delete the file from disk.",
"recoverableWarning": "This will permanently delete the file after 30 seconds unless you undo."
},
"excludeModel": {
-2
View File
@@ -443,7 +443,6 @@
"label": "Omitir versiones de modelos previamente descargadas",
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "Agrupar por modelo",
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
@@ -1281,7 +1280,6 @@
"freesSpace": "[TODO: Translate] Frees {size}",
"title": "Eliminar modelo",
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
},
"excludeModel": {
-2
View File
@@ -443,7 +443,6 @@
"label": "Ignorer les versions de modèles précédemment téléchargées",
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "Grouper par modèle",
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
@@ -1281,7 +1280,6 @@
"freesSpace": "[TODO: Translate] Frees {size}",
"title": "Supprimer le modèle",
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
},
"excludeModel": {
-2
View File
@@ -443,7 +443,6 @@
"label": "דלג על גרסאות מודלים שהורדו בעבר",
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "קיבוץ לפי דגם",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
@@ -1281,7 +1280,6 @@
"freesSpace": "[TODO: Translate] Frees {size}",
"title": "מחק מודל",
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
},
"excludeModel": {
-2
View File
@@ -443,7 +443,6 @@
"label": "以前にダウンロードしたモデルバージョンをスキップ",
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
@@ -1281,7 +1280,6 @@
"freesSpace": "[TODO: Translate] Frees {size}",
"title": "モデルを削除",
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
},
"excludeModel": {
-2
View File
@@ -443,7 +443,6 @@
"label": "이전에 다운로드한 모델 버전 건너뛰기",
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
@@ -1281,7 +1280,6 @@
"freesSpace": "[TODO: Translate] Frees {size}",
"title": "모델 삭제",
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
},
"excludeModel": {
-2
View File
@@ -443,7 +443,6 @@
"label": "Пропускать ранее загруженные версии моделей",
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
@@ -1281,7 +1280,6 @@
"freesSpace": "[TODO: Translate] Frees {size}",
"title": "Удалить модель",
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
},
"excludeModel": {
-2
View File
@@ -443,7 +443,6 @@
"label": "跳过已下载的模型版本",
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
@@ -1281,7 +1280,6 @@
"freesSpace": "[TODO: Translate] Frees {size}",
"title": "删除模型",
"message": "你确定要删除此模型及所有相关文件吗?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
},
"excludeModel": {
-2
View File
@@ -443,7 +443,6 @@
"label": "跳過已下載的模型版本",
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
@@ -1281,7 +1280,6 @@
"freesSpace": "[TODO: Translate] Frees {size}",
"title": "刪除模型",
"message": "您確定要刪除此模型及所有相關檔案嗎?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
},
"excludeModel": {
+8 -20
View File
@@ -39,7 +39,6 @@ from typing import (
from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils import settings_paths
from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__)
@@ -122,16 +121,13 @@ class PendingDeleteService:
(``target_dir``), so staging/undo renames stay within one real
directory - EXDEV is impossible even when the business path traverses
nested symlinks to other volumes. Returns the batch id, or ``None``
when undo is disabled, the model root cannot be resolved, or staging
failed (caller falls back to a hard delete).
when the model root cannot be resolved, or staging failed (caller
falls back to a hard delete).
"""
# LOCK-FREE section: opportunistic purge must never run while holding
# the ops lock (the lock is not re-entrant).
await self._opportunistic_purge()
if not self._undo_enabled():
return None
async with self._ops_lock:
batch_dir: Optional[str] = None
staged_pairs: List[Dict[str, Any]] = []
@@ -190,9 +186,10 @@ class PendingDeleteService:
# manifest's expires_at at fire time, so stale timers no-op.
self._arm_purge_timer(batch_id)
logger.info(
"Staged model delete batch %s with %d file(s)",
"Staged model delete batch %s with %d file(s): %s",
batch_id,
len(staged_pairs),
staged_pairs[0]["original"] if staged_pairs else None,
)
return batch_id
except OSError as exc:
@@ -218,14 +215,11 @@ class PendingDeleteService:
) -> Optional[str]:
"""Copy a recipe JSON (and, when it exists, its image) into staging.
Returns the batch id, or ``None`` when undo is disabled / staging
failed. Missing or shared preview images are skipped.
Returns the batch id, or ``None`` when staging failed. Missing or
shared preview images are skipped.
"""
await self._opportunistic_purge()
if not self._undo_enabled():
return None
async with self._ops_lock:
batch_dir: Optional[str] = None
staged_pairs: List[Dict[str, Any]] = []
@@ -255,9 +249,10 @@ class PendingDeleteService:
self._write_manifest_atomic(batch_dir, manifest)
self._arm_purge_timer(batch_id)
logger.info(
"Staged recipe delete batch %s with %d file(s)",
"Staged recipe delete batch %s with %d file(s): %s",
batch_id,
len(staged_pairs),
staged_pairs[0]["original"] if staged_pairs else None,
)
return batch_id
except OSError as exc:
@@ -573,13 +568,6 @@ class PendingDeleteService:
except Exception as exc: # defensive - staging/undo must still proceed
logger.warning("Opportunistic pending-delete purge failed: %s", exc)
def _undo_enabled(self) -> bool:
try:
return bool(get_settings_manager().get("delete_undo_enabled", True))
except Exception as exc: # defensive - default to enabled
logger.warning("Failed to read delete_undo_enabled setting: %s", exc)
return True
def _remember_root(self, root: str) -> None:
"""Record a root the service has staged into (in-process registry)."""
if root and root not in self._known_roots:
-1
View File
@@ -111,7 +111,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"backup_retention_count": 5,
"use_new_license_icons": True,
"group_by_model": False,
"delete_undo_enabled": True,
# AI / LLM provider configuration (BYOK)
"llm_provider": "openai", # "openai" | "ollama" | "custom"
"llm_api_key": "",
+1 -1
View File
@@ -203,7 +203,7 @@ export class BaseModelApiClient {
}
const batchId = data.batch_id || null;
if (!batchId) {
// Not staged (undo disabled or staging failed): keep the legacy toast.
// Not staged (staging failed): keep the legacy toast.
// When staged, the caller shows the undo action toast instead.
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
}
@@ -1,7 +1,6 @@
// Duplicates Manager Component
import { showToast, showActionToast } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { translate } from '../utils/i18nHelpers.js';
import { RecipeCard } from './RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
@@ -449,7 +448,6 @@ export class DuplicatesManager {
// Use the modal manager to show the confirmation modal
modalManager.showModal('duplicateDeleteModal');
armDeleteButton(document.getElementById('duplicateDeleteModal'));
} catch (error) {
console.error('Error preparing delete:', error);
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
@@ -1,7 +1,6 @@
// Model Duplicates Manager Component for LoRAs and Checkpoints
import { showToast, showActionToast } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { translate } from '../utils/i18nHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { formatDate } from '../utils/formatters.js';
@@ -703,7 +702,6 @@ export class ModelDuplicatesManager {
// Use the modal manager to show the confirmation modal
modalManager.showModal('modelDuplicateDeleteModal');
armDeleteButton(document.getElementById('modelDuplicateDeleteModal'));
} catch (error) {
console.error('Error preparing delete:', error);
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
+1 -9
View File
@@ -9,7 +9,6 @@ import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
class RecipeCard {
constructor(recipe, clickHandler) {
@@ -366,7 +365,7 @@ class RecipeCard {
</div>
<div class="delete-info">
<h3>${this.recipe.title}</h3>
<p>This action cannot be undone.</p>
<p>This action can be undone for 30 seconds.</p>
</div>
</div>
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
@@ -378,13 +377,8 @@ class RecipeCard {
`;
// Show the modal with custom content and setup callbacks
let deleteArmTimer = null;
modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback
if (deleteArmTimer) {
clearTimeout(deleteArmTimer);
deleteArmTimer = null;
}
const deleteModal = document.getElementById('deleteModal');
const deleteBtn = deleteModal.querySelector('.delete-btn');
deleteBtn.textContent = 'Delete';
@@ -404,8 +398,6 @@ class RecipeCard {
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => this.confirmDeleteRecipe();
deleteArmTimer = armDeleteButton(deleteModal);
} catch (error) {
console.error('Error showing delete confirmation:', error);
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
-2
View File
@@ -1,7 +1,6 @@
import { state, getCurrentPageState } from '../state/index.js';
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
@@ -630,7 +629,6 @@ export class BulkManager {
}
modalManager.showModal('bulkDeleteModal');
armDeleteButton(document.getElementById('bulkDeleteModal'));
}
async confirmBulkDelete() {
-6
View File
@@ -1111,12 +1111,6 @@ export class SettingsManager {
includeTriggerWordsCheckbox.checked = state.global.settings.include_trigger_words || false;
}
// Set delete undo setting (defaults to enabled)
const deleteUndoEnabledCheckbox = document.getElementById('deleteUndoEnabled');
if (deleteUndoEnabledCheckbox) {
deleteUndoEnabledCheckbox.checked = state.global.settings.delete_undo_enabled ?? true;
}
// Set lora syntax format
const loraSyntaxFormatSelect = document.getElementById('loraSyntaxFormat');
if (loraSyntaxFormatSelect) {
-1
View File
@@ -58,7 +58,6 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
strip_lora_on_copy: false,
use_new_license_icons: true,
group_by_model: false,
delete_undo_enabled: true,
llm_provider: 'openai',
llm_api_key: '',
llm_api_base: '',
+1 -30
View File
@@ -3,31 +3,11 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { showActionToast } from './uiHelpers.js';
import { translate } from './i18nHelpers.js';
import { handleUndoDelete } from './undoHelpers.js';
import { state } from '../state/index.js';
import { formatFileSize } from '../components/shared/utils.js';
const DELETE_BUTTON_ARM_DELAY_MS = 1500;
let pendingDeletePath = null;
let pendingDeleteName = null;
let pendingExcludePath = null;
let pendingDeleteArmTimer = null;
// Delay-activates every delete button inside a delete-confirmation modal so a
// misclick in the first moments after opening cannot confirm the deletion.
// Returns the pending timeout id so callers can cancel it when the modal closes early.
export function armDeleteButton(modalElement, delayMs = DELETE_BUTTON_ARM_DELAY_MS) {
if (!modalElement) return null;
const deleteButtons = modalElement.querySelectorAll('.delete-btn');
if (!deleteButtons.length) return null;
deleteButtons.forEach((button) => { button.disabled = true; });
return setTimeout(() => {
deleteButtons.forEach((button) => { button.disabled = false; });
}, delayMs);
}
export function showDeleteModal(filePath) {
pendingDeletePath = filePath;
@@ -41,10 +21,6 @@ export function showDeleteModal(filePath) {
const modal = modalManager.getModal('deleteModal').element;
const modelInfo = modal.querySelector('.delete-model-info');
const undoEnabled = state.global?.settings?.delete_undo_enabled;
const warningKey = undoEnabled
? 'modals.deleteModel.recoverableWarning'
: 'modals.deleteModel.permanentWarning';
const fileSize = card?.dataset.file_size;
const sizeLine = fileSize
? `<br>${translate('modals.deleteModel.freesSpace', { size: formatFileSize(parseInt(fileSize, 10)) })}`
@@ -55,11 +31,10 @@ export function showDeleteModal(filePath) {
<br>
<strong>File:</strong> ${filePath}
<br>
${translate(warningKey)}${sizeLine}
${translate('modals.deleteModel.recoverableWarning')}${sizeLine}
`;
modalManager.showModal('deleteModal');
pendingDeleteArmTimer = armDeleteButton(modal);
}
export async function confirmDelete() {
@@ -90,10 +65,6 @@ export async function confirmDelete() {
export function closeDeleteModal() {
modalManager.closeModal('deleteModal');
if (pendingDeleteArmTimer) {
clearTimeout(pendingDeleteArmTimer);
pendingDeleteArmTimer = null;
}
pendingDeletePath = null;
pendingDeleteName = null;
}
@@ -811,22 +811,6 @@
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="deleteUndoEnabled">
{{ t('settings.deleteUndoEnabled') }}
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="deleteUndoEnabled"
onchange="settingsManager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
</div>
@@ -123,7 +123,6 @@ vi.mock('../../../static/js/state/index.js', () => ({
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
showExcludeModal: vi.fn(),
showDeleteModal: vi.fn(),
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
@@ -27,16 +27,7 @@ vi.mock('../../../static/js/components/RecipeCard.js', () => ({
},
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
armDeleteButton: (modalElement) => {
if (!modalElement) return null;
const buttons = modalElement.querySelectorAll('.delete-btn');
buttons.forEach((button) => { button.disabled = true; });
return setTimeout(() => {
buttons.forEach((button) => { button.disabled = false; });
}, 1500);
},
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
recreateVirtualScroll: recreateVirtualScrollMock,
@@ -340,41 +331,3 @@ describe('DuplicatesManager confirmDeleteDuplicates undo flows', () => {
);
});
});
describe('DuplicatesManager deleteSelectedDuplicates delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
setCurrentPageType('recipes');
setupDom();
document.body.insertAdjacentHTML('beforeend', `
<div id="duplicateDeleteModal" class="modal delete-modal">
<div class="delete-model-info"><p><span id="duplicateDeleteCount">0</span></p></div>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`);
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
});
afterEach(() => {
vi.useRealTimers();
delete globalThis.modalManager;
});
it('opens with the delete button disabled and enables it after 1500ms', async () => {
const manager = new DuplicatesManager({});
manager.selectedForDeletion.add('r1');
await manager.deleteSelectedDuplicates();
expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('duplicateDeleteModal');
const deleteBtn = document.querySelector('#duplicateDeleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
deleteBtn.click();
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -18,16 +18,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
armDeleteButton: (modalElement) => {
if (!modalElement) return null;
const buttons = modalElement.querySelectorAll('.delete-btn');
buttons.forEach((button) => { button.disabled = true; });
return setTimeout(() => {
buttons.forEach((button) => { button.disabled = false; });
}, 1500);
},
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({}));
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
const { state } = await import('../../../static/js/state/index.js');
@@ -365,36 +356,3 @@ describe('ModelDuplicatesManager confirmDeleteDuplicates undo flows', () => {
);
});
});
describe('ModelDuplicatesManager deleteSelectedDuplicates delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
});
afterEach(() => {
vi.useRealTimers();
delete globalThis.modalManager;
});
it('opens with the delete button disabled and enables it after 1500ms', async () => {
const manager = await createManager();
document.body.insertAdjacentHTML('beforeend', `
<div id="modelDuplicateDeleteModal" class="modal delete-modal">
<div class="delete-model-info"><p><span id="modelDuplicateDeleteCount">0</span></p></div>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`);
manager.selectedForDeletion.add(carPath);
await manager.deleteSelectedDuplicates();
expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('modelDuplicateDeleteModal');
const deleteBtn = document.querySelector('#modelDuplicateDeleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -1,160 +0,0 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
RECIPE_CARD_MODULE,
UI_HELPERS_MODULE,
RECIPE_API_MODULE,
MODEL_CARD_MODULE,
MODAL_MANAGER_MODULE,
BULK_MANAGER_MODULE,
I18N_MODULE,
UNDO_HELPERS_MODULE,
API_FACTORY_MODULE,
STATE_MODULE,
} = vi.hoisted(() => ({
RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname,
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.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,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
}));
const showModalMock = vi.fn();
const closeModalMock = vi.fn();
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
showActionToast: vi.fn(),
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
}));
vi.mock(RECIPE_API_MODULE, () => ({
updateRecipeMetadata: vi.fn(),
}));
vi.mock(MODEL_CARD_MODULE, () => ({
configureModelCardVideo: vi.fn(),
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: showModalMock,
closeModal: closeModalMock,
},
}));
vi.mock(BULK_MANAGER_MODULE, () => ({
bulkManager: {},
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key) => key),
}));
vi.mock(UNDO_HELPERS_MODULE, () => ({
handleUndoDelete: vi.fn(),
}));
// modalUtils.js is intentionally NOT mocked — its real armDeleteButton drives
// the delay-activate behavior under test. Its own imports are mocked below.
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(),
resetAndReload: vi.fn(),
}));
describe('RecipeCard delete confirmation delay-activate', () => {
let capturedOnClose;
beforeEach(async () => {
vi.useFakeTimers();
showModalMock.mockReset();
closeModalMock.mockReset();
capturedOnClose = null;
document.body.innerHTML = '<div id="deleteModal" class="modal delete-modal"></div>';
showModalMock.mockImplementation((id, content, onClose) => {
if (content) {
document.getElementById(id).innerHTML = content;
}
capturedOnClose = onClose;
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
window.recipeManager = { loadRecipes: vi.fn() };
const { state } = await import(STATE_MODULE);
state.virtualScroller = { removeItemByFilePath: vi.fn() };
});
afterEach(() => {
vi.useRealTimers();
delete global.fetch;
delete window.recipeManager;
document.body.innerHTML = '';
});
async function createCard() {
const { RecipeCard } = await import(RECIPE_CARD_MODULE);
const card = Object.create(RecipeCard.prototype);
card.recipe = { id: 'recipe-1', title: 'My Recipe', file_path: '/recipes/r1.json', file_url: '/preview.png' };
return card;
}
it('opens with a disabled delete button that ignores clicks until 1500ms elapse', async () => {
const card = await createCard();
card.showDeleteConfirmation();
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
deleteBtn.click();
expect(global.fetch).not.toHaveBeenCalled();
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
deleteBtn.click();
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/recipe/recipe-1',
expect.objectContaining({ method: 'DELETE' })
);
});
it('clears the pending arm timer when the modal closes during the countdown', async () => {
const card = await createCard();
card.showDeleteConfirmation();
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(700);
capturedOnClose();
expect(deleteBtn.disabled).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
it('re-arms a full 1500ms countdown when the modal is reopened', async () => {
const card = await createCard();
card.showDeleteConfirmation();
vi.advanceTimersByTime(1400);
capturedOnClose();
card.showDeleteConfirmation();
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1499);
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -332,41 +332,3 @@ describe('BulkManager.confirmBulkDelete undo flows', () => {
expect(resetAndReloadMock).not.toHaveBeenCalled();
});
});
describe('BulkManager.showBulkDeleteModal delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
stateStub.currentPageType = 'loras';
stateStub.selectedModels.clear();
stateStub.selectedModels.add('/models/a.safetensors');
document.body.innerHTML = `
<div id="bulkDeleteModal" class="modal delete-modal">
<h2></h2>
<p class="delete-message"></p>
<div class="delete-model-info"><p></p></div>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`;
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
it('opens with the delete button disabled and enables it after 1500ms', async () => {
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
const bulk = new BulkManager();
bulk.showBulkDeleteModal();
const deleteBtn = document.querySelector('#bulkDeleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
deleteBtn.click();
expect(bulkDeleteModelsMock).not.toHaveBeenCalled();
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -1,228 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
closeModal: vi.fn(),
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/state/index.js', () => {
const settings = {};
return {
state: {
global: {
settings,
},
loadingManager: {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
},
},
createDefaultSettings: () => ({
language: 'en',
delete_undo_enabled: true,
}),
};
});
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: vi.fn(),
}));
vi.mock('../../../static/js/utils/constants.js', () => ({
DOWNLOAD_PATH_TEMPLATES: {},
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: [],
PATH_TEMPLATE_PLACEHOLDERS: {},
DEFAULT_PRIORITY_TAG_CONFIG: {
lora: 'character, style',
checkpoint: 'base, guide',
embedding: 'hint',
},
getMappableBaseModelsDynamic: () => [],
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (_key, _params, fallback) => fallback ?? '',
}));
vi.mock('../../../static/js/i18n/index.js', () => ({
i18n: {
getCurrentLocale: () => 'en',
setLanguage: vi.fn().mockResolvedValue(),
},
}));
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
configureModelCardVideo: vi.fn(),
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { showToast } from '../../../static/js/utils/uiHelpers.js';
import { state } from '../../../static/js/state/index.js';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const createManager = () => {
state.global.settings = {};
const initSettingsSpy = vi
.spyOn(SettingsManager.prototype, 'initializeSettings')
.mockResolvedValue();
const initializeSpy = vi
.spyOn(SettingsManager.prototype, 'initialize')
.mockImplementation(() => {});
const manager = new SettingsManager();
initSettingsSpy.mockRestore();
initializeSpy.mockRestore();
return manager;
};
const appendDeleteUndoCheckbox = () => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'deleteUndoEnabled';
document.body.appendChild(checkbox);
return checkbox;
};
const stubLoadSettingsSubloaders = (manager) => {
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
};
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
});
afterEach(() => {
delete global.fetch;
});
describe('SettingsManager delete undo toggle', () => {
it('renders the checkbox markup with a resolvable i18n label', () => {
const template = readFileSync(
resolve(repoRoot, 'templates/components/modals/settings_modal.html'),
'utf8',
);
const locales = JSON.parse(
readFileSync(resolve(repoRoot, 'locales/en.json'), 'utf8'),
);
// The label must resolve to real text, not fall back to the raw key.
expect(locales.settings.deleteUndoEnabled).toBe(
'Keep deleted items recoverable for 30 seconds (undo)',
);
expect(template).toContain('id="deleteUndoEnabled"');
expect(template).toContain("t('settings.deleteUndoEnabled')");
expect(template).toContain(
"settingsManager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled')",
);
});
it('restores the checkbox as unchecked when the saved setting is false', async () => {
const manager = createManager();
const checkbox = appendDeleteUndoCheckbox();
checkbox.checked = true;
stubLoadSettingsSubloaders(manager);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
state.global.settings = { delete_undo_enabled: false };
await manager.loadSettingsToUI();
expect(checkbox.checked).toBe(false);
});
it('restores the checkbox as checked when the saved setting is true or absent', async () => {
const manager = createManager();
const checkbox = appendDeleteUndoCheckbox();
stubLoadSettingsSubloaders(manager);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
state.global.settings = { delete_undo_enabled: true };
await manager.loadSettingsToUI();
expect(checkbox.checked).toBe(true);
checkbox.checked = false;
state.global.settings = {};
await manager.loadSettingsToUI();
expect(checkbox.checked).toBe(true);
});
it('saves delete_undo_enabled to the backend when the checkbox is toggled', async () => {
const manager = createManager();
const checkbox = appendDeleteUndoCheckbox();
checkbox.checked = false;
state.global.settings = { delete_undo_enabled: true };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await manager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled');
expect(state.global.settings.delete_undo_enabled).toBe(false);
expect(global.fetch).toHaveBeenCalledWith('/api/lm/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ delete_undo_enabled: false }),
});
expect(showToast).toHaveBeenCalledWith(
'toast.settings.settingsUpdated',
{ setting: 'delete undo enabled' },
'success',
);
});
it('saves delete_undo_enabled as true when re-enabled', async () => {
const manager = createManager();
const checkbox = appendDeleteUndoCheckbox();
checkbox.checked = true;
state.global.settings = { delete_undo_enabled: false };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await manager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled');
expect(state.global.settings.delete_undo_enabled).toBe(true);
expect(global.fetch).toHaveBeenCalledWith('/api/lm/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ delete_undo_enabled: true }),
});
});
});
@@ -34,7 +34,6 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
@@ -26,7 +26,6 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
-1
View File
@@ -36,7 +36,6 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
-11
View File
@@ -66,9 +66,6 @@ describe('translate() with real en.json locale', () => {
it('resolves delete-model modal keys with interpolation', () => {
installWindowI18n();
expect(translate('modals.deleteModel.permanentWarning')).toBe(
'This will permanently delete the file from disk.',
);
expect(translate('modals.deleteModel.recoverableWarning')).toBe(
'This will permanently delete the file after 30 seconds unless you undo.',
);
@@ -77,14 +74,6 @@ describe('translate() with real en.json locale', () => {
);
});
it('resolves the delete-undo settings label', () => {
installWindowI18n();
expect(translate('settings.deleteUndoEnabled')).toBe(
'Keep deleted items recoverable for 30 seconds (undo)',
);
});
it('returns the raw key when no translation exists (fallback contract)', () => {
installWindowI18n();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+2 -102
View File
@@ -1,4 +1,4 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { describe, it, beforeEach, expect, vi } from 'vitest';
const {
MODAL_UTILS_MODULE,
@@ -7,7 +7,6 @@ const {
UI_HELPERS_MODULE,
I18N_MODULE,
UNDO_HELPERS_MODULE,
STATE_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,
@@ -15,7 +14,6 @@ const {
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,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
}));
const deleteModelMock = vi.fn();
@@ -136,75 +134,6 @@ describe('modalUtils confirmDelete undo flow', () => {
});
});
describe('modalUtils armDeleteButton delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
deleteModelMock.mockReset();
showModalMock.mockReset();
closeModalMock.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>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`;
});
afterEach(() => {
vi.useRealTimers();
});
it('opens with the delete button disabled and enables it after exactly 1500ms', async () => {
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1499);
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1);
expect(deleteBtn.disabled).toBe(false);
});
it('clicking the disabled delete button fires nothing', async () => {
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
deleteBtn.click();
expect(deleteBtn.disabled).toBe(true);
expect(deleteModelMock).not.toHaveBeenCalled();
});
it('closing during the countdown clears the timer and reopening re-arms a full 1500ms', async () => {
const { showDeleteModal, closeDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
vi.advanceTimersByTime(1400);
closeDeleteModal();
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
// Reopen — the stale timer must not enable the button early
showDeleteModal('/models/foo.safetensors');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1499);
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1);
expect(deleteBtn.disabled).toBe(false);
});
});
describe('modalUtils showDeleteModal warning copy and size line', () => {
beforeEach(() => {
showModalMock.mockReset();
@@ -220,19 +149,11 @@ describe('modalUtils showDeleteModal warning copy and size line', () => {
`;
});
afterEach(async () => {
const { state } = await import(STATE_MODULE);
state.global.settings.delete_undo_enabled = true;
});
function modelInfoHtml() {
return document.querySelector('#deleteModal .delete-model-info').innerHTML;
}
it('shows the recoverable warning when delete_undo_enabled is truthy', async () => {
const { state } = await import(STATE_MODULE);
state.global.settings.delete_undo_enabled = true;
it('always shows the recoverable warning', async () => {
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
@@ -240,27 +161,6 @@ describe('modalUtils showDeleteModal warning copy and size line', () => {
expect(modelInfoHtml()).not.toContain('modals.deleteModel.permanentWarning');
});
it('shows the permanent warning when delete_undo_enabled is falsy', async () => {
const { state } = await import(STATE_MODULE);
state.global.settings.delete_undo_enabled = false;
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
expect(modelInfoHtml()).not.toContain('modals.deleteModel.recoverableWarning');
});
it('falls back to the neutral permanent warning when the setting is unavailable', async () => {
const { state } = await import(STATE_MODULE);
delete state.global.settings.delete_undo_enabled;
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
expect(modelInfoHtml()).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
+5 -41
View File
@@ -10,7 +10,6 @@ import pytest
from py.services.model_lifecycle_service import ModelLifecycleService, _require_path_in_library_roots
from py.services.pending_delete_service import PENDING_DELETE_DIR_NAME, _reset_pending_delete_service
from py.services.settings_manager import get_settings_manager
from py.utils.metadata_manager import MetadataManager
from py.utils.models import LoraMetadata
@@ -953,11 +952,11 @@ def _make_delete_service(scanner: Any) -> ModelLifecycleService:
@pytest.mark.asyncio
async def test_delete_model_stages_file_when_undo_enabled(tmp_path: Path):
"""Undo enabled (the default): artifacts are renamed into a
``.lm-pending-delete/<batch_id>/`` staging dir under the model root, the
response carries the batch_id, the cache entry is removed and the cache
is persisted (``_persist_calls`` tracked by ``ScannerForDelete``)."""
async def test_delete_model_stages_file(tmp_path: Path):
"""Artifacts are renamed into a ``.lm-pending-delete/<batch_id>/`` staging
dir under the model root, the response carries the batch_id, the cache
entry is removed and the cache is persisted (``_persist_calls`` tracked by
``ScannerForDelete``)."""
root = tmp_path / "loras"
root.mkdir()
model_path = root / "model.safetensors"
@@ -999,41 +998,6 @@ async def test_delete_model_stages_file_when_undo_enabled(tmp_path: Path):
assert scanner._persist_calls == [True]
@pytest.mark.asyncio
async def test_delete_model_hard_deletes_when_undo_disabled(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""delete_undo_enabled=false: old os.remove behavior, batch_id is None and
no staging directory is ever created."""
root = tmp_path / "loras"
root.mkdir()
model_path = root / "model.safetensors"
model_path.write_bytes(b"content")
settings_manager = get_settings_manager()
monkeypatch.setattr(
settings_manager,
"get",
lambda key, default=None: False
if key == "delete_undo_enabled"
else default,
)
scanner = ScannerForDelete(
raw_data=[{"file_path": str(model_path)}],
roots=[str(root)],
)
service = _make_delete_service(scanner)
result = await service.delete_model(str(model_path))
assert result["success"] is True
assert result["batch_id"] is None
assert result["deleted_files"]
assert not model_path.exists()
assert not (root / PENDING_DELETE_DIR_NAME).exists()
@pytest.mark.asyncio
async def test_delete_model_falls_back_when_staging_fails(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-28
View File
@@ -22,7 +22,6 @@ from py.services.pending_delete_service import (
_reset_pending_delete_service,
)
from py.services.persistent_model_cache import PersistentModelCache, DEFAULT_LICENSE_FLAGS
from py.services.settings_manager import get_settings_manager
from py.utils.civitai_utils import build_license_flags
from py.utils.models import BaseModelMetadata
@@ -1166,33 +1165,6 @@ async def test_bulk_delete_merge_failure_falls_back_to_batch_ids(
assert sorted(staged_files) == ["one.txt", "two.txt"]
@pytest.mark.asyncio
async def test_bulk_delete_undo_disabled_hard_deletes(tmp_path: Path):
"""delete_undo_enabled=false -> old hard delete, no batch, no staging dirs."""
root = tmp_path / "loras"
root.mkdir()
first = root / "one.txt"
first.write_text("one", encoding="utf-8")
second = root / "two.txt"
second.write_text("two", encoding="utf-8")
scanner = _make_bulk_scanner(root, [first, second])
get_settings_manager().settings["delete_undo_enabled"] = False
result = await scanner.bulk_delete_models([str(first), str(second)])
assert result["success"] is True
assert result["status"] == "success"
assert result["total_deleted"] == 2
assert result.get("batch_id") is None
assert "batch_ids" not in result
# Old hard-delete behavior: files removed, zero staging dirs created.
assert not first.exists()
assert not second.exists()
assert not (root / PENDING_DELETE_DIR_NAME).exists()
@pytest.mark.asyncio
async def test_bulk_delete_cancelled_after_one_staged_batch_present(
tmp_path: Path, monkeypatch
@@ -31,7 +31,6 @@ from py.services.pending_delete_service import (
)
from py.services.model_hash_index import ModelHashIndex
from py.services.model_scanner import ModelScanner
from py.services.settings_manager import DEFAULT_SETTINGS, get_settings_manager
from py.utils import settings_paths
from py.utils.models import LoraMetadata
@@ -771,32 +770,6 @@ async def test_l2_merge_basename_collision_aborts_without_dropping_files(
assert (sub_b / "model.safetensors").read_bytes() == b"model-data"
# ---------------------------------------------------------------------------
# (m) delete_undo_enabled=false -> stage returns None, nothing created
# ---------------------------------------------------------------------------
async def test_m_undo_disabled_returns_none(tmp_path: Path) -> None:
root = tmp_path / "loras"
root.mkdir()
model = root / "model.safetensors"
model.write_bytes(b"data")
get_settings_manager().settings["delete_undo_enabled"] = False
service = await PendingDeleteService.get_instance()
batch_id = await service.stage_model_delete(
scanner=ScannerForStage([root]),
target_dir=str(root),
file_name="model",
main_extension=".safetensors",
original_file_path=str(model),
cached_entry=None,
)
assert batch_id is None
assert model.exists()
assert not (root / PENDING_DELETE_DIR_NAME).exists()
# ---------------------------------------------------------------------------
# (n) simulated OSError during staging -> rollback, no orphaned batch dir
# ---------------------------------------------------------------------------
@@ -839,13 +812,6 @@ async def test_n_staging_oserror_rolls_back(tmp_path: Path, monkeypatch) -> None
assert not any(staging.iterdir())
# ---------------------------------------------------------------------------
# (o) DEFAULT_SETTINGS contains delete_undo_enabled=True
# ---------------------------------------------------------------------------
def test_o_default_settings_contains_undo_enabled() -> None:
assert DEFAULT_SETTINGS.get("delete_undo_enabled") is True
# ---------------------------------------------------------------------------
# (p) SCANNER EXCLUSION
# ---------------------------------------------------------------------------
-24
View File
@@ -31,7 +31,6 @@ from py.services.recipes.persistence_service import (
PersistenceResult,
RecipePersistenceService,
)
from py.services.settings_manager import get_settings_manager
from py.utils import settings_paths
@@ -223,29 +222,6 @@ async def test_delete_recipe_skips_missing_preview_image(tmp_path: Path) -> None
assert scanner.removed == ["r2"]
# ---------------------------------------------------------------------------
# (3) undo disabled -> no staging, payload batch_id None, existing behavior
# ---------------------------------------------------------------------------
async def test_delete_recipe_undo_disabled_no_staging(tmp_path: Path) -> None:
get_settings_manager().settings["delete_undo_enabled"] = False
scanner = RecipeScannerStub(tmp_path)
json_path, image_path, _recipe_data = _write_recipe(tmp_path, "r3")
scanner.register_recipe("r3", json_path)
result = await _make_service().delete_recipe(
recipe_scanner=scanner, recipe_id="r3"
)
assert result.payload["batch_id"] is None
# No staging leftovers when undo is disabled.
assert not _staging_parent().exists()
# Existing hard delete behavior unchanged.
assert not json_path.exists()
assert not image_path.exists()
assert scanner.removed == ["r3"]
# ---------------------------------------------------------------------------
# (4) bulk_delete with 2 ids -> single batch_id, one batch dir with both
# recipes, re-anchored expires_at in the merged manifest
-5
View File
@@ -1178,8 +1178,3 @@ def test_skip_previously_downloaded_model_versions_coerces_string_input(manager)
assert manager.get_skip_previously_downloaded_model_versions() is True
assert manager.settings["skip_previously_downloaded_model_versions"] is True
def test_delete_undo_enabled_defaults_true(manager):
assert settings_manager_module.DEFAULT_SETTINGS.get("delete_undo_enabled") is True
assert manager.get("delete_undo_enabled") is True