From 1f8b62cd89a4d1a5843b9d876d4b74505758759f Mon Sep 17 00:00:00 2001 From: huang47 <157390+huang47@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:37:32 -0700 Subject: [PATCH] test: cover system and workspace stores --- .../electronDownloadStore.nonDesktop.test.ts | 26 ++ src/stores/electronDownloadStore.test.ts | 103 +++++++ src/stores/systemStatsStore.test.ts | 13 +- src/stores/templateRankingStore.test.ts | 6 + src/stores/topbarBadgeStore.test.ts | 25 ++ src/stores/userFileStore.test.ts | 155 +++++++++++ src/stores/userStore.test.ts | 138 ++++++++-- .../workspace/favoritedWidgetsStore.test.ts | 258 ++++++++++++++++++ src/stores/workspaceStore.test.ts | 115 ++++++++ 9 files changed, 820 insertions(+), 19 deletions(-) create mode 100644 src/stores/electronDownloadStore.nonDesktop.test.ts create mode 100644 src/stores/electronDownloadStore.test.ts create mode 100644 src/stores/topbarBadgeStore.test.ts create mode 100644 src/stores/workspace/favoritedWidgetsStore.test.ts create mode 100644 src/stores/workspaceStore.test.ts diff --git a/src/stores/electronDownloadStore.nonDesktop.test.ts b/src/stores/electronDownloadStore.nonDesktop.test.ts new file mode 100644 index 00000000000..67478f3bbf0 --- /dev/null +++ b/src/stores/electronDownloadStore.nonDesktop.test.ts @@ -0,0 +1,26 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useElectronDownloadStore } from '@/stores/electronDownloadStore' + +const electronAPI = vi.hoisted(() => vi.fn()) + +vi.mock('@/platform/distribution/types', () => ({ isDesktop: false })) +vi.mock('@/utils/envUtil', () => ({ electronAPI })) + +describe('electronDownloadStore outside desktop', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + electronAPI.mockClear() + }) + + it('skips the Electron bridge when not running on desktop', async () => { + const store = useElectronDownloadStore() + + await store.initialize() + + expect(electronAPI).not.toHaveBeenCalled() + expect(store.downloads).toEqual([]) + }) +}) diff --git a/src/stores/electronDownloadStore.test.ts b/src/stores/electronDownloadStore.test.ts new file mode 100644 index 00000000000..f881405b30b --- /dev/null +++ b/src/stores/electronDownloadStore.test.ts @@ -0,0 +1,103 @@ +import { DownloadStatus } from '@comfyorg/comfyui-electron-types' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useElectronDownloadStore } from '@/stores/electronDownloadStore' + +const downloadManagerMock = vi.hoisted(() => ({ + cancelDownload: vi.fn(), + getAllDownloads: vi.fn(), + onDownloadProgress: vi.fn(), + pauseDownload: vi.fn(), + resumeDownload: vi.fn(), + startDownload: vi.fn() +})) + +vi.mock('@/platform/distribution/types', () => ({ + isDesktop: true +})) + +vi.mock('@/utils/envUtil', () => ({ + electronAPI: () => ({ + DownloadManager: downloadManagerMock + }) +})) + +describe('electronDownloadStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + Object.values(downloadManagerMock).forEach((mock) => mock.mockReset()) + downloadManagerMock.getAllDownloads.mockResolvedValue([ + { + filename: 'done.bin', + status: DownloadStatus.COMPLETED, + url: 'https://example.com/done.bin' + } + ]) + }) + + it('loads existing downloads and applies progress updates by URL', async () => { + let progressCallback: + | Parameters[0] + | undefined + downloadManagerMock.onDownloadProgress.mockImplementation((callback) => { + progressCallback = callback + }) + const store = useElectronDownloadStore() + + await store.initialize() + progressCallback?.({ + filename: 'model.bin', + progress: 25, + savePath: '/tmp/model.bin', + status: DownloadStatus.IN_PROGRESS, + url: 'https://example.com/model.bin' + }) + progressCallback?.({ + filename: 'model.bin', + progress: 50, + savePath: '/tmp/model.bin', + status: DownloadStatus.IN_PROGRESS, + url: 'https://example.com/model.bin' + }) + + expect(store.findByUrl('https://example.com/done.bin')?.status).toBe( + DownloadStatus.COMPLETED + ) + expect(store.findByUrl('https://example.com/model.bin')).toMatchObject({ + filename: 'model.bin', + progress: 50, + status: DownloadStatus.IN_PROGRESS + }) + expect(store.inProgressDownloads).toHaveLength(1) + }) + + it('delegates download controls to the Electron bridge', async () => { + const store = useElectronDownloadStore() + + await store.start({ + filename: 'model.bin', + savePath: '/tmp/model.bin', + url: 'https://example.com/model.bin' + }) + await store.pause('https://example.com/model.bin') + await store.resume('https://example.com/model.bin') + await store.cancel('https://example.com/model.bin') + + expect(downloadManagerMock.startDownload).toHaveBeenCalledWith( + 'https://example.com/model.bin', + '/tmp/model.bin', + 'model.bin' + ) + expect(downloadManagerMock.pauseDownload).toHaveBeenCalledWith( + 'https://example.com/model.bin' + ) + expect(downloadManagerMock.resumeDownload).toHaveBeenCalledWith( + 'https://example.com/model.bin' + ) + expect(downloadManagerMock.cancelDownload).toHaveBeenCalledWith( + 'https://example.com/model.bin' + ) + }) +}) diff --git a/src/stores/systemStatsStore.test.ts b/src/stores/systemStatsStore.test.ts index 289667958c6..f8c831053cd 100644 --- a/src/stores/systemStatsStore.test.ts +++ b/src/stores/systemStatsStore.test.ts @@ -6,7 +6,7 @@ import type { SystemStats } from '@/schemas/apiSchema' import { api } from '@/scripts/api' import { useSystemStatsStore } from '@/stores/systemStatsStore' -const mockData = vi.hoisted(() => ({ isDesktop: false })) +const mockData = vi.hoisted(() => ({ isCloud: false, isDesktop: false })) // Mock the API vi.mock('@/scripts/api', () => ({ @@ -19,7 +19,9 @@ vi.mock('@/platform/distribution/types', () => ({ get isDesktop() { return mockData.isDesktop }, - isCloud: false + get isCloud() { + return mockData.isCloud + } })) describe('useSystemStatsStore', () => { @@ -138,6 +140,7 @@ describe('useSystemStatsStore', () => { describe('getFormFactor', () => { beforeEach(() => { // Reset systemStats for each test + mockData.isCloud = false store.systemStats = null }) @@ -162,6 +165,12 @@ describe('useSystemStatsStore', () => { expect(store.getFormFactor()).toBe('other') }) + it('should return "cloud" in cloud mode', () => { + mockData.isCloud = true + + expect(store.getFormFactor()).toBe('cloud') + }) + describe('desktop environment', () => { beforeEach(() => { mockData.isDesktop = true diff --git a/src/stores/templateRankingStore.test.ts b/src/stores/templateRankingStore.test.ts index 12e7950fad4..f86e85391c8 100644 --- a/src/stores/templateRankingStore.test.ts +++ b/src/stores/templateRankingStore.test.ts @@ -90,6 +90,12 @@ describe('templateRankingStore', () => { }) describe('computePopularScore', () => { + it('normalizes usage against itself before a largest score is loaded', () => { + const store = useTemplateRankingStore() + + expect(store.computePopularScore('2024-01-01', 10)).toBeGreaterThan(0.8) + }) + it('does not use searchRank', () => { const store = useTemplateRankingStore() store.largestUsageScore = 100 diff --git a/src/stores/topbarBadgeStore.test.ts b/src/stores/topbarBadgeStore.test.ts new file mode 100644 index 00000000000..c8ee3185cfa --- /dev/null +++ b/src/stores/topbarBadgeStore.test.ts @@ -0,0 +1,25 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useExtensionStore } from '@/stores/extensionStore' +import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore' + +describe('topbarBadgeStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('collects topbar badges from registered extensions', () => { + const extensionStore = useExtensionStore() + extensionStore.registerExtension({ + name: 'badges', + topbarBadges: [{ text: 'Beta', label: 'BETA' }] + }) + extensionStore.registerExtension({ name: 'plain' }) + + const store = useTopbarBadgeStore() + + expect(store.badges).toEqual([{ text: 'Beta', label: 'BETA' }]) + }) +}) diff --git a/src/stores/userFileStore.test.ts b/src/stores/userFileStore.test.ts index b94bd983be1..69e3ef95fc3 100644 --- a/src/stores/userFileStore.test.ts +++ b/src/stores/userFileStore.test.ts @@ -116,6 +116,33 @@ describe('useUserFileStore', () => { "Failed to load file 'file1.txt': 404 Not Found" ) }) + + it('should skip loading temporary and already loaded files', async () => { + const temporaryFile = UserFile.createTemporary('draft.txt') + const loadedFile = new UserFile('file1.txt', 123, 100) + loadedFile.content = 'content' + loadedFile.originalContent = 'content' + + await temporaryFile.load() + await loadedFile.load() + + expect(api.getUserData).not.toHaveBeenCalled() + }) + + it('should force reload loaded files', async () => { + const file = new UserFile('file1.txt', 123, 100) + file.content = 'old' + file.originalContent = 'old' + vi.mocked(api.getUserData).mockResolvedValue({ + status: 200, + text: () => Promise.resolve('new') + } as Response) + + await file.load({ force: true }) + + expect(api.getUserData).toHaveBeenCalledWith('file1.txt') + expect(file.content).toBe('new') + }) }) describe('save', () => { @@ -148,6 +175,60 @@ describe('useUserFileStore', () => { expect(api.storeUserData).not.toHaveBeenCalled() }) + + it('should save unmodified files when forced', async () => { + const file = new UserFile('file1.txt', 123, 100) + file.content = 'content' + file.originalContent = 'content' + vi.mocked(api.storeUserData).mockResolvedValue({ + status: 200, + json: () => Promise.resolve('file1.txt') + } as Response) + + await file.save({ force: true }) + + expect(api.storeUserData).toHaveBeenCalledWith('file1.txt', 'content', { + throwOnError: true, + full_info: true, + overwrite: true + }) + expect(file.lastModified).toBe(123) + expect(file.size).toBe(100) + }) + + it('should normalize string modified times', async () => { + const file = new UserFile('file1.txt', 123, 100) + file.content = 'modified content' + file.originalContent = 'original content' + vi.mocked(api.storeUserData).mockResolvedValue({ + status: 200, + json: () => + Promise.resolve({ modified: '2024-01-02T03:04:05Z', size: 200 }) + } as Response) + + await file.save() + + expect(file.lastModified).toBe( + new Date('2024-01-02T03:04:05Z').getTime() + ) + expect(file.size).toBe(200) + }) + + it('should fall back when modified time is invalid', async () => { + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(999) + const file = new UserFile('file1.txt', 123, 100) + file.content = 'modified content' + file.originalContent = 'original content' + vi.mocked(api.storeUserData).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ modified: 'bad date', size: 200 }) + } as Response) + + await file.save() + + expect(file.lastModified).toBe(999) + dateNow.mockRestore() + }) }) describe('delete', () => { @@ -161,6 +242,26 @@ describe('useUserFileStore', () => { expect(api.deleteUserData).toHaveBeenCalledWith('file1.txt') }) + + it('should skip deleting temporary files', async () => { + const file = UserFile.createTemporary('draft.txt') + + await file.delete() + + expect(api.deleteUserData).not.toHaveBeenCalled() + }) + + it('should throw when delete fails', async () => { + const file = new UserFile('file1.txt', 123, 100) + vi.mocked(api.deleteUserData).mockResolvedValue({ + status: 500, + statusText: 'Server Error' + } as Response) + + await expect(file.delete()).rejects.toThrow( + "Failed to delete file 'file1.txt': 500 Server Error" + ) + }) }) describe('rename', () => { @@ -181,6 +282,41 @@ describe('useUserFileStore', () => { expect(file.lastModified).toBe(456) expect(file.size).toBe(200) }) + + it('should rename temporary files locally', async () => { + const file = UserFile.createTemporary('draft.txt') + + await file.rename('renamed.txt') + + expect(api.moveUserData).not.toHaveBeenCalled() + expect(file.path).toBe('renamed.txt') + }) + + it('should throw when rename fails', async () => { + const file = new UserFile('file1.txt', 123, 100) + vi.mocked(api.moveUserData).mockResolvedValue({ + status: 409, + statusText: 'Conflict' + } as Response) + + await expect(file.rename('newfile.txt')).rejects.toThrow( + "Failed to rename file 'file1.txt': 409 Conflict" + ) + }) + + it('should leave metadata unchanged when rename returns a string', async () => { + const file = new UserFile('file1.txt', 123, 100) + vi.mocked(api.moveUserData).mockResolvedValue({ + status: 200, + json: () => Promise.resolve('newfile.txt') + } as Response) + + await file.rename('newfile.txt') + + expect(file.path).toBe('newfile.txt') + expect(file.lastModified).toBe(123) + expect(file.size).toBe(100) + }) }) describe('saveAs', () => { @@ -207,6 +343,25 @@ describe('useUserFileStore', () => { expect(newFile.size).toBe(200) expect(newFile.content).toBe('file content') }) + + it('should save temporary files in place', async () => { + const file = UserFile.createTemporary('draft.txt') + file.content = 'file content' + vi.mocked(api.storeUserData).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ modified: 456, size: 200 }) + } as Response) + + const newFile = await file.saveAs('newfile.txt') + + expect(api.storeUserData).toHaveBeenCalledWith( + 'draft.txt', + 'file content', + { throwOnError: true, full_info: true, overwrite: false } + ) + expect(newFile).toBe(file) + expect(newFile.path).toBe('draft.txt') + }) }) }) }) diff --git a/src/stores/userStore.test.ts b/src/stores/userStore.test.ts index 365fc53be4e..8c333c8048e 100644 --- a/src/stores/userStore.test.ts +++ b/src/stores/userStore.test.ts @@ -1,61 +1,72 @@ -import { createPinia, setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useUserStore } from './userStore' -const getUserConfig = vi.fn() +const apiMock = vi.hoisted(() => ({ + createUser: vi.fn(), + getUserConfig: vi.fn(), + user: undefined as string | undefined +})) vi.mock('@/scripts/api', () => ({ - api: { - getUserConfig: (...args: unknown[]) => getUserConfig(...args) - } + api: apiMock })) describe('userStore', () => { beforeEach(() => { - setActivePinia(createPinia()) - getUserConfig.mockReset() + setActivePinia(createTestingPinia({ stubActions: false })) + apiMock.createUser.mockReset() + apiMock.getUserConfig.mockReset() + apiMock.user = undefined localStorage.clear() }) describe('initialize', () => { + it('returns an empty user list before initialization', () => { + const store = useUserStore() + + expect(store.users).toEqual([]) + }) + it('fetches user config on first call', async () => { - getUserConfig.mockResolvedValue({}) + apiMock.getUserConfig.mockResolvedValue({}) const store = useUserStore() await store.initialize() - expect(getUserConfig).toHaveBeenCalledTimes(1) + expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1) expect(store.initialized).toBe(true) }) it('is a no-op once already initialized', async () => { - getUserConfig.mockResolvedValue({}) + apiMock.getUserConfig.mockResolvedValue({}) const store = useUserStore() await store.initialize() - getUserConfig.mockClear() + apiMock.getUserConfig.mockClear() await store.initialize() - expect(getUserConfig).not.toHaveBeenCalled() + expect(apiMock.getUserConfig).not.toHaveBeenCalled() }) it('retries on a subsequent call when the first fetch failed', async () => { - getUserConfig.mockRejectedValueOnce(new Error('network down')) - getUserConfig.mockResolvedValueOnce({}) + apiMock.getUserConfig.mockRejectedValueOnce(new Error('network down')) + apiMock.getUserConfig.mockResolvedValueOnce({}) const store = useUserStore() await expect(store.initialize()).rejects.toThrow('network down') expect(store.initialized).toBe(false) await expect(store.initialize()).resolves.toBeUndefined() - expect(getUserConfig).toHaveBeenCalledTimes(2) + expect(apiMock.getUserConfig).toHaveBeenCalledTimes(2) expect(store.initialized).toBe(true) }) it('deduplicates concurrent calls before the first fetch resolves', async () => { let resolveConfig: (value: unknown) => void = () => {} - getUserConfig.mockImplementation( + apiMock.getUserConfig.mockImplementation( () => new Promise((resolve) => { resolveConfig = resolve @@ -68,7 +79,100 @@ describe('userStore', () => { resolveConfig({}) await Promise.all([a, b]) - expect(getUserConfig).toHaveBeenCalledTimes(1) + expect(apiMock.getUserConfig).toHaveBeenCalledTimes(1) + }) + + it('derives multi-user state and restores the current user from storage', async () => { + localStorage['Comfy.userId'] = 'user-2' + apiMock.getUserConfig.mockResolvedValue({ + users: { 'user-1': 'Ada', 'user-2': 'Grace' } + }) + const store = useUserStore() + + await store.initialize() + + expect(store.isMultiUserServer).toBe(true) + expect(store.needsLogin).toBe(false) + expect(store.users).toEqual([ + { userId: 'user-1', username: 'Ada' }, + { userId: 'user-2', username: 'Grace' } + ]) + expect(store.currentUser).toEqual({ userId: 'user-2', username: 'Grace' }) + await vi.waitFor(() => expect(apiMock.user).toBe('user-2')) + }) + + it('requires login on multi-user servers without a stored user', async () => { + apiMock.getUserConfig.mockResolvedValue({ + users: { 'user-1': 'Ada' } + }) + const store = useUserStore() + + await store.initialize() + + expect(store.needsLogin).toBe(true) + expect(store.currentUser).toBeNull() + expect(apiMock.user).toBeUndefined() + }) + }) + + describe('createUser', () => { + it('returns the created user id with the requested username', async () => { + apiMock.createUser.mockResolvedValue({ + json: () => Promise.resolve('user-1'), + status: 201 + }) + const store = useUserStore() + + await expect(store.createUser('Ada')).resolves.toEqual({ + userId: 'user-1', + username: 'Ada' + }) + }) + + it('throws API errors returned by user creation', async () => { + apiMock.createUser.mockResolvedValue({ + json: () => Promise.resolve({ error: 'name taken' }), + status: 409, + statusText: 'Conflict' + }) + const store = useUserStore() + + await expect(store.createUser('Ada')).rejects.toThrow('name taken') + }) + + it('throws a fallback error when user creation has no error body', async () => { + apiMock.createUser.mockResolvedValue({ + json: () => Promise.resolve({}), + status: 500, + statusText: 'Server Error' + }) + const store = useUserStore() + + await expect(store.createUser('Ada')).rejects.toThrow( + 'Error creating user: 500 Server Error' + ) + }) + }) + + describe('login/logout', () => { + it('persists login identity and clears it on logout', async () => { + const store = useUserStore() + + await store.login({ userId: 'user-1', username: 'Ada' }) + expect(localStorage['Comfy.userId']).toBe('user-1') + expect(localStorage['Comfy.userName']).toBe('Ada') + + await store.logout() + expect(localStorage['Comfy.userId']).toBeUndefined() + expect(localStorage['Comfy.userName']).toBeUndefined() + }) + + it('does not set api.user when login happens before user config loads', async () => { + const store = useUserStore() + + await store.login({ userId: 'user-1', username: 'Ada' }) + + expect(apiMock.user).toBeUndefined() }) }) }) diff --git a/src/stores/workspace/favoritedWidgetsStore.test.ts b/src/stores/workspace/favoritedWidgetsStore.test.ts new file mode 100644 index 00000000000..86ef72d88d1 --- /dev/null +++ b/src/stores/workspace/favoritedWidgetsStore.test.ts @@ -0,0 +1,258 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore' +import { toNodeId } from '@/types/nodeId' + +const { mockState } = vi.hoisted(() => ({ + mockState: { + graph: null as { extra: Record } | null, + nodes: {} as Record, + setDirty: vi.fn() + } +})) + +vi.mock('@/scripts/app', () => ({ + app: { + get rootGraph() { + return mockState.graph + } + } +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + activeWorkflow: undefined, + nodeToNodeLocatorId: (node: { id: unknown }) => String(node.id), + nodeIdToNodeLocatorId: (id: unknown) => String(id) + }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({ canvas: { setDirty: mockState.setDirty } }) +})) + +vi.mock('@/utils/graphTraversalUtil', () => ({ + getNodeByLocatorId: (_graph: unknown, id: string) => + mockState.nodes[id] ?? null +})) + +vi.mock('@/utils/nodeTitleUtil', () => ({ + resolveNodeDisplayName: (node: { title?: string }) => node.title ?? 'Node' +})) + +vi.mock('@/i18n', () => ({ + st: (_key: string, fallback: string) => fallback +})) + +interface FakeWidget { + name: string + label?: string +} + +function makeWidget({ name, label }: FakeWidget): IBaseWidget { + return { + name, + label, + options: {}, + type: 'number', + y: 0 + } as IBaseWidget +} + +function makeNode(id: number, widgets: FakeWidget[] = [], title = 'My Node') { + const node = new LGraphNode(title) + node.id = toNodeId(id) + node.title = title + node.widgets = widgets.map(makeWidget) + return node +} + +function registerNode(node: { id: unknown }) { + mockState.nodes[String(node.id)] = node +} + +beforeEach(() => { + setActivePinia(createPinia()) + mockState.graph = { extra: {} } + mockState.nodes = {} + mockState.setDirty = vi.fn() +}) + +describe('favoritedWidgetsStore', () => { + it('adds a favorite, marks workflow dirty, and persists to graph.extra', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + + store.addFavorite(node, 'seed') + + expect(store.isFavorited(node, 'seed')).toBe(true) + expect(mockState.setDirty).toHaveBeenCalledWith(true, true) + expect(mockState.graph?.extra.favoritedWidgets).toEqual({ + favorites: [{ nodeLocatorId: '1', widgetName: 'seed' }] + }) + }) + + it('does not add the same favorite twice', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + + store.addFavorite(node, 'seed') + const persisted = structuredClone(mockState.graph?.extra.favoritedWidgets) + const dirtyCalls = mockState.setDirty.mock.calls.length + + store.addFavorite(node, 'seed') + + expect(store.favoritedWidgets).toHaveLength(1) + expect(mockState.graph?.extra.favoritedWidgets).toEqual(persisted) + expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls) + }) + + it('removes a favorite and treats removing an absent one as a no-op', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + store.addFavorite(node, 'seed') + const persisted = structuredClone(mockState.graph?.extra.favoritedWidgets) + const dirtyCalls = mockState.setDirty.mock.calls.length + + store.removeFavorite(node, 'missing') + expect(store.isFavorited(node, 'seed')).toBe(true) + expect(mockState.graph?.extra.favoritedWidgets).toEqual(persisted) + expect(mockState.setDirty).toHaveBeenCalledTimes(dirtyCalls) + + store.removeFavorite(node, 'seed') + expect(store.isFavorited(node, 'seed')).toBe(false) + }) + + it('toggles favorite state in both directions', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + + store.toggleFavorite(node, 'seed') + expect(store.isFavorited(node, 'seed')).toBe(true) + + store.toggleFavorite(node, 'seed') + expect(store.isFavorited(node, 'seed')).toBe(false) + }) + + it('resolves a valid favorite to a node/widget with a composed label', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(7, [{ name: 'cfg', label: 'CFG Scale' }], 'KSampler') + registerNode(node) + + store.addFavorite(node, 'cfg') + + const [resolved] = store.favoritedWidgets + expect(resolved.label).toBe('KSampler / CFG Scale') + expect(store.validFavoritedWidgets).toHaveLength(1) + }) + + it('labels favorites whose node was deleted and excludes them from valid', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(2, [{ name: 'seed' }]) + registerNode(node) + store.addFavorite(node, 'seed') + + delete mockState.nodes['2'] + + expect(store.favoritedWidgets[0].label).toContain('(node deleted)') + expect(store.validFavoritedWidgets).toHaveLength(0) + }) + + it('labels favorites whose widget no longer exists', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(3, [{ name: 'seed' }]) + registerNode(node) + store.addFavorite(node, 'seed') + + mockState.nodes['3'] = makeNode(3, [], 'My Node') + + expect(store.favoritedWidgets[0].label).toContain('(widget not found)') + }) + + it('prunes invalid favorites while keeping valid ones', () => { + const store = useFavoritedWidgetsStore() + const valid = makeNode(1, [{ name: 'seed' }]) + const stale = makeNode(2, [{ name: 'steps' }]) + registerNode(valid) + registerNode(stale) + store.addFavorite(valid, 'seed') + store.addFavorite(stale, 'steps') + + delete mockState.nodes['2'] + store.pruneInvalidFavorites() + + expect(store.favoritedWidgets).toHaveLength(1) + expect(store.isFavorited(valid, 'seed')).toBe(true) + }) + + it('reorders favorites to match the provided order', () => { + const store = useFavoritedWidgetsStore() + const a = makeNode(1, [{ name: 'seed' }]) + const b = makeNode(2, [{ name: 'steps' }]) + registerNode(a) + registerNode(b) + store.addFavorite(a, 'seed') + store.addFavorite(b, 'steps') + + store.reorderFavorites([...store.validFavoritedWidgets].reverse()) + + expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual([ + '2', + '1' + ]) + }) + + it('clears all favorites', () => { + const store = useFavoritedWidgetsStore() + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + store.addFavorite(node, 'seed') + + store.clearFavorites() + + expect(store.favoritedWidgets).toHaveLength(0) + }) + + it('loads favorites from graph.extra on init, normalizing legacy nodeId entries', () => { + mockState.graph = { + extra: { + favoritedWidgets: { + favorites: [ + { nodeLocatorId: '1', widgetName: 'seed' }, + { nodeId: 2, widgetName: 'steps' }, + { widgetName: 'no-node' } + ] + } + } + } + registerNode(makeNode(1, [{ name: 'seed' }])) + registerNode(makeNode(2, [{ name: 'steps' }])) + + const store = useFavoritedWidgetsStore() + + expect(store.favoritedWidgets.map((fw) => fw.nodeLocatorId)).toEqual([ + '1', + '2' + ]) + }) + + it('labels existing favorites when the graph is not loaded', () => { + const node = makeNode(1, [{ name: 'seed' }]) + registerNode(node) + const store = useFavoritedWidgetsStore() + store.addFavorite(node, 'seed') + + mockState.graph = null + + expect(store.favoritedWidgets[0].label).toContain('(graph not loaded)') + store.clearFavorites() + expect(store.favoritedWidgets).toHaveLength(0) + }) +}) diff --git a/src/stores/workspaceStore.test.ts b/src/stores/workspaceStore.test.ts new file mode 100644 index 00000000000..a7efe26667b --- /dev/null +++ b/src/stores/workspaceStore.test.ts @@ -0,0 +1,115 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useWorkspaceStore } from '@/stores/workspaceStore' + +const storeMocks = vi.hoisted(() => ({ + apiKeyAuthStore: { + isAuthenticated: false + }, + authStore: { + currentUser: null as null | { uid: string } + }, + commandStore: { + commands: [], + execute: vi.fn() + }, + executionErrorStore: { + lastExecutionError: null, + lastNodeErrors: null + }, + queueSettingsStore: {}, + settingStore: { + settingsById: {}, + get: vi.fn(), + set: vi.fn() + }, + sidebarTabStore: { + registerSidebarTab: vi.fn(), + unregisterSidebarTab: vi.fn(), + sidebarTabs: [] + }, + toastStore: {}, + workflowStore: {} +})) + +vi.mock('@vueuse/core', () => ({ + useMagicKeys: () => ({ shift: false }) +})) + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => storeMocks.settingStore +})) + +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: () => storeMocks.toastStore +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => storeMocks.workflowStore +})) + +vi.mock('@/services/colorPaletteService', () => ({ + useColorPaletteService: () => ({}) +})) + +vi.mock('@/services/dialogService', () => ({ + useDialogService: () => ({}) +})) + +vi.mock('@/stores/apiKeyAuthStore', () => ({ + useApiKeyAuthStore: () => storeMocks.apiKeyAuthStore +})) + +vi.mock('@/stores/authStore', () => ({ + useAuthStore: () => storeMocks.authStore +})) + +vi.mock('@/stores/commandStore', () => ({ + useCommandStore: () => storeMocks.commandStore +})) + +vi.mock('@/stores/executionErrorStore', () => ({ + useExecutionErrorStore: () => storeMocks.executionErrorStore +})) + +vi.mock('@/stores/queueStore', () => ({ + useQueueSettingsStore: () => storeMocks.queueSettingsStore +})) + +vi.mock('@/stores/workspace/bottomPanelStore', () => ({ + useBottomPanelStore: () => ({}) +})) + +vi.mock('@/stores/workspace/sidebarTabStore', () => ({ + useSidebarTabStore: () => storeMocks.sidebarTabStore +})) + +describe('useWorkspaceStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + storeMocks.apiKeyAuthStore.isAuthenticated = false + storeMocks.authStore.currentUser = null + }) + + it('reports logged out when neither auth source is active', () => { + const store = useWorkspaceStore() + + expect(store.user.isLoggedIn).toBe(false) + }) + + it('reports logged in for API-key auth', () => { + storeMocks.apiKeyAuthStore.isAuthenticated = true + const store = useWorkspaceStore() + + expect(store.user.isLoggedIn).toBe(true) + }) + + it('reports logged in for Firebase auth', () => { + storeMocks.authStore.currentUser = { uid: 'user-1' } + const store = useWorkspaceStore() + + expect(store.user.isLoggedIn).toBe(true) + }) +})