+
ChatPage Component
+
+
+ ) +})) + +vi.mock('./providers/SettingsProvider', () => ({ + default: ({ children, testId = 'settings-provider' }) => ( +
+ {children} +
+ ), + useSettings: () => ({ + settings: { + theme: 'light', + chatHistory: { chatHistoryLength: 400 }, + customTheme: { current: 'light' } + }, + updateSettings: vi.fn(), + handleThemeChange: vi.fn() + }) +})) + +vi.mock('./components/ErrorBoundary', () => ({ + default: ({ children, testId = 'error-boundary' }) => ( +
+ {children} +
+ ) +})) + +vi.mock('./pages/Loader', () => ({ + default: ({ onFinish, testId = 'loader' }) => ( +
+ Loading... +
v1.0.0
+ +
+ ) +})) + +vi.mock('./components/ChatHistorySettingsSync', () => ({ + default: ({ testId = 'chat-history-settings-sync' }) => ( +
+ ChatHistorySettingsSync Component +
+ ) +})) + +describe('App Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllTimers() + }) + + describe('Component Rendering', () => { + it('should render all main components in correct hierarchy', () => { + render() + + // Verify Error Boundary is the root wrapper + const errorBoundary = screen.getByTestId('error-boundary') + expect(errorBoundary).toBeInTheDocument() + + // Verify Loader is rendered + expect(screen.getByTestId('loader')).toBeInTheDocument() + expect(screen.getByText('Loading...')).toBeInTheDocument() + + // Verify Settings Provider is rendered + expect(screen.getByTestId('settings-provider')).toBeInTheDocument() + + // Verify ChatHistorySettingsSync is rendered inside Settings Provider + expect(screen.getByTestId('chat-history-settings-sync')).toBeInTheDocument() + + // Verify ChatPage is rendered inside Settings Provider + expect(screen.getByTestId('chat-page')).toBeInTheDocument() + }) + + it('should have correct component nesting structure', () => { + render() + + const errorBoundary = screen.getByTestId('error-boundary') + const settingsProvider = screen.getByTestId('settings-provider') + const chatPage = screen.getByTestId('chat-page') + const loader = screen.getByTestId('loader') + const chatHistorySync = screen.getByTestId('chat-history-settings-sync') + + // Error Boundary should contain all components + expect(errorBoundary).toContainElement(loader) + expect(errorBoundary).toContainElement(settingsProvider) + + // Settings Provider should contain ChatHistorySettingsSync and ChatPage + expect(settingsProvider).toContainElement(chatHistorySync) + expect(settingsProvider).toContainElement(chatPage) + }) + + it('should render with enhanced accessibility attributes', () => { + render() + + // Verify ARIA roles and attributes + expect(screen.getByRole('alert')).toBeInTheDocument() // ErrorBoundary + expect(screen.getByRole('status')).toBeInTheDocument() // Loader + expect(screen.getByRole('main')).toBeInTheDocument() // ChatPage + + // Verify ARIA labels + expect(screen.getByLabelText('Loading application')).toBeInTheDocument() + expect(screen.getByLabelText('Finish loading')).toBeInTheDocument() + expect(screen.getByLabelText('Chat messages')).toBeInTheDocument() + + // Verify live regions + const errorBoundary = screen.getByTestId('error-boundary') + expect(errorBoundary).toHaveAttribute('aria-live', 'polite') + + const chatMessages = screen.getByTestId('chat-messages') + expect(chatMessages).toHaveAttribute('aria-live', 'polite') + }) + + it('should have proper data attributes for testing and styling', () => { + render() + + // Verify data attributes + const settingsProvider = screen.getByTestId('settings-provider') + expect(settingsProvider).toHaveAttribute('data-provider', 'settings') + + const syncComponent = screen.getByTestId('chat-history-settings-sync') + expect(syncComponent).toHaveAttribute('data-sync-component', 'true') + + // Verify version display + expect(screen.getByTestId('app-version')).toHaveTextContent('v1.0.0') + }) + }) + + describe('Component Integration', () => { + it('should render without crashing', () => { + expect(() => render()).not.toThrow() + }) + + it('should pass props correctly to child components', () => { + render() + + // All mocked components should be present + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + expect(screen.getByTestId('settings-provider')).toBeInTheDocument() + expect(screen.getByTestId('loader')).toBeInTheDocument() + expect(screen.getByTestId('chat-history-settings-sync')).toBeInTheDocument() + expect(screen.getByTestId('chat-page')).toBeInTheDocument() + }) + }) + + describe('Application Flow', () => { + it('should handle loader completion flow', async () => { + const user = userEvent.setup() + + render() + + // Loader should be present initially + expect(screen.getByTestId('loader')).toBeInTheDocument() + expect(screen.getByText('Loading...')).toBeInTheDocument() + + // Simulate loader finishing + const finishButton = screen.getByTestId('finish-loading') + await user.click(finishButton) + + // Main app components should still be present + expect(screen.getByTestId('chat-page')).toBeInTheDocument() + expect(screen.getByTestId('settings-provider')).toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('should be wrapped in Error Boundary', () => { + render() + + const errorBoundary = screen.getByTestId('error-boundary') + expect(errorBoundary).toBeInTheDocument() + + // All other components should be children of Error Boundary + const allComponents = [ + screen.getByTestId('loader'), + screen.getByTestId('settings-provider'), + screen.getByTestId('chat-page'), + screen.getByTestId('chat-history-settings-sync') + ] + + allComponents.forEach(component => { + expect(errorBoundary).toContainElement(component) + }) + }) + + it('should handle component initialization errors gracefully', () => { + // This test verifies that the Error Boundary would catch any initialization errors + // The actual error handling is tested in ErrorBoundary.test.jsx + expect(() => render()).not.toThrow() + }) + }) + + describe('Provider Integration', () => { + it('should provide settings context to child components', () => { + render() + + const settingsProvider = screen.getByTestId('settings-provider') + const chatPage = screen.getByTestId('chat-page') + const chatHistorySync = screen.getByTestId('chat-history-settings-sync') + + // Both ChatPage and ChatHistorySettingsSync should be within SettingsProvider + expect(settingsProvider).toContainElement(chatPage) + expect(settingsProvider).toContainElement(chatHistorySync) + }) + }) + + describe('Component Lifecycle', () => { + it('should mount all components successfully', () => { + const { container } = render() + + // Verify container is not empty + expect(container.firstChild).toBeInTheDocument() + + // Verify all expected components are rendered + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + expect(screen.getByTestId('settings-provider')).toBeInTheDocument() + expect(screen.getByTestId('loader')).toBeInTheDocument() + expect(screen.getByTestId('chat-page')).toBeInTheDocument() + expect(screen.getByTestId('chat-history-settings-sync')).toBeInTheDocument() + }) + + it('should unmount cleanly', () => { + const { unmount } = render() + + expect(() => unmount()).not.toThrow() + }) + + it('should handle multiple renders correctly', () => { + const { rerender } = render() + + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + + // Re-render should not cause issues + rerender() + + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + expect(screen.getByTestId('chat-page')).toBeInTheDocument() + }) + }) + + describe('Performance and Memory', () => { + it('should not create unnecessary re-renders', () => { + const renderSpy = vi.fn() + const TestApp = () => { + renderSpy() + return + } + + const { rerender } = render() + + const initialRenderCount = renderSpy.mock.calls.length + + // Re-render with same props + rerender() + + // Should have re-rendered + expect(renderSpy.mock.calls.length).toBeGreaterThan(initialRenderCount) + }) + + it('should handle memory cleanup on unmount', () => { + const { unmount } = render() + + // Simulate unmount + unmount() + + // Should not throw or cause memory leaks + expect(() => render()).not.toThrow() + }) + }) + + describe('Accessibility', () => { + it('should have proper document structure', () => { + render() + + const errorBoundary = screen.getByTestId('error-boundary') + expect(errorBoundary).toBeInTheDocument() + + // Should have predictable structure for screen readers + expect(screen.getByTestId('settings-provider')).toBeInTheDocument() + expect(screen.getByTestId('chat-page')).toBeInTheDocument() + }) + + it('should not have accessibility violations in basic structure', () => { + render() + + // Basic structure should be accessible + const app = screen.getByTestId('error-boundary') + expect(app).toBeInTheDocument() + + // Should have all required components for proper app function + expect(screen.getByTestId('loader')).toBeInTheDocument() + expect(screen.getByTestId('chat-page')).toBeInTheDocument() + }) + }) + + describe('Integration with Real Components', () => { + it('should work with actual ErrorBoundary behavior', () => { + // Mock a component that throws an error + const ErrorThrowingComponent = () => { + throw new Error('Test error') + } + + // Test that error boundary would catch errors + // (This would be tested more thoroughly in ErrorBoundary.test.jsx) + expect(() => render()).not.toThrow() + }) + + it('should handle settings provider initialization', () => { + render() + + // Settings provider should wrap the necessary components + const settingsProvider = screen.getByTestId('settings-provider') + expect(settingsProvider).toBeInTheDocument() + + // Should contain the components that need settings + expect(settingsProvider).toContainElement(screen.getByTestId('chat-page')) + expect(settingsProvider).toContainElement(screen.getByTestId('chat-history-settings-sync')) + }) + }) + + describe('Edge Cases', () => { + it('should handle rapid mount/unmount cycles', () => { + for (let i = 0; i < 5; i++) { + const { unmount } = render() + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + unmount() + } + + // Final render should still work + render() + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + }) + + it('should handle component order correctly', () => { + render() + + // Verify the specific order of components as defined in App.jsx + const errorBoundary = screen.getByTestId('error-boundary') + const loader = screen.getByTestId('loader') + const settingsProvider = screen.getByTestId('settings-provider') + + // Error Boundary should be the outermost wrapper + expect(errorBoundary).toBeInTheDocument() + expect(errorBoundary).toContainElement(loader) + expect(errorBoundary).toContainElement(settingsProvider) + }) + }) + + describe('Component Dependencies', () => { + it('should handle missing prop scenarios gracefully', () => { + // App component doesn't take props, but test that it handles it gracefully + expect(() => render()).not.toThrow() + }) + + it('should maintain component hierarchy under different conditions', () => { + render() + + // Verify that all required components are present regardless of conditions + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + expect(screen.getByTestId('loader')).toBeInTheDocument() + expect(screen.getByTestId('settings-provider')).toBeInTheDocument() + expect(screen.getByTestId('chat-page')).toBeInTheDocument() + expect(screen.getByTestId('chat-history-settings-sync')).toBeInTheDocument() + }) + }) + + describe('Static Structure Verification', () => { + it('should render exact component structure as defined in source', () => { + render() + + // Based on the actual App.jsx source: + // + // + // + // + // + // + // + + const errorBoundary = screen.getByTestId('error-boundary') + const loader = screen.getByTestId('loader') + const settingsProvider = screen.getByTestId('settings-provider') + const chatHistorySync = screen.getByTestId('chat-history-settings-sync') + const chatPage = screen.getByTestId('chat-page') + + // Verify hierarchical relationships + expect(errorBoundary).toContainElement(loader) + expect(errorBoundary).toContainElement(settingsProvider) + expect(settingsProvider).toContainElement(chatHistorySync) + expect(settingsProvider).toContainElement(chatPage) + + // Verify loader is not inside settings provider + expect(settingsProvider).not.toContainElement(loader) + }) + + it('should have exactly the expected number of top-level components', () => { + render() + + // Should have one error boundary as root + expect(screen.getAllByTestId('error-boundary')).toHaveLength(1) + + // Should have one loader + expect(screen.getAllByTestId('loader')).toHaveLength(1) + + // Should have one settings provider + expect(screen.getAllByTestId('settings-provider')).toHaveLength(1) + + // Should have one chat page + expect(screen.getAllByTestId('chat-page')).toHaveLength(1) + + // Should have one chat history settings sync + expect(screen.getAllByTestId('chat-history-settings-sync')).toHaveLength(1) + }) + }) + + describe('Theme Management and Settings', () => { + it('should support theme provider integration', () => { + render() + + // Settings provider should be available for theme management + const settingsProvider = screen.getByTestId('settings-provider') + expect(settingsProvider).toBeInTheDocument() + expect(settingsProvider).toHaveAttribute('data-provider', 'settings') + + // Components that need theme context should be within provider + expect(settingsProvider).toContainElement(screen.getByTestId('chat-page')) + }) + + it('should handle settings synchronization properly', () => { + render() + + // ChatHistorySettingsSync should be present and properly positioned + const syncComponent = screen.getByTestId('chat-history-settings-sync') + expect(syncComponent).toBeInTheDocument() + expect(syncComponent).toHaveAttribute('data-sync-component', 'true') + + // Should be within settings provider for context access + const settingsProvider = screen.getByTestId('settings-provider') + expect(settingsProvider).toContainElement(syncComponent) + }) + + it('should maintain settings context hierarchy', () => { + const { rerender } = render() + + // Initial render should have proper hierarchy + expect(screen.getByTestId('settings-provider')).toBeInTheDocument() + + // Re-render should maintain the same structure + rerender() + + const settingsProvider = screen.getByTestId('settings-provider') + expect(settingsProvider).toContainElement(screen.getByTestId('chat-page')) + expect(settingsProvider).toContainElement(screen.getByTestId('chat-history-settings-sync')) + }) + }) + + describe('User Interaction and Event Handling', () => { + it('should handle loader interactions properly', async () => { + const user = userEvent.setup() + + render() + + // Verify loader is interactive + const finishButton = screen.getByTestId('finish-loading') + expect(finishButton).toBeInTheDocument() + expect(finishButton).toHaveAttribute('aria-label', 'Finish loading') + + // Should be able to interact with loader + await user.click(finishButton) + + // Components should remain functional after interaction + expect(screen.getByTestId('chat-page')).toBeInTheDocument() + }) + + it('should maintain event handling across re-renders', async () => { + const user = userEvent.setup() + const { rerender } = render() + + // Initial interaction should work + const finishButton = screen.getByTestId('finish-loading') + await user.click(finishButton) + + // Re-render + rerender() + + // Should still have interactive elements + expect(screen.getByTestId('finish-loading')).toBeInTheDocument() + expect(screen.getByTestId('finish-loading')).toHaveAttribute('aria-label', 'Finish loading') + }) + + it('should support keyboard navigation', () => { + render() + + // Interactive elements should be keyboard accessible + const finishButton = screen.getByTestId('finish-loading') + expect(finishButton).toBeInTheDocument() + + // Should have proper button semantics for keyboard users + expect(finishButton.tagName).toBe('BUTTON') + }) + }) + + describe('Responsive Layout and State Management', () => { + it('should maintain layout consistency', () => { + render() + + // Should have stable layout structure + const errorBoundary = screen.getByTestId('error-boundary') + expect(errorBoundary).toBeInTheDocument() + + // Main content should be accessible + expect(screen.getByRole('main')).toBeInTheDocument() + + // Should maintain component hierarchy + const settingsProvider = screen.getByTestId('settings-provider') + expect(settingsProvider).toContainElement(screen.getByTestId('chat-page')) + }) + + it('should handle state changes during lifecycle', () => { + const { unmount } = render() + + // Verify initial state + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + + // Unmount should not cause errors + unmount() + + // Should be able to remount successfully + render() + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + }) + + it('should support dynamic content updates', () => { + render() + + // Should have live regions for dynamic content + const chatMessages = screen.getByTestId('chat-messages') + expect(chatMessages).toHaveAttribute('aria-live', 'polite') + + // Error boundary should also support live updates + const errorBoundary = screen.getByTestId('error-boundary') + expect(errorBoundary).toHaveAttribute('aria-live', 'polite') + }) + }) + + describe('Advanced Integration Scenarios', () => { + it('should handle complete application initialization flow', () => { + render() + + // All core components should be initialized + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + expect(screen.getByTestId('loader')).toBeInTheDocument() + expect(screen.getByTestId('settings-provider')).toBeInTheDocument() + expect(screen.getByTestId('chat-page')).toBeInTheDocument() + expect(screen.getByTestId('chat-history-settings-sync')).toBeInTheDocument() + + // Should have proper semantic structure + expect(screen.getByRole('main')).toBeInTheDocument() + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + + it('should maintain consistency across different rendering scenarios', () => { + // Test multiple rendering approaches + const { rerender } = render() + + const initialStructure = screen.getByTestId('error-boundary').innerHTML + + rerender() + + const rerenderStructure = screen.getByTestId('error-boundary').innerHTML + + // Structure should be consistent + expect(initialStructure).toBe(rerenderStructure) + }) + + it('should handle provider context integration correctly', () => { + render() + + // Settings provider should properly wrap required components + const settingsProvider = screen.getByTestId('settings-provider') + expect(settingsProvider).toBeInTheDocument() + + // All components requiring settings context should be wrapped + expect(settingsProvider).toContainElement(screen.getByTestId('chat-page')) + expect(settingsProvider).toContainElement(screen.getByTestId('chat-history-settings-sync')) + + // Loader should not be within settings provider (correct hierarchy) + expect(settingsProvider).not.toContainElement(screen.getByTestId('loader')) + }) + }) + + describe('Negative Test Cases and Edge Scenarios', () => { + it('should handle unexpected props gracefully', () => { + // App component doesn't accept props but should handle them gracefully + expect(() => render()).not.toThrow() + + // Should still render correctly + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + }) + + it('should maintain structure integrity under stress conditions', () => { + // Rapid mount/unmount cycles + for (let i = 0; i < 3; i++) { + const { unmount } = render() + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + unmount() + } + + // Final render should still work perfectly + render() + expect(screen.getByTestId('error-boundary')).toBeInTheDocument() + expect(screen.getByTestId('settings-provider')).toBeInTheDocument() + expect(screen.getByTestId('chat-page')).toBeInTheDocument() + }) + + it('should handle DOM hierarchy validation', () => { + const { container } = render() + + // Should have clean DOM structure without extra wrappers + expect(container.firstChild).toBe(screen.getByTestId('error-boundary')) + + // Error boundary should have exactly the expected children + const errorBoundary = screen.getByTestId('error-boundary') + expect(errorBoundary.children).toHaveLength(2) // Loader + SettingsProvider + + // Verify children order matches the source code + const children = Array.from(errorBoundary.children) + expect(children[0]).toHaveAttribute('data-testid', 'loader') + expect(children[1]).toHaveAttribute('data-testid', 'settings-provider') + }) + + it('should provide error boundaries for component protection', () => { + render() + + // Error boundary should be at the root level + const errorBoundary = screen.getByTestId('error-boundary') + expect(errorBoundary).toHaveAttribute('role', 'alert') + + // All other components should be protected by the error boundary + expect(errorBoundary).toContainElement(screen.getByTestId('settings-provider')) + expect(errorBoundary).toContainElement(screen.getByTestId('loader')) + expect(errorBoundary).toContainElement(screen.getByTestId('chat-page')) + expect(errorBoundary).toContainElement(screen.getByTestId('chat-history-settings-sync')) + }) + }) +}) \ No newline at end of file diff --git a/src/renderer/src/components/Chat/Input/EmoteDialogs.jsx b/src/renderer/src/components/Chat/Input/EmoteDialogs.jsx index 39214fc..137609e 100644 --- a/src/renderer/src/components/Chat/Input/EmoteDialogs.jsx +++ b/src/renderer/src/components/Chat/Input/EmoteDialogs.jsx @@ -4,7 +4,7 @@ import KickLogoFull from "../../../assets/logos/kickLogoFull.svg?asset"; import { memo, useCallback, useState } from "react"; import STVLogo from "../../../assets/logos/stvLogo.svg?asset"; import CaretDown from "../../../assets/icons/caret-down-bold.svg?asset"; -import useClickOutside from "../../../utils/useClickOutside"; +import useClickOutside from "../../utils/useClickOutside.js"; import KickLogoIcon from "../../../assets/logos/kickLogoIcon.svg?asset"; import GlobeIcon from "../../../assets/icons/globe-fill.svg?asset"; import LockIcon from "../../../assets/icons/lock-simple-fill.svg?asset"; @@ -13,7 +13,7 @@ import useChatStore from "../../../providers/ChatProvider"; import { useShallow } from "zustand/react/shallow"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../Shared/Tooltip"; -const EmoteSection = ({ emotes, title, handleEmoteClick, type, section, userChatroomInfo }) => { +const EmoteSection = ({ emotes, title, handleEmoteClick, type, section, userChatroomInfo, exposeCaret = false }) => { const [isSectionOpen, setIsSectionOpen] = useState(true); const [visibleCount, setVisibleCount] = useState(20); const loadMoreTriggerRef = useRef(null); @@ -38,10 +38,8 @@ const EmoteSection = ({ emotes, title, handleEmoteClick, type, section, userChat } return () => { - if (loadMoreTriggerRef.current) { - observerRef.current.disconnect(); - observerRef.current = null; - } + try { observerRef.current?.disconnect?.() } catch {} + observerRef.current = null; }; }, [loadMoreEmotes]); @@ -50,31 +48,32 @@ const EmoteSection = ({ emotes, title, handleEmoteClick, type, section, userChat
{title}
- {emotes?.slice(0, visibleCount).map((emote, i) => ( + {Array.isArray(emotes) && emotes.slice(0, visibleCount).map((emote, i) => ( +
+ )) +})) + +vi.mock('./InfoBar', () => ({ + default: vi.fn(() =>
Info Bar
) +})) + +vi.mock('../../../utils/MessageParser', () => ({ + MessageParser: vi.fn(() => Parsed message) +})) + +vi.mock('@utils/constants', () => ({ + kickEmoteInputRegex: /(?:^|\s)(:(?\w{3,}):)|(?:^|\s)(?\w{2,})\b/g, + DEFAULT_CHAT_HISTORY_LENGTH: 400 +})) + +// Mock chat store with proper structure +const mockChatStore = { + sendMessage: vi.fn().mockResolvedValue(true), + sendReply: vi.fn().mockResolvedValue(true), + saveDraftMessage: vi.fn(), + getDraftMessage: vi.fn().mockReturnValue(''), + clearDraftMessage: vi.fn(), + chatrooms: [ + { + id: 'test-chatroom-1', + username: 'testuser', + userChatroomInfo: { + subscription: true, + id: 'user123' + }, + streamerData: { + id: 'streamer123', + user_id: 'user123', + user: { username: 'testuser' }, + subscriber_badges: [] + }, + emotes: [{ + emotes: [ + { id: '1', name: 'Kappa', platform: 'kick' }, + { id: '2', name: 'PogChamp', platform: 'kick' } + ] + }], + channel7TVEmotes: [{ + type: 'channel', + emotes: [ + { id: '7tv1', name: 'OMEGALUL', platform: '7tv', width: '28px', height: '28px' } + ] + }], + chatroomInfo: { title: 'Test Chat' }, + initialChatroomInfo: { title: 'Test Chat' } + } + ], + chatters: { + 'test-chatroom-1': [ + { id: '1', username: 'chatter1' }, + { id: '2', username: 'chatter2' } + ] + }, + personalEmoteSets: [], + messages: {} +} + +vi.mock('../../../providers/ChatProvider', () => ({ + default: vi.fn((selector) => { + if (typeof selector === 'function') { + return selector(mockChatStore) + } + return mockChatStore + }) +})) + +vi.mock('zustand/react/shallow', () => ({ + useShallow: vi.fn((fn) => fn) +})) + +// Mock Lexical components and functions +vi.mock('@lexical/react/LexicalComposer', () => ({ + LexicalComposer: vi.fn(({ children }) =>
{children}
) +})) + +vi.mock('@lexical/react/LexicalAutoFocusPlugin', () => ({ + AutoFocusPlugin: vi.fn(() =>
) +})) + +vi.mock('@lexical/react/PlainTextPlugin', () => ({ + PlainTextPlugin: vi.fn(({ contentEditable, placeholder }) => ( +
+ {contentEditable} + {placeholder} +
+ )) +})) + +vi.mock('@lexical/react/LexicalContentEditable', () => ({ + ContentEditable: vi.fn((props) => ( +
+ )) +})) + +vi.mock('@lexical/react/LexicalHistoryPlugin', () => ({ + HistoryPlugin: vi.fn(() =>
) +})) + +vi.mock('@lexical/react/LexicalErrorBoundary', () => ({ + LexicalErrorBoundary: vi.fn(() =>
) +})) + +vi.mock('@lexical/react/LexicalComposerContext', () => { + const mockEditor = { + focus: vi.fn(), + update: vi.fn((fn) => fn()), + getRootElement: vi.fn(() => document.createElement('div')), + registerCommand: vi.fn(() => vi.fn()), + registerUpdateListener: vi.fn(() => vi.fn()), + registerNodeTransform: vi.fn(() => vi.fn()) + } + + return { + useLexicalComposerContext: vi.fn(() => [mockEditor]) + } +}) + +vi.mock('lexical', () => ({ + $getRoot: vi.fn(() => ({ + clear: vi.fn(), + append: vi.fn() + })), + $createTextNode: vi.fn((text) => ({ + getTextContent: () => text, + setTextContent: vi.fn(), + splitText: vi.fn(() => []) + })), + $createParagraphNode: vi.fn(() => ({ + append: vi.fn() + })), + $getSelection: vi.fn(() => ({ + anchor: { + getNode: vi.fn(() => ({ + getTextContent: () => 'test text', + getType: () => 'text' + })), + offset: 0 + }, + insertNodes: vi.fn() + })), + $isRangeSelection: vi.fn(() => true), + $getNodeByKey: vi.fn(), + TextNode: class TextNode { + constructor(text) { + this.__text = text + } + getTextContent() { return this.__text } + setTextContent(text) { this.__text = text } + splitText() { return [] } + }, + KEY_ENTER_COMMAND: 'ENTER', + KEY_ARROW_UP_COMMAND: 'ARROW_UP', + KEY_ARROW_DOWN_COMMAND: 'ARROW_DOWN', + KEY_TAB_COMMAND: 'TAB', + KEY_BACKSPACE_COMMAND: 'BACKSPACE', + KEY_SPACE_COMMAND: 'SPACE', + COMMAND_PRIORITY_HIGH: 1, + COMMAND_PRIORITY_CRITICAL: 2 +})) + +vi.mock('@lexical/text', () => ({ + $rootTextContent: vi.fn(() => 'test message content') +})) + +// Mock window.app APIs +global.window.app = { + reply: { + onData: vi.fn((callback) => { + // Store callback for manual triggering in tests + global.mockReplyCallback = callback + return vi.fn() // cleanup function + }) + }, + kick: { + getUserChatroomInfo: vi.fn().mockResolvedValue({ + data: { + id: '123', + username: 'testuser', + slug: 'testuser' + } + }) + }, + userDialog: { + open: vi.fn() + } +} + +describe('ChatInput Component', () => { + const defaultProps = { + chatroomId: 'test-chatroom-1', + isReplyThread: false, + replyMessage: {}, + settings: { + chatrooms: { showInfoBar: true }, + sevenTV: { enabled: true } + } + } + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(screen.getByTestId('lexical-composer')).toBeInTheDocument() + expect(screen.getByTestId('content-editable')).toBeInTheDocument() + }) + + it('should render with correct structure', () => { + render() + + expect(screen.getByTestId('plain-text-plugin')).toBeInTheDocument() + expect(screen.getByTestId('auto-focus-plugin')).toBeInTheDocument() + expect(screen.getByTestId('history-plugin')).toBeInTheDocument() + expect(screen.getByTestId('emote-dialogs')).toBeInTheDocument() + }) + + it('should render placeholder text', () => { + render() + + expect(screen.getByText('Send a message...')).toBeInTheDocument() + }) + + it('should render info bar when enabled in settings', () => { + render() + + expect(screen.getByTestId('info-bar')).toBeInTheDocument() + }) + + it('should not render info bar when disabled in settings', () => { + const props = { + ...defaultProps, + settings: { chatrooms: { showInfoBar: false } } + } + + render() + + expect(screen.queryByTestId('info-bar')).not.toBeInTheDocument() + }) + }) + + describe('Message Input and Handling', () => { + it('should focus content editable element', () => { + render() + + const contentEditable = screen.getByTestId('content-editable') + expect(contentEditable).toHaveAttribute('contentEditable', 'true') + }) + + it('should handle basic text input', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByTestId('content-editable') + await user.click(input) + await user.type(input, 'Hello world') + + // Verify the input received the text (mocked behavior) + expect(input).toHaveTextContent('Hello world') + }) + + it('should handle Enter key to send message', async () => { + render() + + // Simulate Enter key press through the mock + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const enterHandler = mockEditor.registerCommand.mock.calls.find( + call => call[0] === 'ENTER' + )?.[1] + + if (enterHandler) { + const mockEvent = { shiftKey: false, preventDefault: vi.fn() } + enterHandler(mockEvent) + + expect(mockEvent.preventDefault).toHaveBeenCalled() + expect(mockChatStore.sendMessage).toHaveBeenCalledWith('test-chatroom-1', 'test message content') + } + }) + + it('should not send message on Shift+Enter', async () => { + render() + + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const enterHandler = mockEditor.registerCommand.mock.calls.find( + call => call[0] === 'ENTER' + )?.[1] + + if (enterHandler) { + const mockEvent = { shiftKey: true, preventDefault: vi.fn() } + const result = enterHandler(mockEvent) + + expect(result).toBe(false) + expect(mockChatStore.sendMessage).not.toHaveBeenCalled() + } + }) + + it('should not send empty messages', async () => { + vi.mocked(require('@lexical/text').$rootTextContent).mockReturnValue('') + render() + + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const enterHandler = mockEditor.registerCommand.mock.calls.find( + call => call[0] === 'ENTER' + )?.[1] + + if (enterHandler) { + const mockEvent = { shiftKey: false, preventDefault: vi.fn() } + enterHandler(mockEvent) + + expect(mockChatStore.sendMessage).not.toHaveBeenCalled() + } + }) + }) + + describe('Emote Functionality', () => { + it('should render emote picker', () => { + render() + + expect(screen.getByTestId('emote-dialogs')).toBeInTheDocument() + expect(screen.getByTestId('emote-button')).toBeInTheDocument() + }) + + it('should handle emote selection', async () => { + const user = userEvent.setup() + render() + + const emoteButton = screen.getByTestId('emote-button') + await user.click(emoteButton) + + // Verify emote handler was called (mocked implementation) + expect(emoteButton).toBeInTheDocument() + }) + + it('should show emote suggestions when typing colon', () => { + render() + + // This would be tested through the mocked update listener + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const updateListener = mockEditor.registerUpdateListener.mock.calls[0]?.[0] + + if (updateListener) { + // Mock editor state with colon input + const mockEditorState = { + read: vi.fn((fn) => { + // Mock selection and text content for emote suggestions + vi.mocked(require('lexical').$getSelection).mockReturnValue({ + anchor: { + getNode: vi.fn(() => ({ + getTextContent: () => ':kappa', + getType: () => 'text' + })), + offset: 6 + } + }) + fn() + }) + } + + updateListener({ editorState: mockEditorState }) + + expect(mockEditorState.read).toHaveBeenCalled() + } + }) + + it('should show chatter suggestions when typing @', () => { + render() + + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const updateListener = mockEditor.registerUpdateListener.mock.calls[0]?.[0] + + if (updateListener) { + const mockEditorState = { + read: vi.fn((fn) => { + vi.mocked(require('lexical').$getSelection).mockReturnValue({ + anchor: { + getNode: vi.fn(() => ({ + getTextContent: () => '@chat', + getType: () => 'text' + })), + offset: 5 + } + }) + fn() + }) + } + + updateListener({ editorState: mockEditorState }) + + expect(mockEditorState.read).toHaveBeenCalled() + } + }) + }) + + describe('Reply Functionality', () => { + it('should not show reply UI initially', () => { + render() + + expect(screen.queryByText(/Replying to/)).not.toBeInTheDocument() + }) + + it('should handle reply data from external API', async () => { + render() + + // Simulate reply data coming from external API + const replyData = { + id: 'message123', + content: 'Original message', + sender: { + id: 'user123', + username: 'testuser' + } + } + + // Trigger the reply callback + if (global.mockReplyCallback) { + act(() => { + global.mockReplyCallback(replyData) + }) + } + + await waitFor(() => { + expect(screen.getByText(/Replying to/)).toBeInTheDocument() + expect(screen.getByText(/@testuser/)).toBeInTheDocument() + }) + }) + + it('should close reply UI when close button is clicked', async () => { + const user = userEvent.setup() + render() + + // First set up reply data + const replyData = { + id: 'message123', + content: 'Original message', + sender: { + id: 'user123', + username: 'testuser' + } + } + + if (global.mockReplyCallback) { + act(() => { + global.mockReplyCallback(replyData) + }) + } + + await waitFor(() => { + expect(screen.getByText(/Replying to/)).toBeInTheDocument() + }) + + // Click close button + const closeButton = screen.getByRole('button') + await user.click(closeButton) + + await waitFor(() => { + expect(screen.queryByText(/Replying to/)).not.toBeInTheDocument() + }) + }) + + it('should send reply when reply data is present', async () => { + render() + + // Set up reply data + const replyData = { + id: 'message123', + content: 'Original message', + sender: { + id: 'user123', + username: 'testuser' + } + } + + if (global.mockReplyCallback) { + act(() => { + global.mockReplyCallback(replyData) + }) + } + + // Simulate sending a reply + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const enterHandler = mockEditor.registerCommand.mock.calls.find( + call => call[0] === 'ENTER' + )?.[1] + + if (enterHandler) { + const mockEvent = { shiftKey: false, preventDefault: vi.fn() } + enterHandler(mockEvent) + + expect(mockChatStore.sendReply).toHaveBeenCalledWith( + 'test-chatroom-1', + 'test message content', + expect.objectContaining({ + original_message: expect.objectContaining({ + id: 'message123', + content: 'Original message' + }), + original_sender: expect.objectContaining({ + username: 'testuser' + }) + }) + ) + } + }) + }) + + describe('Command Handling', () => { + it('should handle user lookup command', async () => { + vi.mocked(require('@lexical/text').$rootTextContent).mockReturnValue('/user @testuser') + render() + + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const enterHandler = mockEditor.registerCommand.mock.calls.find( + call => call[0] === 'ENTER' + )?.[1] + + if (enterHandler) { + const mockEvent = { shiftKey: false, preventDefault: vi.fn() } + await enterHandler(mockEvent) + + await waitFor(() => { + expect(window.app.kick.getUserChatroomInfo).toHaveBeenCalledWith('testuser', 'testuser') + }) + } + }) + + it('should handle arrow key navigation for message history', () => { + render() + + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const arrowUpHandler = mockEditor.registerCommand.mock.calls.find( + call => call[0] === 'ARROW_UP' + )?.[1] + + if (arrowUpHandler) { + const mockEvent = { preventDefault: vi.fn() } + arrowUpHandler(mockEvent) + + expect(mockEvent.preventDefault).toHaveBeenCalled() + } + }) + + it('should handle tab key for emote completion', () => { + render() + + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const tabHandler = mockEditor.registerCommand.mock.calls.find( + call => call[0] === 'TAB' + )?.[1] + + if (tabHandler) { + const mockEvent = { shiftKey: false, preventDefault: vi.fn() } + const result = tabHandler(mockEvent) + + expect(mockEvent.preventDefault).toHaveBeenCalled() + expect(result).toBe(true) + } + }) + }) + + describe('Draft Message Management', () => { + it('should save draft messages', () => { + render() + + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const updateListener = mockEditor.registerUpdateListener.mock.calls.find( + call => typeof call[0] === 'function' + )?.[0] + + if (updateListener) { + const mockEditorState = { + read: vi.fn((fn) => fn()) + } + + updateListener({ editorState: mockEditorState }) + + expect(mockChatStore.saveDraftMessage).toHaveBeenCalledWith( + 'test-chatroom-1', + 'test message content' + ) + } + }) + + it('should restore draft messages when switching chatrooms', () => { + mockChatStore.getDraftMessage.mockReturnValue('saved draft message') + + render() + + expect(mockChatStore.getDraftMessage).toHaveBeenCalledWith('test-chatroom-1') + }) + + it('should clear draft after sending message', async () => { + render() + + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const enterHandler = mockEditor.registerCommand.mock.calls.find( + call => call[0] === 'ENTER' + )?.[1] + + if (enterHandler) { + const mockEvent = { shiftKey: false, preventDefault: vi.fn() } + await enterHandler(mockEvent) + + expect(mockChatStore.clearDraftMessage).toHaveBeenCalledWith('test-chatroom-1') + } + }) + }) + + describe('Accessibility', () => { + it('should have proper ARIA attributes', () => { + render() + + const contentEditable = screen.getByTestId('content-editable') + expect(contentEditable).toHaveAttribute('aria-placeholder', 'Enter message...') + }) + + it('should have proper keyboard navigation', () => { + render() + + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + + // Verify key handlers are registered + expect(mockEditor.registerCommand).toHaveBeenCalledWith( + 'ENTER', + expect.any(Function), + expect.any(Number) + ) + expect(mockEditor.registerCommand).toHaveBeenCalledWith( + 'ARROW_UP', + expect.any(Function), + expect.any(Number) + ) + expect(mockEditor.registerCommand).toHaveBeenCalledWith( + 'ARROW_DOWN', + expect.any(Function), + expect.any(Number) + ) + expect(mockEditor.registerCommand).toHaveBeenCalledWith( + 'TAB', + expect.any(Function), + expect.any(Number) + ) + }) + }) + + describe('Error Handling', () => { + it('should handle failed message sends gracefully', async () => { + mockChatStore.sendMessage.mockRejectedValueOnce(new Error('Send failed')) + + render() + + const mockEditor = require('@lexical/react/LexicalComposerContext').useLexicalComposerContext()[0] + const enterHandler = mockEditor.registerCommand.mock.calls.find( + call => call[0] === 'ENTER' + )?.[1] + + if (enterHandler) { + const mockEvent = { shiftKey: false, preventDefault: vi.fn() } + + // Should not throw error + expect(() => enterHandler(mockEvent)).not.toThrow() + } + }) + + it('should handle invalid reply data', async () => { + render() + + // Simulate invalid reply data + const invalidReplyData = null + + if (global.mockReplyCallback) { + act(() => { + global.mockReplyCallback(invalidReplyData) + }) + } + + // Should not crash or show reply UI + expect(screen.queryByText(/Replying to/)).not.toBeInTheDocument() + }) + + it('should handle missing reply API gracefully', () => { + // Remove the reply API temporarily + const originalAPI = window.app.reply + delete window.app.reply + + // Should not crash when rendering + expect(() => { + render() + }).not.toThrow() + + // Restore API + window.app.reply = originalAPI + }) + }) + + describe('Integration Tests', () => { + it('should integrate with chat store properly', () => { + render() + + // Verify chat store is accessed for chatroom data + expect(mockChatStore.chatrooms).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'test-chatroom-1', + username: 'testuser' + }) + ]) + ) + }) + + it('should handle chatroom switching', () => { + const { rerender } = render() + + // Switch to different chatroom + rerender() + + // Should clear reply data and reset state + expect(screen.queryByText(/Replying to/)).not.toBeInTheDocument() + }) + + it('should handle reply thread mode', () => { + const replyProps = { + ...defaultProps, + isReplyThread: true, + replyMessage: { + original_message: { id: 'msg1', content: 'Original' }, + original_sender: { username: 'sender1' } + } + } + + render() + + // Should be in reply thread mode + expect(screen.getByTestId('lexical-composer')).toBeInTheDocument() + }) + }) + + describe('Performance', () => { + it('should memoize component properly', () => { + const { rerender } = render() + + // Re-render with same props should not cause full re-render + rerender() + + // Component should still be rendered correctly + expect(screen.getByTestId('lexical-composer')).toBeInTheDocument() + }) + + it('should handle large emote lists efficiently', () => { + const largeEmoteProps = { + ...defaultProps, + settings: { + ...defaultProps.settings, + sevenTV: { enabled: true } + } + } + + // Should render without performance issues + render() + + expect(screen.getByTestId('emote-dialogs')).toBeInTheDocument() + }) + }) +}) \ No newline at end of file diff --git a/src/renderer/src/components/Chat/Input/integration.test.jsx b/src/renderer/src/components/Chat/Input/integration.test.jsx new file mode 100644 index 0000000..cae269b --- /dev/null +++ b/src/renderer/src/components/Chat/Input/integration.test.jsx @@ -0,0 +1,759 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor, act, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ChatInput from './index.jsx' +import { + createMockChatStore, + createMockWindowApp, + createMockReplyData, + triggerMockReply, + setupMockEnvironment, + cleanupMockEnvironment +} from './__tests__/test-utils.js' + +// Mock all dependencies +vi.mock('clsx', () => ({ + default: (...args) => args.filter(Boolean).join(' ') +})) + +vi.mock('../../../assets/styles/components/Chat/Input.scss') +vi.mock('../../../assets/icons/x-bold.svg?asset', () => ({ default: 'x-icon.svg' })) +vi.mock('../../../assets/icons/lock-simple-fill.svg?asset', () => ({ default: 'lock-icon.svg' })) + +vi.mock('./EmoteDialogs', () => ({ + default: vi.fn(({ handleEmoteClick, chatroomId }) => ( +
+ + + +
+ )) +})) + +vi.mock('./InfoBar', () => ({ + default: vi.fn(({ chatroomInfo }) => ( +
+ Info: {chatroomInfo?.title || 'No Info'} +
+ )) +})) + +vi.mock('../../../utils/MessageParser', () => ({ + MessageParser: vi.fn(({ message }) => ( + {message?.content || 'No content'} + )) +})) + +// Enhanced mock chat store +const mockChatStore = createMockChatStore({ + chatrooms: [ + { + id: 'test-chatroom-1', + username: 'teststreamer', + userChatroomInfo: { + subscription: true, + id: 'user123' + }, + streamerData: { + id: 'streamer123', + user_id: 'user123', + user: { username: 'teststreamer' }, + subscriber_badges: [ + { months: 1, badge_image: { src: 'badge1.png' } }, + { months: 6, badge_image: { src: 'badge6.png' } } + ] + }, + emotes: [{ + emotes: [ + { id: '1', name: 'Kappa', platform: 'kick', subscribers_only: false }, + { id: '2', name: 'SubEmote', platform: 'kick', subscribers_only: true } + ] + }], + channel7TVEmotes: [{ + type: 'channel', + emotes: [ + { id: '7tv1', name: 'OMEGALUL', platform: '7tv', width: '28px', height: '28px' } + ] + }], + chatroomInfo: { title: 'Test Stream Chat' }, + initialChatroomInfo: { title: 'Initial Chat Info' } + } + ], + chatters: { + 'test-chatroom-1': [ + { id: '1', username: 'viewer1' }, + { id: '2', username: 'moderator1' }, + { id: '3', username: 'subscriber1' } + ] + }, + messages: { + 'test-chatroom-1': [ + { + id: 'msg1', + content: 'Hello world!', + sender: { id: 'user1', username: 'viewer1' }, + type: 'message' + } + ] + } +}) + +vi.mock('../../../providers/ChatProvider', () => ({ + default: vi.fn((selector) => { + if (typeof selector === 'function') { + return selector(mockChatStore) + } + return mockChatStore + }) +})) + +vi.mock('zustand/react/shallow', () => ({ + useShallow: vi.fn((fn) => fn) +})) + +// Mock Lexical with more realistic behavior +const mockEditor = { + focus: vi.fn(), + update: vi.fn((fn) => fn()), + getRootElement: vi.fn(() => document.createElement('div')), + registerCommand: vi.fn(() => vi.fn()), + registerUpdateListener: vi.fn(() => vi.fn()), + registerNodeTransform: vi.fn(() => vi.fn()) +} + +vi.mock('@lexical/react/LexicalComposerContext', () => ({ + useLexicalComposerContext: vi.fn(() => [mockEditor]) +})) + +vi.mock('@lexical/react/LexicalComposer', () => ({ + LexicalComposer: vi.fn(({ children }) => ( +
+ {children} +
+ )) +})) + +vi.mock('@lexical/react/PlainTextPlugin', () => ({ + PlainTextPlugin: vi.fn(({ contentEditable, placeholder }) => ( +
+
+ {contentEditable} +
+ {placeholder} +
+ )) +})) + +vi.mock('@lexical/react/LexicalContentEditable', () => ({ + ContentEditable: vi.fn((props) => ( +