Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions __tests__/HuggingFaceModelPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,36 @@ describe('Hugging Face binding interactions', () => {
fireEvent.change(screen.getByLabelText('Revision'), { target: { value: 'main' } });
expect(screen.queryByText('Confirm manual link')).toBeNull();
});
it('routes verification and unlink to the selected copy without exposing HF monitoring', async () => {
it('routes verification and unlink to the selected copy and leaves monitoring off by default', async () => {
const first = makeItem('one', 'a'.repeat(64)), second = makeItem('two', 'a'.repeat(64));
second.location.huggingFace = { repoId: 'other/repo', filePath: 'model.safetensors', linkedRevision: 'main', resolvedCommit: 'b'.repeat(40), linkedRemoteFingerprint: 'git:oid:' + 'c'.repeat(40), size: 100, verification: 'manual', fetchedAt: 1 };
fakes.manager.catalog.locations = [first.location, second.location];
render(<HuggingFaceModelPanel item={first} />);
fireEvent.change(screen.getByLabelText('File location'), { target: { value: 'two' } });
expect(screen.getByText('other/repo')).toBeTruthy();
expect(screen.queryByText(/monitoring/i)).toBeNull();
expect(screen.getByText('Hugging Face monitoring (off)')).toBeTruthy();
expect((screen.getByLabelText('Enable automatic Hugging Face checks') as HTMLInputElement).checked).toBe(false);
fireEvent.click(screen.getByText('Verify file match'));
await waitFor(() => expect(fakes.command).toHaveBeenLastCalledWith({ type: 'verifyHF', locationId: 'two' }));
fireEvent.click(screen.getByText('Remove Hugging Face link'));
await waitFor(() => expect(fakes.command).toHaveBeenLastCalledWith({ type: 'unbindHF', locationId: 'two' }));
});
it('saves monitoring only after explicit confirmation and preserves the original link revision', async () => {
const item = makeItem();
item.location.huggingFace = { repoId: 'owner/repo', filePath: 'folder/one.safetensors', linkedRevision: 'release/v1', resolvedCommit: 'b'.repeat(40), linkedRemoteFingerprint: lookup.files[0].fingerprint, verification: 'manual', size: 100, fetchedAt: 1 };
render(<HuggingFaceModelPanel item={item} />);
expect((screen.getByLabelText('Tracked revision') as HTMLInputElement).value).toBe('main');
expect((screen.getByLabelText('Watched folder (empty for root)') as HTMLInputElement).value).toBe('folder');
expect(fakes.command).not.toHaveBeenCalled();
fireEvent.click(screen.getByLabelText('Enable automatic Hugging Face checks'));
fireEvent.change(screen.getByLabelText('Tracked revision'), { target: { value: 'release/v2' } });
fireEvent.change(screen.getByLabelText('Watched folder (empty for root)'), { target: { value: '' } });
fireEvent.click(screen.getByLabelText('Include subfolders'));
expect(fakes.command).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('Save HF monitoring'));
await waitFor(() => expect(fakes.command).toHaveBeenLastCalledWith({ type: 'configureHF', locationId: 'synthetic', config: { trackedRevision: 'release/v2', watchedDirectory: '', recursive: true, monitoringEnabled: true } }));
expect(item.location.huggingFace.linkedRevision).toBe('release/v1');
});
it('discards a lookup response after switching to another copy', async () => {
const first = makeItem('one', 'a'.repeat(64)), second = makeItem('two', 'a'.repeat(64));
fakes.manager.catalog.locations = [first.location, second.location];
Expand Down
71 changes: 71 additions & 0 deletions __tests__/ModelsWorkspace.providers.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ModelManagerSnapshot, ModelWatchRecord } from '../services/modelLibrary/types';
import { emptyHuggingFaceWatch } from '../services/modelLibrary/huggingFaceTracking';

const fakes = vi.hoisted(() => ({ manager: {} as ModelManagerSnapshot, check: vi.fn(), identify: vi.fn() }));
vi.mock('../services/modelLibrary/manager', () => ({ useModelManager: () => fakes.manager, getModelManagerState: () => fakes.manager, installedVersions: (modelId: number) => fakes.manager.catalog.locations.flatMap((location) => location.civitai && 'modelId' in location.civitai && location.civitai.modelId === modelId ? [location.civitai] : []), unreadModelCount: () => 1, checkModelUpdates: (...args: unknown[]) => fakes.check(...args), identifyModels: (...args: unknown[]) => fakes.identify(...args), addModelSource: vi.fn(), cancelModelJob: vi.fn(), managerMessage: vi.fn(), removeModelSource: vi.fn(), scanModelSources: vi.fn(), setModelInterval: vi.fn(), showModelUpdates: (showUpdates: boolean) => { fakes.manager.showUpdates = showUpdates; }, updateModelSource: vi.fn() }));
vi.mock('../components/ModelManagerPanels', () => ({ executeModelCommand: vi.fn(), ModelActionsPanel: () => null, ModelLocalEditor: () => null, ModelMediaPanel: () => null, ReleaseGroup: ({ watch }: { watch: ModelWatchRecord }) => <p>Civitai release group: {watch.modelName}</p>, modelButton: '', modelInput: '' }));
vi.mock('../components/ModelStoragePanel', () => ({ default: () => null, ModelRemovalDialog: () => null }));
vi.mock('../components/ModelUsagePanel', () => ({ default: () => null }));
vi.mock('../components/HuggingFaceModelPanel', () => ({ HuggingFaceModelPanel: ({ revealUpdates }: { revealUpdates: number }) => <p>HF reveal {revealUpdates}</p> }));
import ModelsWorkspace from '../components/ModelsWorkspace';

beforeEach(() => {
fakes.check.mockReset().mockResolvedValue(undefined); fakes.identify.mockReset().mockResolvedValue(true);
const binding = { repoId: 'owner/repo', filePath: 'model.safetensors', linkedRevision: 'main', resolvedCommit: 'b'.repeat(40), linkedRemoteFingerprint: 'git:oid:' + 'a'.repeat(40), verification: 'manual' as const, size: 100, fetchedAt: 1 };
const watch = emptyHuggingFaceWatch(binding);
watch.events = [{ id: 'event', source: 'huggingFace', kind: 'newModelFile', path: 'new.safetensors', fingerprint: 'git:oid:' + 'c'.repeat(40), commit: 'b'.repeat(40), detectedAt: 1 }];
fakes.manager = { revision: 1, sources: [{ id: 's', name: 'Models', path: '/synthetic', kind: 'lora', recursive: true, createdAt: 1, updatedAt: 1 }], watches: {}, hfWatches: { [watch.id]: watch }, localMetadata: {}, intervalHours: 24, loading: false, progress: null, message: null, notification: null, catalog: { version: 1, updatedAt: 1, locations: [{ id: 'one', sourceId: 's', sourceName: 'Models', sourceKind: 'lora', fileName: 'one.safetensors', relativePath: 'one.safetensors', absolutePath: '/synthetic/one.safetensors', size: 100, createdAt: 1, modifiedAt: 1, discoveredAt: 1, lastSeenAt: 1, huggingFace: binding }] } };
Object.defineProperty(window, 'electronAPI', { configurable: true, value: { modelInspectorSyncCollection: vi.fn().mockResolvedValue(undefined), modelInspectorSyncSelection: vi.fn() } });
});
afterEach(cleanup);

describe('HF catalog update entry points', () => {
it('checks an HF-only model from the toolbar without presenting Civitai identification', async () => {
render(<ModelsWorkspace />);
fireEvent.click(screen.getByText('Check for updates · 1 model'));
await waitFor(() => expect(fakes.check).toHaveBeenCalledExactlyOnceWith(['one']));
expect(screen.queryByRole('dialog')).toBeNull(); expect(fakes.identify).not.toHaveBeenCalled();
expect((screen.getByText('Identify models on Civitai…') as HTMLButtonElement).disabled).toBe(false);
});
it('includes HF events in With updates and reveals their detail from the update indicator', () => {
render(<ModelsWorkspace />);
fireEvent.click(screen.getByText('With updates'));
expect(screen.getByRole('button', { name: 'Select one' })).toBeTruthy();
fireEvent.click(screen.getByText(/1 new update/));
expect(screen.getByText('HF reveal 1')).toBeTruthy();
});
it.each(['huggingFace', 'civitai'] as const)('shows %s updates linked to another copy of a model in the selected folder', (provider) => {
const linked = { ...fakes.manager.catalog.locations[0], sha256: 'a'.repeat(64) };
const unrelated = { ...linked, id: 'unrelated', sha256: 'b'.repeat(64), fileName: 'unrelated.safetensors' };
if (provider === 'huggingFace') {
unrelated.huggingFace = { ...linked.huggingFace!, repoId: 'other/repo' };
const watch = emptyHuggingFaceWatch(unrelated.huggingFace);
watch.events = [{ ...Object.values(fakes.manager.hfWatches!)[0].events[0], id: 'unrelated-event' }];
fakes.manager.hfWatches![watch.id] = watch;
} else {
linked.huggingFace = undefined;
unrelated.huggingFace = undefined;
linked.civitai = { modelId: 1, versionId: 1, modelName: 'Target', versionName: 'Installed', trainedWords: [], fetchedAt: 1, url: '' };
unrelated.civitai = { ...linked.civitai, modelId: 2, modelName: 'Unrelated' };
const watch: ModelWatchRecord = { id: '1', modelId: 1, modelName: 'Target', versions: [{ id: 3, name: 'New release', description: '', url: '' }], novelVersionIds: [3], knownVersionIds: [1, 3], seenVersionIds: [], ignoredVersionIds: [], notifiedVersionIds: [] };
fakes.manager.watches = { '1': watch, '2': { ...watch, id: '2', modelId: 2, modelName: 'Unrelated' } };
}
const copy = { ...linked, id: 'copy', sourceId: 'selected', sourceName: 'Selected folder', fileName: 'copy.safetensors', relativePath: 'copy.safetensors', absolutePath: '/synthetic/selected/copy.safetensors', huggingFace: undefined, civitai: undefined };
fakes.manager.sources.push({ ...fakes.manager.sources[0], id: 'selected', name: 'Selected folder', path: '/synthetic/selected' });
fakes.manager.catalog.locations = [copy, linked, unrelated];
const view = render(<ModelsWorkspace />);
fireEvent.change(screen.getByRole('combobox', { name: 'Model folder' }), { target: { value: 'selected' } });
fireEvent.click(screen.getByText('With updates'));
expect(screen.getByRole('button', { name: 'Select copy' })).toBeTruthy();
expect(screen.getByText('1 new update')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /What's new/ }));
view.rerender(<ModelsWorkspace />);
expect(screen.queryByText(/You're caught up/)).toBeNull();
expect(screen.getByText(provider === 'huggingFace' ? '1 model · 0 Civitai releases · 1 Hugging Face events' : '1 model · 1 Civitai releases · 0 Hugging Face events')).toBeTruthy();
expect(screen.getByText(provider === 'huggingFace' ? /Hugging Face · owner\/repo/ : 'Civitai release group: Target')).toBeTruthy();
expect(screen.queryByText(/other\/repo|Civitai release group: Unrelated/)).toBeNull();
});
});
63 changes: 63 additions & 0 deletions __tests__/UnifiedModelUpdatesPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ModelManagerSnapshot } from '../services/modelLibrary/types';
import { emptyHuggingFaceWatch, huggingFaceWatchId } from '../services/modelLibrary/huggingFaceTracking';

const fakes = vi.hoisted(() => ({ command: vi.fn(), manager: {} as ModelManagerSnapshot, observers: [] as IntersectionObserverCallback[] }));
vi.mock('../services/modelLibrary/manager', () => ({ useModelManager: () => fakes.manager, installedVersions: () => [{ versionId: 2 }] }));
vi.mock('../components/ModelManagerPanels', () => ({ executeModelCommand: (command: unknown) => fakes.command(command), modelButton: '', ReleaseGroup: ({ watch }: { watch: { modelName: string } }) => <p>Civitai · {watch.modelName}</p> }));
import { ModelUpdatesPanel, HuggingFaceReleaseGroup } from '../components/UnifiedModelUpdatesPanel';

const binding = { repoId: 'owner/repo', filePath: 'model.safetensors', linkedRevision: 'tag', linkedRemoteFingerprint: 'git:oid:' + 'a'.repeat(40), resolvedCommit: 'b'.repeat(40), verification: 'manual' as const, size: 100, fetchedAt: 1 };
beforeEach(() => {
vi.useFakeTimers(); fakes.command.mockReset().mockResolvedValue(undefined); fakes.observers = [];
const hf = emptyHuggingFaceWatch(binding);
hf.events = ['one', 'two'].map((id) => ({ id, source: 'huggingFace', kind: 'newModelFile', path: `${id}.safetensors`, fingerprint: `git:oid:${'c'.repeat(40)}`, commit: 'b'.repeat(40), detectedAt: 1 }));
fakes.manager = { revision: 1, sources: [], localMetadata: {}, watches: { '1': { id: '1', modelId: 1, modelName: 'Synthetic', versions: [{ id: 3, name: 'New', description: '', url: '' }], knownVersionIds: [3], novelVersionIds: [3], seenVersionIds: [], ignoredVersionIds: [], notifiedVersionIds: [] } }, hfWatches: { [hf.id]: hf }, intervalHours: 24, loading: false, progress: null, message: null, notification: null, catalog: { version: 1, updatedAt: 1, locations: [{ id: 'one', sha256: 'a'.repeat(64), sourceId: 's', sourceName: 'Models', sourceKind: 'lora', fileName: 'one.safetensors', relativePath: 'one.safetensors', absolutePath: '/synthetic/one.safetensors', size: 100, modifiedAt: 1, createdAt: 1, discoveredAt: 1, lastSeenAt: 1, huggingFace: binding, civitai: { modelId: 1, versionId: 2, modelName: 'Synthetic', versionName: 'Installed', trainedWords: [], fetchedAt: 1, url: '' } }] } };
vi.stubGlobal('IntersectionObserver', class { constructor(callback: IntersectionObserverCallback) { fakes.observers.push(callback); } observe() {} disconnect() {} });
});
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });

describe('provider-aware model updates', () => {
it('counts one logical model across providers and keeps provider event counts distinct', () => {
render(<ModelUpdatesPanel />);
expect(screen.getByText('1 model · 1 Civitai releases · 2 Hugging Face events')).toBeTruthy();
expect(screen.getByText('Civitai · Synthetic')).toBeTruthy();
expect(screen.getByText(/Hugging Face · owner\/repo/)).toBeTruthy();
expect(fakes.command).not.toHaveBeenCalled(); expect(fakes.observers).toHaveLength(0);
fireEvent.click(screen.getByText('Mark all as viewed'));
expect(fakes.command).toHaveBeenCalledWith({ type: 'seen', modelId: 1, versionIds: [3] });
expect(fakes.command).toHaveBeenCalledWith({ type: 'hfEventAction', watchId: huggingFaceWatchId(binding), action: 'seen', eventIds: ['one', 'two'] });
});
it('marks only visible HF events, cancels the dwell timer when a row leaves the viewport and retains history', async () => {
const watch = fakes.manager.hfWatches![huggingFaceWatchId(binding)];
render(<HuggingFaceReleaseGroup watch={watch} />);
const detail = screen.getByText(/Hugging Face · owner\/repo/).closest('details')!;
await act(async () => { detail.open = true; fireEvent(detail, new Event('toggle')); });
expect(fakes.observers).toHaveLength(2);
await act(async () => {
fakes.observers[0]([{ isIntersecting: true, intersectionRatio: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
fakes.observers[1]([{ isIntersecting: true, intersectionRatio: 1 } as IntersectionObserverEntry], {} as IntersectionObserver);
await vi.advanceTimersByTimeAsync(100);
fakes.observers[1]([{ isIntersecting: false, intersectionRatio: 0 } as IntersectionObserverEntry], {} as IntersectionObserver);
await vi.advanceTimersByTimeAsync(200);
});
expect(fakes.command).toHaveBeenCalledExactlyOnceWith({ type: 'hfEventAction', watchId: watch.id, eventIds: ['one'], action: 'seen' });
expect(screen.getByText('one.safetensors')).toBeTruthy(); expect(screen.getByText('two.safetensors')).toBeTruthy();
});
it('keeps ignored events out of unread counts and exposes restore independently from Civitai', async () => {
const watch = fakes.manager.hfWatches![huggingFaceWatchId(binding)]; watch.ignoredEventIds = ['one'];
render(<HuggingFaceReleaseGroup watch={watch} history />);
const detail = screen.getByText(/Hugging Face · owner\/repo/).closest('details')!;
await act(async () => { detail.open = true; fireEvent(detail, new Event('toggle')); });
fireEvent.click(screen.getByText('Restore event'));
expect(fakes.command).toHaveBeenCalledWith({ type: 'hfEventAction', watchId: watch.id, eventIds: ['one'], action: 'restore' });
});
it('opens the HF events when the catalog update indicator requests their presentation', () => {
const watch = fakes.manager.hfWatches![huggingFaceWatchId(binding)];
render(<HuggingFaceReleaseGroup watch={watch} reveal={1} />);
expect(screen.getByText('one.safetensors')).toBeTruthy();
expect(fakes.command).not.toHaveBeenCalled();
});
});
Loading
Loading