feat(download-history): add downloaded status UX

This commit is contained in:
Will Miao
2026-04-13 19:51:04 +08:00
parent ba1800095e
commit a95c518b30
17 changed files with 417 additions and 16 deletions

View File

@@ -957,6 +957,8 @@
"earlyAccess": "Early Access", "earlyAccess": "Early Access",
"earlyAccessTooltip": "Early Access erforderlich", "earlyAccessTooltip": "Early Access erforderlich",
"inLibrary": "In Bibliothek", "inLibrary": "In Bibliothek",
"downloaded": "Heruntergeladen",
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
"alreadyInLibrary": "Bereits in Bibliothek", "alreadyInLibrary": "Bereits in Bibliothek",
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]", "autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
"errors": { "errors": {
@@ -1228,6 +1230,7 @@
"badges": { "badges": {
"current": "Aktuelle Version", "current": "Aktuelle Version",
"inLibrary": "In der Bibliothek", "inLibrary": "In der Bibliothek",
"downloaded": "Heruntergeladen",
"newer": "Neuere Version", "newer": "Neuere Version",
"earlyAccess": "Früher Zugriff", "earlyAccess": "Früher Zugriff",
"ignored": "Ignoriert" "ignored": "Ignoriert"

View File

@@ -957,6 +957,8 @@
"earlyAccess": "Early Access", "earlyAccess": "Early Access",
"earlyAccessTooltip": "Early access required", "earlyAccessTooltip": "Early access required",
"inLibrary": "In Library", "inLibrary": "In Library",
"downloaded": "Downloaded",
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
"alreadyInLibrary": "Already in Library", "alreadyInLibrary": "Already in Library",
"autoOrganizedPath": "[Auto-organized by path template]", "autoOrganizedPath": "[Auto-organized by path template]",
"errors": { "errors": {
@@ -1228,6 +1230,7 @@
"badges": { "badges": {
"current": "Current Version", "current": "Current Version",
"inLibrary": "In Library", "inLibrary": "In Library",
"downloaded": "Downloaded",
"newer": "Newer Version", "newer": "Newer Version",
"earlyAccess": "Early Access", "earlyAccess": "Early Access",
"ignored": "Ignored" "ignored": "Ignored"

View File

@@ -957,6 +957,8 @@
"earlyAccess": "Acceso temprano", "earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Acceso temprano requerido", "earlyAccessTooltip": "Acceso temprano requerido",
"inLibrary": "En la biblioteca", "inLibrary": "En la biblioteca",
"downloaded": "Descargado",
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
"alreadyInLibrary": "Ya en la biblioteca", "alreadyInLibrary": "Ya en la biblioteca",
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]", "autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
"errors": { "errors": {
@@ -1228,6 +1230,7 @@
"badges": { "badges": {
"current": "Versión actual", "current": "Versión actual",
"inLibrary": "En la biblioteca", "inLibrary": "En la biblioteca",
"downloaded": "Descargado",
"newer": "Versión más reciente", "newer": "Versión más reciente",
"earlyAccess": "Acceso temprano", "earlyAccess": "Acceso temprano",
"ignored": "Ignorada" "ignored": "Ignorada"

View File

@@ -957,6 +957,8 @@
"earlyAccess": "Accès anticipé", "earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Accès anticipé requis", "earlyAccessTooltip": "Accès anticipé requis",
"inLibrary": "Dans la bibliothèque", "inLibrary": "Dans la bibliothèque",
"downloaded": "Téléchargé",
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
"alreadyInLibrary": "Déjà dans la bibliothèque", "alreadyInLibrary": "Déjà dans la bibliothèque",
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]", "autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
"errors": { "errors": {
@@ -1228,6 +1230,7 @@
"badges": { "badges": {
"current": "Version actuelle", "current": "Version actuelle",
"inLibrary": "Dans la bibliothèque", "inLibrary": "Dans la bibliothèque",
"downloaded": "Téléchargé",
"newer": "Version plus récente", "newer": "Version plus récente",
"earlyAccess": "Accès anticipé", "earlyAccess": "Accès anticipé",
"ignored": "Ignorée" "ignored": "Ignorée"

View File

@@ -957,6 +957,8 @@
"earlyAccess": "גישה מוקדמת", "earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "נדרשת גישה מוקדמת", "earlyAccessTooltip": "נדרשת גישה מוקדמת",
"inLibrary": "בספרייה", "inLibrary": "בספרייה",
"downloaded": "הורד",
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
"alreadyInLibrary": "כבר בספרייה", "alreadyInLibrary": "כבר בספרייה",
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]", "autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
"errors": { "errors": {
@@ -1228,6 +1230,7 @@
"badges": { "badges": {
"current": "גרסה נוכחית", "current": "גרסה נוכחית",
"inLibrary": "בספרייה", "inLibrary": "בספרייה",
"downloaded": "הורד",
"newer": "גרסה חדשה יותר", "newer": "גרסה חדשה יותר",
"earlyAccess": "גישה מוקדמת", "earlyAccess": "גישה מוקדמת",
"ignored": "התעלם" "ignored": "התעלם"

View File

@@ -957,6 +957,8 @@
"earlyAccess": "アーリーアクセス", "earlyAccess": "アーリーアクセス",
"earlyAccessTooltip": "アーリーアクセスが必要", "earlyAccessTooltip": "アーリーアクセスが必要",
"inLibrary": "ライブラリ内", "inLibrary": "ライブラリ内",
"downloaded": "ダウンロード済み",
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
"alreadyInLibrary": "既にライブラリ内", "alreadyInLibrary": "既にライブラリ内",
"autoOrganizedPath": "[パステンプレートによる自動整理]", "autoOrganizedPath": "[パステンプレートによる自動整理]",
"errors": { "errors": {
@@ -1228,6 +1230,7 @@
"badges": { "badges": {
"current": "現在のバージョン", "current": "現在のバージョン",
"inLibrary": "ライブラリにあります", "inLibrary": "ライブラリにあります",
"downloaded": "ダウンロード済み",
"newer": "新しいバージョン", "newer": "新しいバージョン",
"earlyAccess": "早期アクセス", "earlyAccess": "早期アクセス",
"ignored": "無視中" "ignored": "無視中"

View File

@@ -957,6 +957,8 @@
"earlyAccess": "얼리 액세스", "earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "얼리 액세스 필요", "earlyAccessTooltip": "얼리 액세스 필요",
"inLibrary": "라이브러리에 있음", "inLibrary": "라이브러리에 있음",
"downloaded": "다운로드됨",
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
"alreadyInLibrary": "이미 라이브러리에 있음", "alreadyInLibrary": "이미 라이브러리에 있음",
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]", "autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
"errors": { "errors": {
@@ -1228,6 +1230,7 @@
"badges": { "badges": {
"current": "현재 버전", "current": "현재 버전",
"inLibrary": "라이브러리에 있음", "inLibrary": "라이브러리에 있음",
"downloaded": "다운로드됨",
"newer": "최신 버전", "newer": "최신 버전",
"earlyAccess": "얼리 액세스", "earlyAccess": "얼리 액세스",
"ignored": "무시됨" "ignored": "무시됨"

View File

@@ -957,6 +957,8 @@
"earlyAccess": "Ранний доступ", "earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Требуется ранний доступ", "earlyAccessTooltip": "Требуется ранний доступ",
"inLibrary": "В библиотеке", "inLibrary": "В библиотеке",
"downloaded": "Загружено",
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
"alreadyInLibrary": "Уже в библиотеке", "alreadyInLibrary": "Уже в библиотеке",
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]", "autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
"errors": { "errors": {
@@ -1228,6 +1230,7 @@
"badges": { "badges": {
"current": "Текущая версия", "current": "Текущая версия",
"inLibrary": "В библиотеке", "inLibrary": "В библиотеке",
"downloaded": "Загружено",
"newer": "Более новая версия", "newer": "Более новая версия",
"earlyAccess": "Ранний доступ", "earlyAccess": "Ранний доступ",
"ignored": "Игнорируется" "ignored": "Игнорируется"

View File

@@ -957,6 +957,8 @@
"earlyAccess": "早期访问", "earlyAccess": "早期访问",
"earlyAccessTooltip": "需要早期访问权限", "earlyAccessTooltip": "需要早期访问权限",
"inLibrary": "已在库中", "inLibrary": "已在库中",
"downloaded": "已下载",
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
"alreadyInLibrary": "已存在于库中", "alreadyInLibrary": "已存在于库中",
"autoOrganizedPath": "【已按路径模板自动整理】", "autoOrganizedPath": "【已按路径模板自动整理】",
"errors": { "errors": {
@@ -1228,6 +1230,7 @@
"badges": { "badges": {
"current": "当前版本", "current": "当前版本",
"inLibrary": "已在库中", "inLibrary": "已在库中",
"downloaded": "已下载",
"newer": "较新的版本", "newer": "较新的版本",
"earlyAccess": "抢先体验", "earlyAccess": "抢先体验",
"ignored": "已忽略" "ignored": "已忽略"

View File

@@ -957,6 +957,8 @@
"earlyAccess": "早期存取", "earlyAccess": "早期存取",
"earlyAccessTooltip": "需要早期存取", "earlyAccessTooltip": "需要早期存取",
"inLibrary": "已在庫存", "inLibrary": "已在庫存",
"downloaded": "已下載",
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
"alreadyInLibrary": "已在庫存", "alreadyInLibrary": "已在庫存",
"autoOrganizedPath": "[依路徑範本自動整理]", "autoOrganizedPath": "[依路徑範本自動整理]",
"errors": { "errors": {
@@ -1228,6 +1230,7 @@
"badges": { "badges": {
"current": "目前版本", "current": "目前版本",
"inLibrary": "已在庫中", "inLibrary": "已在庫中",
"downloaded": "已下載",
"newer": "較新版本", "newer": "較新版本",
"earlyAccess": "搶先體驗", "earlyAccess": "搶先體驗",
"ignored": "已忽略" "ignored": "已忽略"

View File

@@ -19,6 +19,7 @@ from ...services.download_coordinator import DownloadCoordinator
from ...services.metadata_sync_service import MetadataSyncService from ...services.metadata_sync_service import MetadataSyncService
from ...services.model_file_service import ModelMoveService from ...services.model_file_service import ModelMoveService
from ...services.preview_asset_service import PreviewAssetService from ...services.preview_asset_service import PreviewAssetService
from ...services.service_registry import ServiceRegistry
from ...services.settings_manager import SettingsManager, get_settings_manager from ...services.settings_manager import SettingsManager, get_settings_manager
from ...services.tag_update_service import TagUpdateService from ...services.tag_update_service import TagUpdateService
from ...services.use_cases import ( from ...services.use_cases import (
@@ -1531,6 +1532,13 @@ class ModelCivitaiHandler:
cache = await self._service.scanner.get_cached_data() cache = await self._service.scanner.get_cached_data()
version_index = cache.version_index version_index = cache.version_index
history_service = await ServiceRegistry.get_downloaded_version_history_service()
downloaded_version_ids = set(
await history_service.get_downloaded_version_ids(
self._service.model_type,
model_id,
)
)
for version in versions: for version in versions:
version_id = None version_id = None
@@ -1547,6 +1555,9 @@ class ModelCivitaiHandler:
else None else None
) )
version["existsLocally"] = cache_entry is not None version["existsLocally"] = cache_entry is not None
version["hasBeenDownloaded"] = (
version_id in downloaded_version_ids if version_id is not None else False
)
if cache_entry and isinstance(cache_entry, Mapping): if cache_entry and isinstance(cache_entry, Mapping):
local_path = cache_entry.get("file_path") local_path = cache_entry.get("file_path")
if local_path: if local_path:
@@ -2265,7 +2276,7 @@ class ModelUpdateHandler:
self, self,
record, record,
*, *,
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None, version_context: Optional[Dict[int, Dict[str, Any]]] = None,
) -> Dict: ) -> Dict:
context = version_context or {} context = version_context or {}
# Check user setting for hiding early access versions # Check user setting for hiding early access versions
@@ -2294,7 +2305,7 @@ class ModelUpdateHandler:
@staticmethod @staticmethod
def _serialize_version( def _serialize_version(
version, context: Optional[Dict[str, Optional[str]]] version, context: Optional[Dict[str, Any]]
) -> Dict: ) -> Dict:
context = context or {} context = context or {}
preview_override = context.get("preview_override") preview_override = context.get("preview_override")
@@ -2328,6 +2339,7 @@ class ModelUpdateHandler:
"sizeBytes": version.size_bytes, "sizeBytes": version.size_bytes,
"previewUrl": preview_url, "previewUrl": preview_url,
"isInLibrary": version.is_in_library, "isInLibrary": version.is_in_library,
"hasBeenDownloaded": bool(context.get("has_been_downloaded", False)),
"shouldIgnore": version.should_ignore, "shouldIgnore": version.should_ignore,
"earlyAccessEndsAt": version.early_access_ends_at, "earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access, "isEarlyAccess": is_early_access,
@@ -2337,8 +2349,31 @@ class ModelUpdateHandler:
async def _build_version_context( async def _build_version_context(
self, record self, record
) -> Dict[int, Dict[str, Optional[str]]]: ) -> Dict[int, Dict[str, Any]]:
context: Dict[int, Dict[str, Optional[str]]] = {} context: Dict[int, Dict[str, Any]] = {}
downloaded_version_ids: set[int] = set()
try:
history_service = await ServiceRegistry.get_downloaded_version_history_service()
downloaded_version_ids = set(
await history_service.get_downloaded_version_ids(
record.model_type,
record.model_id,
)
)
except Exception as exc: # pragma: no cover - defensive logging
self._logger.debug(
"Failed to load download history while building version context: %s",
exc,
)
for version in record.versions:
context[version.version_id] = {
"file_path": None,
"file_name": None,
"preview_override": None,
"has_been_downloaded": version.version_id in downloaded_version_ids,
}
try: try:
cache = await self._service.scanner.get_cached_data() cache = await self._service.scanner.get_cached_data()
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
@@ -2357,16 +2392,21 @@ class ModelUpdateHandler:
cache_entry = version_index.get(version.version_id) cache_entry = version_index.get(version.version_id)
if isinstance(cache_entry, Mapping): if isinstance(cache_entry, Mapping):
preview = cache_entry.get("preview_url") preview = cache_entry.get("preview_url")
context_entry: Dict[str, Optional[str]] = { context_entry = context.setdefault(
"file_path": cache_entry.get("file_path"), version.version_id,
"file_name": cache_entry.get("file_name"), {
"preview_override": None, "file_path": None,
} "file_name": None,
"preview_override": None,
"has_been_downloaded": version.version_id in downloaded_version_ids,
},
)
context_entry["file_path"] = cache_entry.get("file_path")
context_entry["file_name"] = cache_entry.get("file_name")
if isinstance(preview, str) and preview: if isinstance(preview, str) and preview:
context_entry["preview_override"] = config.get_preview_static_url( context_entry["preview_override"] = config.get_preview_static_url(
preview preview
) )
context[version.version_id] = context_entry
return context return context

View File

@@ -21,6 +21,56 @@
font-size: 0.9em; font-size: 0.9em;
} }
.downloaded-badge {
display: inline-flex;
align-items: center;
background: color-mix(in oklch, var(--badge-update-bg, #4a90e2) 22%, transparent);
color: var(--badge-update-bg, #4a90e2);
border: 1px solid color-mix(in oklch, var(--badge-update-bg, #4a90e2) 50%, transparent);
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
position: relative;
transform: translateZ(0);
will-change: transform;
}
.downloaded-badge i {
margin-right: 4px;
font-size: 0.9em;
}
.downloaded-info {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);
border: 1px solid color-mix(in oklch, var(--badge-update-bg, #4a90e2) 50%, transparent);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
margin-top: 4px;
font-size: 0.9em;
color: var(--text-color);
white-space: normal;
word-break: break-word;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 100;
min-width: 240px;
max-width: 320px;
transform: translateZ(0);
position: fixed;
pointer-events: none;
}
.downloaded-badge:hover .downloaded-info {
display: block;
pointer-events: auto;
}
/* Early Access Badge */ /* Early Access Badge */
.early-access-badge { .early-access-badge {
display: inline-flex; display: inline-flex;
@@ -108,4 +158,4 @@
color: var(--lora-error); color: var(--lora-error);
font-size: 0.9em; font-size: 0.9em;
margin-top: 4px; margin-top: 4px;
} }

View File

@@ -433,7 +433,13 @@ function renderRow(version, options) {
if (version.isInLibrary) { if (version.isInLibrary) {
badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success')); badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success'));
} else if (isNewer && !version.shouldIgnore) { }
if (!version.isInLibrary && version.hasBeenDownloaded) {
badges.push(buildBadge(translate('modals.model.versions.badges.downloaded', {}, 'Downloaded'), 'info'));
}
if (!version.isInLibrary && isNewer && !version.shouldIgnore) {
badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info')); badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info'));
} }

View File

@@ -250,6 +250,7 @@ export class DownloadManager {
(version.files[0]?.sizeKB / 1024).toFixed(2); (version.files[0]?.sizeKB / 1024).toFixed(2);
const existsLocally = version.existsLocally; const existsLocally = version.existsLocally;
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
const localPath = version.localPath; const localPath = version.localPath;
const isEarlyAccess = version.availability === 'EarlyAccess'; const isEarlyAccess = version.availability === 'EarlyAccess';
@@ -262,11 +263,22 @@ export class DownloadManager {
`; `;
} }
const localStatus = existsLocally ? let localStatus = '';
`<div class="local-badge"> if (existsLocally) {
localStatus = `<div class="local-badge">
<i class="fas fa-check"></i> ${translate('modals.download.inLibrary')} <i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}
<div class="local-path">${localPath || ''}</div> <div class="local-path">${localPath || ''}</div>
</div>` : ''; </div>`;
} else if (hasBeenDownloaded) {
localStatus = `<div class="downloaded-badge">
<i class="fas fa-history"></i> ${translate('modals.download.downloaded', {}, 'Downloaded')}
<div class="downloaded-info">${translate(
'modals.download.downloadedTooltip',
{},
'Previously downloaded, but it is not currently in your library.'
)}</div>
</div>`;
}
return ` return `
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''} <div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}

View File

@@ -349,4 +349,59 @@ describe('ModelVersionsTab media rendering', () => {
); );
expect(firstBadges).toContain('Newer Version'); expect(firstBadges).toContain('Newer Version');
}); });
it('shows downloaded badge only for previously downloaded versions that are not in library', async () => {
fetchModelUpdateVersions.mockResolvedValue({
success: true,
record: {
shouldIgnore: false,
inLibraryVersionIds: [8],
versions: [
{
versionId: 9,
name: 'History only',
baseModel: 'SDXL',
previewUrl: '/api/lm/previews/v9.png',
sizeBytes: 1024,
isInLibrary: false,
hasBeenDownloaded: true,
shouldIgnore: false,
},
{
versionId: 8,
name: 'Local copy',
baseModel: 'SDXL',
previewUrl: '/api/lm/previews/v8.png',
sizeBytes: 2048,
isInLibrary: true,
hasBeenDownloaded: true,
shouldIgnore: false,
},
],
},
});
const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE);
const controller = initVersionsTab({
modalId: 'model-versions-modal',
modelType: 'loras',
modelId: 654,
currentVersionId: 8,
});
await controller.load();
const rows = document.querySelectorAll('.model-version-row');
const historyBadges = Array.from(rows[0].querySelectorAll('.version-badge')).map(
badge => badge.textContent?.trim()
);
const localBadges = Array.from(rows[1].querySelectorAll('.version-badge')).map(
badge => badge.textContent?.trim()
);
expect(historyBadges).toContain('Downloaded');
expect(historyBadges).not.toContain('In Library');
expect(localBadges).toContain('In Library');
expect(localBadges).not.toContain('Downloaded');
});
}); });

View File

@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
} = vi.hoisted(() => ({
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn(),
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
}));
vi.mock(STATE_MODULE, () => ({
state: {
global: {
settings: {},
},
},
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => ({
showSimpleLoading: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => vi.fn()),
setStatus: vi.fn(),
})),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => ({
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
},
},
})),
resetAndReload: vi.fn(),
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({
clearSelection: vi.fn(),
init: vi.fn(),
})),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
describe('DownloadManager version history badges', () => {
let DownloadManager;
beforeEach(async () => {
vi.resetModules();
document.body.innerHTML = `
<div id="urlStep"></div>
<div id="versionStep"></div>
<div id="versionList"></div>
<button id="nextFromVersion"></button>
`;
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
});
afterEach(() => {
document.body.innerHTML = '';
});
it('shows downloaded badge only for versions missing locally', () => {
const manager = new DownloadManager();
manager.versions = [
{
id: 101,
name: 'History only',
images: [],
files: [{ sizeKB: 2048 }],
createdAt: '2026-01-01T00:00:00Z',
existsLocally: false,
hasBeenDownloaded: true,
},
{
id: 102,
name: 'Still local',
images: [],
files: [{ sizeKB: 2048 }],
createdAt: '2026-01-01T00:00:00Z',
existsLocally: true,
hasBeenDownloaded: true,
localPath: '/models/still-local.safetensors',
},
];
manager.showVersionStep();
const items = document.querySelectorAll('.version-item');
expect(items).toHaveLength(2);
expect(items[0].querySelector('.downloaded-badge')?.textContent).toContain('Downloaded');
expect(items[0].querySelector('.downloaded-info')?.textContent).toContain(
'Previously downloaded, but it is not currently in your library.'
);
expect(items[0].querySelector('.local-badge')).toBeNull();
expect(items[1].querySelector('.local-badge')).not.toBeNull();
expect(items[1].querySelector('.local-path')?.textContent).toContain('/models/still-local.safetensors');
expect(items[1].querySelector('.downloaded-badge')).toBeNull();
});
});

View File

@@ -7,6 +7,7 @@ import pytest
from py.config import config from py.config import config
from py.routes.handlers.model_handlers import ModelUpdateHandler from py.routes.handlers.model_handlers import ModelUpdateHandler
from py.services.service_registry import ServiceRegistry
from py.utils.metadata_manager import MetadataManager from py.utils.metadata_manager import MetadataManager
from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord
@@ -91,7 +92,75 @@ async def test_build_version_context_includes_static_urls():
overrides = await handler._build_version_context(record) overrides = await handler._build_version_context(record)
expected = config.get_preview_static_url("/tmp/previews/example.png") expected = config.get_preview_static_url("/tmp/previews/example.png")
assert overrides == {123: {"file_path": None, "file_name": None, "preview_override": expected}} assert overrides == {
123: {
"file_path": None,
"file_name": None,
"preview_override": expected,
"has_been_downloaded": False,
}
}
@pytest.mark.asyncio
async def test_build_version_context_includes_download_history(monkeypatch):
cache = SimpleNamespace(version_index={})
service = DummyService(cache)
handler = ModelUpdateHandler(
service=service,
update_service=SimpleNamespace(),
metadata_provider_selector=lambda *_: None,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
class DummyHistoryService:
async def get_downloaded_version_ids(self, model_type, model_id):
assert model_type == "lora"
assert model_id == 42
return [123]
async def fake_history_service_factory():
return DummyHistoryService()
monkeypatch.setattr(
ServiceRegistry,
"get_downloaded_version_history_service",
staticmethod(fake_history_service_factory),
)
record = ModelUpdateRecord(
model_type="lora",
model_id=42,
versions=[
ModelVersionRecord(
version_id=123,
name="Downloaded",
base_model=None,
released_at=None,
size_bytes=None,
preview_url=None,
is_in_library=False,
should_ignore=False,
),
ModelVersionRecord(
version_id=124,
name="Fresh",
base_model=None,
released_at=None,
size_bytes=None,
preview_url=None,
is_in_library=False,
should_ignore=False,
),
],
last_checked_at=None,
should_ignore_model=False,
)
overrides = await handler._build_version_context(record)
assert overrides[123]["has_been_downloaded"] is True
assert overrides[124]["has_been_downloaded"] is False
@pytest.mark.asyncio @pytest.mark.asyncio