mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-28 08:21:27 -03:00
fix(ui): reconcile model listing in place after download (#1078)
Stop resetting the whole listing after a successful download. The legacy flow reloaded page 1, scrolled to the top and hijacked the sidebar's active folder whenever a custom target folder was used, which made the Updates view lose its place (and sometimes render as an empty page). Downloads only flip the update flag for one model, so the listing is now reconciled in place through the virtual scroller: - Updates view: the model's cards are removed once its newest eligible version is installed (the flag is model-level). - Normal listing: the card stays; only update_available is cleared. - Model not in the current view (different folder/filter/window): no-op; the sidebar folder tree alone is refreshed. - Falling back to the legacy reload only when no virtual scroller is available (e.g. recipes page, duplicates mode, HF downloads).
This commit is contained in:
@@ -1426,6 +1426,32 @@ export function initVersionsTab({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the downloaded version is the newest version in the model's
|
||||
* remote version set, i.e. the one whose install flips the backend
|
||||
* update-available flag off. Unknown version sets fall back to "latest"
|
||||
* so the post-download in-place reconciliation still runs by default.
|
||||
* (#1078)
|
||||
*/
|
||||
function versionIsLatestAvailable(version) {
|
||||
if (!controller.record || !Array.isArray(controller.record.versions)) {
|
||||
return true;
|
||||
}
|
||||
const versions = controller.record.versions;
|
||||
if (versions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const target = Number(version?.versionId);
|
||||
if (!Number.isFinite(target)) {
|
||||
return true;
|
||||
}
|
||||
const maxId = versions.reduce(
|
||||
(max, v) => Math.max(max, Number(v?.versionId) || 0),
|
||||
0
|
||||
);
|
||||
return target >= maxId;
|
||||
}
|
||||
|
||||
async function handleDownloadVersion(button, versionId) {
|
||||
if (!controller.record) {
|
||||
return;
|
||||
@@ -1451,6 +1477,7 @@ export function initVersionsTab({
|
||||
targetFolder: resolveTemplatePath ? '' : (pathInfo?.targetFolder || ''),
|
||||
useDefaultPaths: resolveTemplatePath ? true : null,
|
||||
useSaveDirAsRoot: resolveTemplatePath,
|
||||
isLatestVersion: versionIsLatestAvailable(version),
|
||||
});
|
||||
|
||||
if (success) {
|
||||
|
||||
@@ -1077,6 +1077,7 @@ export class DownloadManager {
|
||||
deferReload = false,
|
||||
suppressSuccessToast = false,
|
||||
suppressFailureSummary = false,
|
||||
isLatestVersion = null,
|
||||
}) {
|
||||
const config = this.apiClient?.apiConfig?.config;
|
||||
|
||||
@@ -1085,7 +1086,7 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
const displayName = versionName || `#${versionId}`;
|
||||
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false, deferReload, suppressSuccessToast, suppressFailureSummary };
|
||||
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false, deferReload, suppressSuccessToast, suppressFailureSummary, isLatestVersion };
|
||||
this._lastDownloadError = null;
|
||||
let ws = null;
|
||||
let updateProgress = () => { };
|
||||
@@ -1228,24 +1229,18 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
if (!deferReload) {
|
||||
const pageState = this.apiClient.getPageState();
|
||||
|
||||
if (!useDefaultPaths && targetFolder) {
|
||||
pageState.activeFolder = targetFolder;
|
||||
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
|
||||
|
||||
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
|
||||
const isActive = tag.dataset.folder === targetFolder;
|
||||
tag.classList.toggle('active', isActive);
|
||||
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
|
||||
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
// In-place view update instead of a full page reload: the
|
||||
// download only flips the update flag for one model, so we
|
||||
// reconcile its cards without resetting the listing, the
|
||||
// scroll position or the sidebar's active folder (#1078).
|
||||
// The legacy code hijacked `pageState.activeFolder` here
|
||||
// whenever a custom target folder was used.
|
||||
await this._reconcileViewAfterDownload({
|
||||
modelId,
|
||||
isLatestVersion: isLatestVersion ?? this._isDownloadingLatestVersion(versionId),
|
||||
});
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
@@ -1285,6 +1280,175 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the current model listing after a successful download,
|
||||
* without resetting the whole page (#1078).
|
||||
*
|
||||
* The legacy behaviour re-loaded page 1 and scrolled to the top after
|
||||
* every download, and hijacked the sidebar's active folder whenever a
|
||||
* custom target folder was used. In-place reconciliation only touches
|
||||
* the cards that can change as a result of the download:
|
||||
*
|
||||
* - Updates view: once the newest eligible version is installed the
|
||||
* model no longer qualifies, so its cards are removed from the list
|
||||
* (the update flag is model-level, so every visible card of the
|
||||
* model disappears at once).
|
||||
* - Normal listing: the card stays; only the update flag is cleared.
|
||||
* - The model is not in the current view (different folder / filter /
|
||||
* window): nothing changes, which also covers brand-new models whose
|
||||
* card did not exist before.
|
||||
*
|
||||
* The sidebar folder tree is refreshed separately so folder counts
|
||||
* stay accurate without touching the model listing or scroll position.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string|number} opts.modelId CivitAI model id of the downloaded model.
|
||||
* @param {boolean} [opts.isLatestVersion=true] True when the downloaded
|
||||
* version is the newest known remote version, so the update flag can
|
||||
* be cleared. When false (user deliberately picked an older version)
|
||||
* the list is left untouched.
|
||||
* @param {boolean} [opts.refreshSidebar=true] Whether to refresh the
|
||||
* sidebar folder tree afterwards (batch callers batch this into a
|
||||
* single refresh).
|
||||
* @returns {Promise<boolean>} True when an in-place update was applied.
|
||||
*/
|
||||
async _reconcileViewAfterDownload({ modelId, isLatestVersion = true, refreshSidebar = true } = {}) {
|
||||
const scroller = state?.virtualScroller;
|
||||
const items = Array.isArray(scroller?.items) ? scroller.items : [];
|
||||
|
||||
// No virtual scroller (page without one, not on a listing page,
|
||||
// recipes duplicates mode, ...) — fall back to the legacy reload.
|
||||
if (!scroller || items.length === 0 || typeof scroller.removeMultipleItemsByFilePath !== 'function') {
|
||||
await resetAndReload(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (modelId == null) {
|
||||
// No CivitAI identity (e.g. HF downloads) — nothing to reconcile.
|
||||
await this._refreshSidebarAfterReconcile(refreshSidebar);
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = String(modelId);
|
||||
const matches = items.filter(item => {
|
||||
const civitai = item?.civitai;
|
||||
return civitai != null && String(civitai.modelId) === key;
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
// Downloaded model is not visible in the current view — keep the
|
||||
// listing untouched, only refresh folder counts.
|
||||
await this._refreshSidebarAfterReconcile(refreshSidebar);
|
||||
return false;
|
||||
}
|
||||
|
||||
const pageState = this.apiClient?.getPageState ? this.apiClient.getPageState() : null;
|
||||
const updatesView = pageState?.showUpdateAvailableOnly === true;
|
||||
|
||||
if (updatesView && isLatestVersion) {
|
||||
const paths = matches.map(match => match.file_path).filter(Boolean);
|
||||
if (paths.length > 0) {
|
||||
scroller.removeMultipleItemsByFilePath(paths);
|
||||
}
|
||||
} else if (!updatesView && isLatestVersion) {
|
||||
for (const match of matches) {
|
||||
if (match.file_path) {
|
||||
scroller.updateSingleItem(match.file_path, { update_available: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
// isLatestVersion === false: deliberately downloading an older
|
||||
// version keeps the update flag — nothing changes in the list.
|
||||
|
||||
await this._refreshSidebarAfterReconcile(refreshSidebar);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the listing after a batch download. CivitAI models are
|
||||
* matched card-by-card via `_reconcileViewAfterDownload`; HF
|
||||
* downloads (no CivitAI identity to match) keep the legacy reload.
|
||||
*/
|
||||
async _reconcileBatchViewAfterDownload(completedCivitaiItems = [], hfCompletedCount = 0) {
|
||||
if (hfCompletedCount > 0) {
|
||||
await resetAndReload(true);
|
||||
return;
|
||||
}
|
||||
const scroller = state?.virtualScroller;
|
||||
if (!scroller || !Array.isArray(scroller.items)) {
|
||||
await resetAndReload(true);
|
||||
return;
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const item of completedCivitaiItems) {
|
||||
const modelId = item?.modelId;
|
||||
if (modelId == null || seen.has(String(modelId))) {
|
||||
continue;
|
||||
}
|
||||
seen.add(String(modelId));
|
||||
await this._reconcileViewAfterDownload({
|
||||
modelId,
|
||||
isLatestVersion: this._isVersionLatest(item.selectedVersion?.id, item.versions),
|
||||
refreshSidebar: false,
|
||||
});
|
||||
}
|
||||
await this._refreshSidebarAfterReconcile(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the sidebar folder tree (counts only — never the model
|
||||
* listing). Lazy import keeps SidebarManager out of DownloadManager's
|
||||
* load graph (it transitively imports BulkManager and friends).
|
||||
*/
|
||||
async _refreshSidebarAfterReconcile(shouldRefresh) {
|
||||
if (shouldRefresh === false) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { sidebarManager } = await import('../components/SidebarManager.js');
|
||||
if (sidebarManager && typeof sidebarManager.refresh === 'function') {
|
||||
await sidebarManager.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Failed to refresh sidebar after download:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `versionId` is the newest known remote version of the
|
||||
* versions list. Unknown/missing lists are treated as "latest" so the
|
||||
* common download-the-update flow reconciles by default; callers that
|
||||
* know the remote version set pass an explicit flag instead.
|
||||
*/
|
||||
_isVersionLatest(versionId, versions) {
|
||||
if (!Array.isArray(versions) || versions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
let maxId = null;
|
||||
for (const version of versions) {
|
||||
const id = Number(version?.id ?? version?.versionId);
|
||||
if (!Number.isFinite(id)) {
|
||||
continue;
|
||||
}
|
||||
if (maxId === null || id > maxId) {
|
||||
maxId = id;
|
||||
}
|
||||
}
|
||||
if (maxId === null) {
|
||||
return true;
|
||||
}
|
||||
const target = Number(versionId);
|
||||
if (!Number.isFinite(target)) {
|
||||
return true;
|
||||
}
|
||||
return target >= maxId;
|
||||
}
|
||||
|
||||
/** True when the currently selected version is the newest remote one. */
|
||||
_isDownloadingLatestVersion(versionId) {
|
||||
return this._isVersionLatest(versionId, this.versions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download multiple selected files of the same version sequentially,
|
||||
* reusing the location-step choices for every file. Per-file toasts,
|
||||
@@ -1364,7 +1528,15 @@ export class DownloadManager {
|
||||
});
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
// Full success: reconcile the model's cards in place. On partial
|
||||
// failure keep the listing untouched so the still-outdated version
|
||||
// flags survive until the user retries the remaining files.
|
||||
if (failedItems.length === 0) {
|
||||
await this._reconcileViewAfterDownload({
|
||||
modelId: this.modelId,
|
||||
isLatestVersion: this._isDownloadingLatestVersion(this.currentVersion?.id),
|
||||
});
|
||||
}
|
||||
return failedItems.length === 0;
|
||||
}
|
||||
|
||||
@@ -1961,6 +2133,11 @@ export class DownloadManager {
|
||||
let failedDownloads = 0;
|
||||
let cancelled = false;
|
||||
const failedItems = [];
|
||||
// Successful CivitAI items are reconciled in place afterwards
|
||||
// (their cards can be matched by model id); HF items keep the
|
||||
// legacy full reload because they have no CivitAI identity (#1078).
|
||||
const completedCivitaiItems = [];
|
||||
let hfCompletedCount = 0;
|
||||
|
||||
loadingManager.showCancelButton(async () => {
|
||||
if (cancelled) return;
|
||||
@@ -2065,6 +2242,11 @@ export class DownloadManager {
|
||||
} else {
|
||||
completedDownloads++;
|
||||
updateProgress(100, completedDownloads, '');
|
||||
if (isHf) {
|
||||
hfCompletedCount++;
|
||||
} else {
|
||||
completedCivitaiItems.push(item);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
@@ -2095,7 +2277,7 @@ export class DownloadManager {
|
||||
});
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
await this._reconcileBatchViewAfterDownload(completedCivitaiItems, hfCompletedCount);
|
||||
}
|
||||
|
||||
async downloadVersionWithDefaults(modelType, modelId, versionId, {
|
||||
@@ -2104,7 +2286,8 @@ export class DownloadManager {
|
||||
modelRoot = '',
|
||||
targetFolder = '',
|
||||
useDefaultPaths = null,
|
||||
useSaveDirAsRoot = false
|
||||
useSaveDirAsRoot = false,
|
||||
isLatestVersion = null,
|
||||
} = {}) {
|
||||
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
|
||||
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
|
||||
@@ -2129,6 +2312,7 @@ export class DownloadManager {
|
||||
useSaveDirAsRoot,
|
||||
source,
|
||||
closeModal: false,
|
||||
isLatestVersion,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
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,
|
||||
SUMMARY_MODULE,
|
||||
SIDEBAR_MANAGER_MODULE,
|
||||
mockApiClient,
|
||||
mockScroller,
|
||||
stateMock,
|
||||
mockLoadingManager,
|
||||
showToastMock,
|
||||
resetAndReloadMock,
|
||||
setStorageItemMock,
|
||||
sidebarRefreshMock,
|
||||
} = vi.hoisted(() => {
|
||||
const mockScroller = {
|
||||
items: [],
|
||||
removeItemByFilePath: vi.fn(),
|
||||
removeMultipleItemsByFilePath: vi.fn(),
|
||||
updateSingleItem: vi.fn(),
|
||||
};
|
||||
|
||||
const stateMock = {
|
||||
currentPageType: 'loras',
|
||||
global: { settings: {} },
|
||||
loadingManager: null,
|
||||
virtualScroller: mockScroller,
|
||||
};
|
||||
|
||||
const mockApiClient = {
|
||||
apiConfig: {
|
||||
config: {
|
||||
displayName: 'LoRA',
|
||||
singularName: 'lora',
|
||||
},
|
||||
},
|
||||
modelType: 'loras',
|
||||
getPageState: vi.fn(() => ({})),
|
||||
downloadModel: vi.fn(),
|
||||
downloadHfModel: vi.fn(),
|
||||
cancelDownload: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLoadingManager = {
|
||||
showSimpleLoading: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
restoreProgressBar: vi.fn(),
|
||||
showDownloadProgress: vi.fn(() => vi.fn()),
|
||||
showCancelButton: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
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,
|
||||
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
|
||||
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||
mockApiClient,
|
||||
mockScroller,
|
||||
stateMock,
|
||||
mockLoadingManager,
|
||||
showToastMock: vi.fn(),
|
||||
resetAndReloadMock: vi.fn(),
|
||||
setStorageItemMock: vi.fn(),
|
||||
sidebarRefreshMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
showModal: vi.fn(),
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: showToastMock,
|
||||
}));
|
||||
|
||||
vi.mock(STATE_MODULE, () => ({
|
||||
state: stateMock,
|
||||
}));
|
||||
|
||||
vi.mock(LOADING_MANAGER_MODULE, () => ({
|
||||
LoadingManager: vi.fn(() => mockLoadingManager),
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
getModelApiClient: vi.fn(() => mockApiClient),
|
||||
resetAndReload: resetAndReloadMock,
|
||||
}));
|
||||
|
||||
vi.mock(STORAGE_HELPERS_MODULE, () => ({
|
||||
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
|
||||
setStorageItem: setStorageItemMock,
|
||||
}));
|
||||
|
||||
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 ?? ''),
|
||||
}));
|
||||
|
||||
vi.mock(SUMMARY_MODULE, () => ({
|
||||
showDownloadBatchSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
|
||||
sidebarManager: { refresh: sidebarRefreshMock },
|
||||
}));
|
||||
|
||||
/** Minimal WebSocket stub: executeDownloadWithProgress never awaits open. */
|
||||
class FakeWebSocket {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = 0; // CONNECTING
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
|
||||
/** Build a card item as returned by the backend listing endpoint. */
|
||||
function makeItem(filePath, modelId, base) {
|
||||
return {
|
||||
file_path: filePath,
|
||||
civitai: { modelId, ...(base ? { baseModel: base } : {}) },
|
||||
update_available: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DownloadManager post-download in-place reconciliation (#1078)', () => {
|
||||
let DownloadManager;
|
||||
let manager;
|
||||
|
||||
beforeEach(async () => {
|
||||
document.body.innerHTML = '';
|
||||
stateMock.virtualScroller = mockScroller;
|
||||
mockScroller.items = [];
|
||||
mockScroller.removeItemByFilePath.mockReset();
|
||||
mockScroller.removeMultipleItemsByFilePath.mockReset();
|
||||
mockScroller.updateSingleItem.mockReset();
|
||||
mockApiClient.getPageState.mockReset();
|
||||
mockApiClient.getPageState.mockReturnValue({});
|
||||
mockApiClient.downloadModel.mockReset();
|
||||
resetAndReloadMock.mockReset();
|
||||
resetAndReloadMock.mockResolvedValue(undefined);
|
||||
sidebarRefreshMock.mockReset();
|
||||
sidebarRefreshMock.mockResolvedValue(undefined);
|
||||
setStorageItemMock.mockReset();
|
||||
showToastMock.mockClear();
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||
|
||||
vi.resetModules();
|
||||
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
|
||||
manager = new DownloadManager();
|
||||
manager.apiClient = mockApiClient;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
stateMock.virtualScroller = mockScroller;
|
||||
});
|
||||
|
||||
describe('_isVersionLatest', () => {
|
||||
it('treats unknown/empty version lists as latest', () => {
|
||||
expect(manager._isVersionLatest('250', [])).toBe(true);
|
||||
expect(manager._isVersionLatest('250', null)).toBe(true);
|
||||
expect(manager._isVersionLatest(undefined, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true only for the newest known remote version', () => {
|
||||
const versions = [{ id: 100 }, { id: 250 }, { id: 30 }];
|
||||
expect(manager._isVersionLatest(250, versions)).toBe(true);
|
||||
expect(manager._isVersionLatest('250', versions)).toBe(true);
|
||||
expect(manager._isVersionLatest(100, versions)).toBe(false);
|
||||
expect(manager._isVersionLatest(30, versions)).toBe(false);
|
||||
});
|
||||
|
||||
it('supports record-style version objects (versionId field)', () => {
|
||||
const versions = [{ versionId: 10 }, { versionId: 20 }];
|
||||
expect(manager._isVersionLatest(20, versions)).toBe(true);
|
||||
expect(manager._isVersionLatest(10, versions)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when the target id is unknown', () => {
|
||||
const versions = [{ id: 100 }];
|
||||
expect(manager._isVersionLatest('not-a-number', versions)).toBe(true);
|
||||
expect(manager._isVersionLatest(undefined, versions)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_reconcileViewAfterDownload', () => {
|
||||
it('removes the model cards in the Updates view when the latest version was installed', async () => {
|
||||
stateMock.virtualScroller.items = [
|
||||
makeItem('/models/loras/old.safetensors', 837884),
|
||||
makeItem('/models/loras/unrelated.safetensors', 999999),
|
||||
];
|
||||
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
|
||||
|
||||
const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: true });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledWith([
|
||||
'/models/loras/old.safetensors',
|
||||
]);
|
||||
expect(mockScroller.updateSingleItem).not.toHaveBeenCalled();
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
// Sidebar (folder counts) is refreshed, but never the model listing.
|
||||
expect(sidebarRefreshMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('patches the update flag instead of removing cards in a normal listing', async () => {
|
||||
stateMock.virtualScroller.items = [
|
||||
makeItem('/models/loras/old.safetensors', 837884),
|
||||
];
|
||||
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: false });
|
||||
|
||||
const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: true });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockScroller.updateSingleItem).toHaveBeenCalledWith(
|
||||
'/models/loras/old.safetensors',
|
||||
{ update_available: false }
|
||||
);
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled();
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
expect(sidebarRefreshMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves the list untouched when an older version was deliberately downloaded', async () => {
|
||||
stateMock.virtualScroller.items = [makeItem('/models/loras/old.safetensors', 837884)];
|
||||
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
|
||||
|
||||
const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: false });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled();
|
||||
expect(mockScroller.updateSingleItem).not.toHaveBeenCalled();
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
expect(sidebarRefreshMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the listing untouched when the downloaded model is not in the current view', async () => {
|
||||
stateMock.virtualScroller.items = [makeItem('/models/loras/other.safetensors', 999999)];
|
||||
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
|
||||
|
||||
const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: true });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled();
|
||||
expect(mockScroller.updateSingleItem).not.toHaveBeenCalled();
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
expect(sidebarRefreshMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('no-ops for models without a CivitAI identity (HF downloads)', async () => {
|
||||
stateMock.virtualScroller.items = [makeItem('/models/loras/a.safetensors', 123)];
|
||||
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
|
||||
|
||||
const result = await manager._reconcileViewAfterDownload({ modelId: null, isLatestVersion: true });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled();
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
expect(sidebarRefreshMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to a full reload when no virtual scroller is available', async () => {
|
||||
stateMock.virtualScroller = undefined;
|
||||
|
||||
const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: true });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled();
|
||||
expect(sidebarRefreshMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_reconcileBatchViewAfterDownload', () => {
|
||||
it('reconciles each distinct successful CivitAI model and refreshes the sidebar once', async () => {
|
||||
stateMock.virtualScroller.items = [
|
||||
makeItem('/models/loras/a.safetensors', 111),
|
||||
makeItem('/models/loras/b.safetensors', 222),
|
||||
];
|
||||
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
|
||||
|
||||
await manager._reconcileBatchViewAfterDownload([
|
||||
{ modelId: '111', selectedVersion: { id: 40 }, versions: [{ id: 40 }, { id: 10 }] },
|
||||
{ modelId: '111', selectedVersion: { id: 40 }, versions: [{ id: 40 }, { id: 10 }] },
|
||||
{ modelId: '222', selectedVersion: { id: 7 }, versions: [{ id: 7 }] },
|
||||
], 0);
|
||||
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledTimes(2);
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledWith(['/models/loras/a.safetensors']);
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledWith(['/models/loras/b.safetensors']);
|
||||
// One sidebar refresh for the whole batch, not one per model.
|
||||
expect(sidebarRefreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to a full reload when any HF download completed', async () => {
|
||||
stateMock.virtualScroller.items = [makeItem('/models/loras/a.safetensors', 111)];
|
||||
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
|
||||
|
||||
await manager._reconcileBatchViewAfterDownload([
|
||||
{ modelId: '111', selectedVersion: { id: 40 }, versions: [{ id: 40 }, { id: 10 }] },
|
||||
], 1);
|
||||
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled();
|
||||
expect(sidebarRefreshMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to a full reload when no virtual scroller exists', async () => {
|
||||
stateMock.virtualScroller = undefined;
|
||||
|
||||
await manager._reconcileBatchViewAfterDownload([
|
||||
{ modelId: '111', selectedVersion: { id: 40 }, versions: [{ id: 40 }, { id: 10 }] },
|
||||
], 0);
|
||||
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeDownloadWithProgress success path', () => {
|
||||
it('reconciles in place instead of hijacking the active folder or reloading', async () => {
|
||||
stateMock.virtualScroller.items = [makeItem('/models/loras/old.safetensors', 837884)];
|
||||
const pageState = { showUpdateAvailableOnly: true };
|
||||
mockApiClient.getPageState.mockReturnValue(pageState);
|
||||
mockApiClient.downloadModel.mockResolvedValue({ success: true });
|
||||
manager.versions = [{ id: 250 }, { id: 100 }];
|
||||
|
||||
const result = await manager.executeDownloadWithProgress({
|
||||
modelId: 837884,
|
||||
versionId: 250,
|
||||
versionName: 'v2',
|
||||
targetFolder: 'Some/SubFolder',
|
||||
useDefaultPaths: false,
|
||||
source: 'civitai',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
// The download destination folder must never become the active folder.
|
||||
expect(pageState).toEqual({ showUpdateAvailableOnly: true });
|
||||
expect(setStorageItemMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('_activeFolder'),
|
||||
expect.anything()
|
||||
);
|
||||
// Card reconciled in place; no full page reload, no scroll reset.
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledWith([
|
||||
'/models/loras/old.safetensors',
|
||||
]);
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
expect(sidebarRefreshMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to a full reload when no virtual scroller is available', async () => {
|
||||
stateMock.virtualScroller = undefined;
|
||||
mockApiClient.downloadModel.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await manager.executeDownloadWithProgress({
|
||||
modelId: 837884,
|
||||
versionId: 250,
|
||||
source: 'civitai',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('passes through an explicit isLatestVersion flag', async () => {
|
||||
stateMock.virtualScroller.items = [makeItem('/models/loras/old.safetensors', 837884)];
|
||||
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
|
||||
mockApiClient.downloadModel.mockResolvedValue({ success: true });
|
||||
manager.versions = [{ id: 250 }];
|
||||
|
||||
await manager.executeDownloadWithProgress({
|
||||
modelId: 837884,
|
||||
versionId: 100,
|
||||
source: 'civitai',
|
||||
isLatestVersion: false,
|
||||
});
|
||||
|
||||
// Deliberately downloading an older version keeps the card.
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled();
|
||||
expect(mockScroller.updateSingleItem).not.toHaveBeenCalled();
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_downloadSelectedFilesSequentially success path', () => {
|
||||
it('reconciles in place once all files of the latest version are downloaded', async () => {
|
||||
stateMock.virtualScroller.items = [makeItem('/models/loras/old.safetensors', 837884)];
|
||||
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
|
||||
mockApiClient.downloadModel.mockResolvedValue({ success: true });
|
||||
manager.modelId = '837884';
|
||||
manager.currentVersion = { id: 201 };
|
||||
manager.versions = [{ id: 201 }, { id: 100 }];
|
||||
manager.source = 'civitai';
|
||||
manager.selectedFiles = [
|
||||
{ id: 1, name: 'a.safetensors', type: 'Model', sizeKB: 10 },
|
||||
{ id: 2, name: 'b.safetensors', type: 'Model', sizeKB: 10 },
|
||||
];
|
||||
|
||||
const result = await manager._downloadSelectedFilesSequentially({
|
||||
modelRoot: '/models/loras',
|
||||
targetFolder: '',
|
||||
useDefaultPaths: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledWith([
|
||||
'/models/loras/old.safetensors',
|
||||
]);
|
||||
expect(sidebarRefreshMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the listing untouched on partial multi-file failure', async () => {
|
||||
stateMock.virtualScroller.items = [makeItem('/models/loras/old.safetensors', 837884)];
|
||||
mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true });
|
||||
mockApiClient.downloadModel
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
.mockResolvedValueOnce({ success: false, error: 'rate limited' });
|
||||
manager.modelId = '837884';
|
||||
manager.currentVersion = { id: 201 };
|
||||
manager.versions = [{ id: 201 }, { id: 100 }];
|
||||
manager.source = 'civitai';
|
||||
manager.selectedFiles = [
|
||||
{ id: 1, name: 'a.safetensors', type: 'Model', sizeKB: 10 },
|
||||
{ id: 2, name: 'b.safetensors', type: 'Model', sizeKB: 10 },
|
||||
];
|
||||
|
||||
const result = await manager._downloadSelectedFilesSequentially({
|
||||
modelRoot: '/models/loras',
|
||||
targetFolder: '',
|
||||
useDefaultPaths: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled();
|
||||
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user