feat(excluded-models): add excluded management view

This commit is contained in:
Will Miao
2026-04-16 21:40:59 +08:00
parent ae7bfdb517
commit c53f44e7ef
34 changed files with 962 additions and 17 deletions

View File

@@ -2102,6 +2102,7 @@ describe('Interaction-level regression coverage', () => {
<div class="context-menu-item" data-action="check-model-updates"></div>
<div class="context-menu-item" data-action="fetch-missing-licenses"></div>
<div class="context-menu-item" data-action="cleanup-example-images-folders"></div>
<div class="context-menu-item" data-action="manage-excluded-models"></div>
</div>
`;
@@ -2120,6 +2121,9 @@ describe('Interaction-level regression coverage', () => {
startProgressUpdates: vi.fn(),
updateDownloadButtonText: vi.fn(),
};
window.pageControls = {
enterExcludedView: vi.fn().mockResolvedValue(undefined),
};
global.fetch = vi.fn()
.mockResolvedValueOnce({
@@ -2224,5 +2228,10 @@ describe('Interaction-level regression coverage', () => {
);
expect(loadingManagerStub.showSimpleLoading).toHaveBeenNthCalledWith(2, 'Refreshing license metadata for LoRAs...');
expect(fetchMissingItem.classList.contains('disabled')).toBe(false);
menu.showMenu(560, 600);
const excludedItem = document.querySelector('[data-action="manage-excluded-models"]');
excludedItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1);
});
});

View File

@@ -86,6 +86,7 @@ beforeEach(() => {
});
afterEach(() => {
delete window.bulkManager;
delete window.modelDuplicatesManager;
delete global.fetch;
vi.useRealTimers();
@@ -114,6 +115,9 @@ function renderControlsDom(pageKey) {
<button class="clear-filter"></button>
</div>
<div class="controls">
<div id="excludedViewBanner" class="excluded-view-banner hidden">
<button id="excludedViewBackBtn">Back</button>
</div>
<div class="actions">
<div class="action-buttons">
<div class="control-group">
@@ -172,6 +176,9 @@ function renderControlsDom(pageKey) {
<i class="fas fa-times-circle clear-filter"></i>
</div>
</div>
<div id="breadcrumbContainer"></div>
<div id="duplicatesBanner" style="display: none;"></div>
<div class="alphabet-bar-container"></div>
`;
}
@@ -576,4 +583,93 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
duplicateButton.click();
expect(toggleDuplicateMode).toHaveBeenCalledTimes(1);
});
it.each([
['loras', 'LorasControls'],
['checkpoints', 'CheckpointsControls'],
['embeddings', 'EmbeddingsControls'],
])('switches %s page into excluded mode and restores state', async (pageKey, exportName) => {
renderControlsDom(pageKey);
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState(pageKey);
const pageState = stateModule.getCurrentPageState();
pageState.filters.search = 'active-search';
pageState.showFavoritesOnly = true;
pageState.showUpdateAvailableOnly = true;
const controlsModule = await import('../../../static/js/components/controls/index.js');
const ControlsClass = controlsModule[exportName];
const controls = new ControlsClass();
await controls.enterExcludedView();
expect(pageState.viewMode).toBe('excluded');
expect(pageState.filters.search).toBe('');
expect(resetAndReloadMock).toHaveBeenLastCalledWith(false);
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(false);
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(true);
expect(document.getElementById('filterButton').disabled).toBe(true);
pageState.filters.search = 'excluded-search';
await controls.exitExcludedView();
expect(pageState.viewMode).toBe('active');
expect(pageState.filters.search).toBe('active-search');
expect(pageState.excludedViewState.search).toBe('excluded-search');
expect(resetAndReloadMock).toHaveBeenLastCalledWith(true);
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(true);
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(false);
expect(document.getElementById('filterButton').disabled).toBe(false);
});
it('suspends bulk and duplicate modes for excluded view and restores custom filter banner on exit', async () => {
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const pageState = stateModule.getCurrentPageState();
stateModule.state.bulkMode = true;
pageState.duplicatesMode = true;
sessionStorage.setItem('lora_manager_recipe_to_lora_filterLoraHash', 'hash-1');
sessionStorage.setItem('lora_manager_filterRecipeName', 'Recipe Filter');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
const toggleBulkMode = vi.fn(() => {
stateModule.state.bulkMode = !stateModule.state.bulkMode;
});
const exitDuplicateMode = vi.fn(() => {
pageState.duplicatesMode = false;
});
const enterDuplicateMode = vi.fn(() => {
pageState.duplicatesMode = true;
});
window.bulkManager = { toggleBulkMode };
window.modelDuplicatesManager = {
duplicateGroups: [{ hash: 'dup-1', models: [{ file_path: 'a' }, { file_path: 'b' }] }],
exitDuplicateMode,
enterDuplicateMode,
};
const controls = new LorasControls();
const indicator = document.getElementById('customFilterIndicator');
expect(indicator.classList.contains('hidden')).toBe(false);
await controls.enterExcludedView();
expect(toggleBulkMode).toHaveBeenCalledTimes(1);
expect(exitDuplicateMode).toHaveBeenCalledTimes(1);
expect(stateModule.state.bulkMode).toBe(false);
expect(pageState.duplicatesMode).toBe(false);
expect(indicator.classList.contains('hidden')).toBe(true);
await controls.exitExcludedView();
expect(indicator.classList.contains('hidden')).toBe(false);
expect(toggleBulkMode).toHaveBeenCalledTimes(2);
expect(enterDuplicateMode).toHaveBeenCalledTimes(1);
expect(stateModule.state.bulkMode).toBe(true);
expect(pageState.duplicatesMode).toBe(true);
});
});

View File

@@ -5,6 +5,7 @@ import pytest
from py.services.model_lifecycle_service import ModelLifecycleService
from py.utils.metadata_manager import MetadataManager
from py.utils.models import LoraMetadata
class DummyCache:
@@ -445,6 +446,63 @@ async def test_exclude_model_empty_path_raises_error():
await service.exclude_model("")
@pytest.mark.asyncio
async def test_unexclude_model_restores_cache_entry(tmp_path: Path):
"""Verify unexclude_model clears exclude metadata and restores cache entry."""
model_path = tmp_path / "restored_model.safetensors"
model_path.write_bytes(b"content")
metadata_payload = {
"file_name": "restored_model",
"model_name": "restored_model",
"file_path": str(model_path),
"sha256": "abc123",
"exclude": True,
"tags": ["tag1"],
}
metadata_path = tmp_path / "restored_model.metadata.json"
metadata_path.write_text(json.dumps(metadata_payload))
class RestoreScanner:
def __init__(self):
self.model_type = "lora"
self.model_class = LoraMetadata
self._excluded_models = [str(model_path)]
self.updated = []
async def update_single_model_cache(self, old_path, new_path, metadata, recalculate_type=False):
exclude_value = metadata.get("exclude") if isinstance(metadata, dict) else metadata.exclude
self.updated.append((old_path, new_path, exclude_value, recalculate_type))
saved_metadata = []
class SavingMetadataManager:
async def save_metadata(self, path: str, metadata: dict):
saved_metadata.append((path, metadata.copy()))
await MetadataManager.save_metadata(path, metadata)
async def metadata_loader(path: str):
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
scanner = RestoreScanner()
service = ModelLifecycleService(
scanner=scanner,
metadata_manager=SavingMetadataManager(),
metadata_loader=metadata_loader,
)
result = await service.unexclude_model(str(model_path))
assert result["success"] is True
assert "restored" in result["message"].lower()
assert scanner._excluded_models == []
assert saved_metadata[0][1]["exclude"] is False
assert scanner.updated == [
(str(model_path), str(model_path), False, True)
]
# =============================================================================
# Tests for bulk_delete_models functionality
# =============================================================================