diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts index 7ffcbdd065..dde2d147c3 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.test.ts @@ -1,7 +1,8 @@ -import type { IDockviewPanel, IGridviewPanel } from 'dockview'; +import { GridviewPanel, type IDockviewPanel } from 'dockview'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NavigationApi } from './navigation-api'; +import { LEFT_PANEL_MIN_SIZE_PX, RIGHT_PANEL_MIN_SIZE_PX } from './shared'; // Mock the logger vi.mock('app/logging/logger', () => ({ @@ -13,13 +14,36 @@ vi.mock('app/logging/logger', () => ({ }), })); -// Mock panel with setActive method -const createMockPanel = () => - ({ - api: { +vi.mock('dockview', async () => { + const actual = await vi.importActual('dockview'); + + // Mock GridviewPanel class for instanceof checks + class MockGridviewPanel { + maximumWidth: number; + minimumWidth: number; + api = { setActive: vi.fn(), - }, - }) as unknown as IGridviewPanel; + setConstraints: vi.fn(), + setSize: vi.fn(), + }; + + constructor(config: { maximumWidth?: number; minimumWidth?: number } = {}) { + this.maximumWidth = config.maximumWidth ?? Number.MAX_SAFE_INTEGER; + this.minimumWidth = config.minimumWidth ?? 0; + } + } + + return { + ...actual, + GridviewPanel: MockGridviewPanel, + }; +}); + +// Mock panel with setActive method +const createMockPanel = (config: { maximumWidth?: number; minimumWidth?: number } = {}) => { + /* @ts-expect-error we are mocking GridviewPanel to be a concrete class */ + return new GridviewPanel(config); +}; const createMockDockPanel = () => ({ @@ -365,4 +389,504 @@ describe('AppNavigationApi', () => { expect(mockPanel.api.setActive).toHaveBeenCalledOnce(); }); }); + + describe('Tab Validation', () => { + it('should reject non-enabled tabs in waitForPanel', async () => { + await expect(navigationApi.waitForPanel('models', 'settings')).rejects.toThrow( + 'Tab models is not enabled for panel registration' + ); + }); + + it('should reject non-enabled tabs in focusPanel', async () => { + const result = await navigationApi.focusPanel('models', 'settings'); + expect(result).toBe(false); + }); + + it('should warn for non-enabled tabs in getPanel', () => { + const result = navigationApi.getPanel('models', 'settings'); + expect(result).toBeUndefined(); + }); + + it('should accept all enabled tabs', async () => { + const enabledTabs = ['canvas', 'generate', 'workflows', 'upscaling'] as const; + + for (const tab of enabledTabs) { + // These should timeout since panels aren't registered, but not reject due to invalid tab + await expect(navigationApi.waitForPanel(tab, 'settings', 50)).rejects.toThrow('timed out'); + } + }); + }); + + describe('focusPanelInActiveTab', () => { + beforeEach(() => { + navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab }); + }); + + it('should focus panel in active tab', async () => { + const mockPanel = createMockPanel(); + navigationApi.registerPanel('generate', 'settings', mockPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = await navigationApi.focusPanelInActiveTab('settings'); + + expect(result).toBe(true); + expect(mockPanel.api.setActive).toHaveBeenCalledOnce(); + }); + + it('should return false when no active tab', async () => { + mockGetAppTab.mockReturnValue(null); + + const result = await navigationApi.focusPanelInActiveTab('settings'); + + expect(result).toBe(false); + }); + + it('should work without app connection', async () => { + navigationApi.disconnectFromApp(); + + const result = await navigationApi.focusPanelInActiveTab('settings'); + + expect(result).toBe(false); + }); + }); + + describe('Panel Expansion and Collapse', () => { + it('should expand panel with correct constraints and size', () => { + const mockPanel = createMockPanel(); + const width = 500; + + navigationApi.expandPanel(mockPanel, width); + + expect(mockPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: width, + }); + expect(mockPanel.api.setSize).toHaveBeenCalledWith({ width }); + }); + + it('should collapse panel with zero constraints and size', () => { + const mockPanel = createMockPanel(); + + navigationApi.collapsePanel(mockPanel); + + expect(mockPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: 0, + minimumWidth: 0, + }); + expect(mockPanel.api.setSize).toHaveBeenCalledWith({ width: 0 }); + }); + }); + + describe('getPanel', () => { + it('should return registered panel', () => { + const mockPanel = createMockPanel(); + navigationApi.registerPanel('generate', 'settings', mockPanel); + + const result = navigationApi.getPanel('generate', 'settings'); + + expect(result).toBe(mockPanel); + }); + + it('should return undefined for unregistered panel', () => { + const result = navigationApi.getPanel('generate', 'nonexistent'); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for non-enabled tab', () => { + const result = navigationApi.getPanel('models', 'settings'); + + expect(result).toBeUndefined(); + }); + }); + + describe('toggleLeftPanel', () => { + beforeEach(() => { + navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab }); + }); + + it('should expand collapsed left panel', () => { + const mockPanel = createMockPanel({ maximumWidth: 0 }); + navigationApi.registerPanel('generate', 'left', mockPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleLeftPanel(); + + expect(result).toBe(true); + expect(mockPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + }); + expect(mockPanel.api.setSize).toHaveBeenCalledWith({ width: LEFT_PANEL_MIN_SIZE_PX }); + }); + + it('should collapse expanded left panel', () => { + const mockPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER }); + navigationApi.registerPanel('generate', 'left', mockPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleLeftPanel(); + + expect(result).toBe(true); + expect(mockPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: 0, + minimumWidth: 0, + }); + expect(mockPanel.api.setSize).toHaveBeenCalledWith({ width: 0 }); + }); + + it('should return false when no active tab', () => { + mockGetAppTab.mockReturnValue(null); + + const result = navigationApi.toggleLeftPanel(); + + expect(result).toBe(false); + }); + + it('should return false when left panel not found', () => { + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleLeftPanel(); + + expect(result).toBe(false); + }); + + it('should return false when panel is not GridviewPanel', () => { + const mockPanel = createMockDockPanel(); + navigationApi.registerPanel('generate', 'left', mockPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleLeftPanel(); + + expect(result).toBe(false); + }); + }); + + describe('toggleRightPanel', () => { + beforeEach(() => { + navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab }); + }); + + it('should expand collapsed right panel', () => { + const mockPanel = createMockPanel({ maximumWidth: 0 }); + navigationApi.registerPanel('generate', 'right', mockPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleRightPanel(); + + expect(result).toBe(true); + expect(mockPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + }); + expect(mockPanel.api.setSize).toHaveBeenCalledWith({ width: RIGHT_PANEL_MIN_SIZE_PX }); + }); + + it('should collapse expanded right panel', () => { + const mockPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER }); + navigationApi.registerPanel('generate', 'right', mockPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleRightPanel(); + + expect(result).toBe(true); + expect(mockPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: 0, + minimumWidth: 0, + }); + expect(mockPanel.api.setSize).toHaveBeenCalledWith({ width: 0 }); + }); + + it('should return false when no active tab', () => { + mockGetAppTab.mockReturnValue(null); + + const result = navigationApi.toggleRightPanel(); + + expect(result).toBe(false); + }); + + it('should return false when right panel not found', () => { + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleRightPanel(); + + expect(result).toBe(false); + }); + + it('should return false when panel is not GridviewPanel', () => { + const mockPanel = createMockDockPanel(); + navigationApi.registerPanel('generate', 'right', mockPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleRightPanel(); + + expect(result).toBe(false); + }); + }); + + describe('toggleLeftAndRightPanels', () => { + beforeEach(() => { + navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab }); + }); + + it('should expand both panels when left is collapsed', () => { + const leftPanel = createMockPanel({ maximumWidth: 0 }); + const rightPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER }); + + navigationApi.registerPanel('generate', 'left', leftPanel); + navigationApi.registerPanel('generate', 'right', rightPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleLeftAndRightPanels(); + + expect(result).toBe(true); + + // Both should be expanded + expect(leftPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + }); + expect(rightPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + }); + }); + + it('should expand both panels when right is collapsed', () => { + const leftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER }); + const rightPanel = createMockPanel({ maximumWidth: 0 }); + + navigationApi.registerPanel('generate', 'left', leftPanel); + navigationApi.registerPanel('generate', 'right', rightPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleLeftAndRightPanels(); + + expect(result).toBe(true); + + // Both should be expanded + expect(leftPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + }); + expect(rightPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + }); + }); + + it('should collapse both panels when both are expanded', () => { + const leftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER }); + const rightPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER }); + + navigationApi.registerPanel('generate', 'left', leftPanel); + navigationApi.registerPanel('generate', 'right', rightPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleLeftAndRightPanels(); + + expect(result).toBe(true); + + // Both should be collapsed + expect(leftPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: 0, + minimumWidth: 0, + }); + expect(rightPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: 0, + minimumWidth: 0, + }); + }); + + it('should expand both panels when both are collapsed', () => { + const leftPanel = createMockPanel({ maximumWidth: 0 }); + const rightPanel = createMockPanel({ maximumWidth: 0 }); + + navigationApi.registerPanel('generate', 'left', leftPanel); + navigationApi.registerPanel('generate', 'right', rightPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleLeftAndRightPanels(); + + expect(result).toBe(true); + + // Both should be expanded + expect(leftPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + }); + expect(rightPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + }); + }); + + it('should return false when no active tab', () => { + mockGetAppTab.mockReturnValue(null); + + const result = navigationApi.toggleLeftAndRightPanels(); + + expect(result).toBe(false); + }); + + it('should return false when panels not found', () => { + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleLeftAndRightPanels(); + + expect(result).toBe(false); + }); + + it('should return false when panels are not GridviewPanels', () => { + const leftPanel = createMockDockPanel(); + const rightPanel = createMockDockPanel(); + + navigationApi.registerPanel('generate', 'left', leftPanel); + navigationApi.registerPanel('generate', 'right', rightPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.toggleLeftAndRightPanels(); + + expect(result).toBe(false); + }); + }); + + describe('resetLeftAndRightPanels', () => { + beforeEach(() => { + navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab }); + }); + + it('should reset both panels to expanded state', () => { + const leftPanel = createMockPanel({ maximumWidth: 0 }); + const rightPanel = createMockPanel({ maximumWidth: 0 }); + + navigationApi.registerPanel('generate', 'left', leftPanel); + navigationApi.registerPanel('generate', 'right', rightPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.resetLeftAndRightPanels(); + + expect(result).toBe(true); + + // Both should be reset to expanded state + expect(leftPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + }); + expect(leftPanel.api.setSize).toHaveBeenCalledWith({ width: LEFT_PANEL_MIN_SIZE_PX }); + + expect(rightPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + }); + expect(rightPanel.api.setSize).toHaveBeenCalledWith({ width: RIGHT_PANEL_MIN_SIZE_PX }); + }); + + it('should return false when no active tab', () => { + mockGetAppTab.mockReturnValue(null); + + const result = navigationApi.resetLeftAndRightPanels(); + + expect(result).toBe(false); + }); + + it('should return false when panels not found', () => { + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.resetLeftAndRightPanels(); + + expect(result).toBe(false); + }); + + it('should return false when panels are not GridviewPanels', () => { + const leftPanel = createMockDockPanel(); + const rightPanel = createMockDockPanel(); + + navigationApi.registerPanel('generate', 'left', leftPanel); + navigationApi.registerPanel('generate', 'right', rightPanel); + mockGetAppTab.mockReturnValue('generate'); + + const result = navigationApi.resetLeftAndRightPanels(); + + expect(result).toBe(false); + }); + }); + + describe('Integration Tests', () => { + beforeEach(() => { + navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab }); + }); + + it('should handle complete panel management workflow', async () => { + const leftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER }); + const rightPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER }); + const settingsPanel = createMockPanel(); + + // Register panels + navigationApi.registerPanel('generate', 'left', leftPanel); + navigationApi.registerPanel('generate', 'right', rightPanel); + navigationApi.registerPanel('generate', 'settings', settingsPanel); + mockGetAppTab.mockReturnValue('generate'); + + // Focus a panel in active tab + const focusResult = await navigationApi.focusPanelInActiveTab('settings'); + expect(focusResult).toBe(true); + expect(settingsPanel.api.setActive).toHaveBeenCalled(); + + // Toggle panels + navigationApi.toggleLeftAndRightPanels(); // Should collapse both + expect(leftPanel.api.setConstraints).toHaveBeenCalledWith({ maximumWidth: 0, minimumWidth: 0 }); + expect(rightPanel.api.setConstraints).toHaveBeenCalledWith({ maximumWidth: 0, minimumWidth: 0 }); + + // Reset panels + navigationApi.resetLeftAndRightPanels(); // Should expand both + expect(leftPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + }); + expect(rightPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + }); + }); + + it('should handle tab switching with panel operations', () => { + const generateLeftPanel = createMockPanel({ maximumWidth: Number.MAX_SAFE_INTEGER }); + const canvasLeftPanel = createMockPanel({ maximumWidth: 0 }); + + navigationApi.registerPanel('generate', 'left', generateLeftPanel); + navigationApi.registerPanel('canvas', 'left', canvasLeftPanel); + + // Start on generate tab + mockGetAppTab.mockReturnValue('generate'); + navigationApi.toggleLeftPanel(); // Should collapse + expect(generateLeftPanel.api.setConstraints).toHaveBeenCalledWith({ maximumWidth: 0, minimumWidth: 0 }); + + // Switch to canvas tab + mockGetAppTab.mockReturnValue('canvas'); + navigationApi.toggleLeftPanel(); // Should expand + expect(canvasLeftPanel.api.setConstraints).toHaveBeenCalledWith({ + maximumWidth: Number.MAX_SAFE_INTEGER, + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + }); + }); + + it('should handle error cases gracefully', () => { + mockGetAppTab.mockReturnValue('generate'); + + // Test operations without panels registered + expect(navigationApi.toggleLeftPanel()).toBe(false); + expect(navigationApi.toggleRightPanel()).toBe(false); + expect(navigationApi.toggleLeftAndRightPanels()).toBe(false); + expect(navigationApi.resetLeftAndRightPanels()).toBe(false); + expect(navigationApi.getPanel('generate', 'nonexistent')).toBeUndefined(); + }); + + it('should handle async error cases gracefully', async () => { + mockGetAppTab.mockReturnValue('generate'); + + const focusResult = await navigationApi.focusPanelInActiveTab('nonexistent'); + expect(focusResult).toBe(false); + }); + }); });