diff --git a/lib/setupTest.ts b/lib/setupTest.ts index 0f35e917..2a82d9c3 100644 --- a/lib/setupTest.ts +++ b/lib/setupTest.ts @@ -1,2 +1,26 @@ // eslint-disable-next-line import-x/no-unassigned-import import '@testing-library/jest-dom' +import { vi } from 'vitest' + +// TipTap requires Range and Document APIs that happy-dom doesn't fully implement +Range.prototype.getBoundingClientRect = () => ({ + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + toJSON: vi.fn(), +}) + +Range.prototype.getClientRects = () => ({ + item: () => null, + length: 0, + [Symbol.iterator]: vi.fn(), +}) + +if (typeof Document.prototype.elementFromPoint === 'undefined') { + Document.prototype.elementFromPoint = vi.fn() +} diff --git a/lib/src/Components/Input/RichTextEditor.spec.tsx b/lib/src/Components/Input/RichTextEditor.spec.tsx new file mode 100644 index 00000000..6b297a44 --- /dev/null +++ b/lib/src/Components/Input/RichTextEditor.spec.tsx @@ -0,0 +1,175 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { RichTextEditor } from './RichTextEditor' + +import type { Item } from '#types/Item' +import type { Tag } from '#types/Tag' + +vi.mock('#components/Map/hooks/useTags') +vi.mock('#components/Map/hooks/useItems') +vi.mock('#components/Map/hooks/useItemColor') + +const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +const mockTags: Tag[] = [ + { id: '1', name: 'nature', color: '#22c55e' }, + { id: '2', name: 'tech', color: '#3b82f6' }, +] + +const mockItems: Item[] = [ + { id: 'abc-123', name: 'Alice', color: '#ef4444' } as Item, + { id: 'def-456', name: 'Bob', color: '#8b5cf6' } as Item, +] + +describe('', () => { + let mockUseTags: ReturnType + let mockUseAddTag: ReturnType + let mockUseItems: ReturnType + let mockUseGetItemColor: ReturnType + let mockAddTag: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + mockAddTag = vi.fn() + + const { useTags, useAddTag } = await import('#components/Map/hooks/useTags') + const { useItems } = await import('#components/Map/hooks/useItems') + const { useGetItemColor } = await import('#components/Map/hooks/useItemColor') + + mockUseTags = vi.mocked(useTags) + mockUseAddTag = vi.mocked(useAddTag) + mockUseItems = vi.mocked(useItems) + mockUseGetItemColor = vi.mocked(useGetItemColor) + + mockUseTags.mockReturnValue(mockTags) + mockUseAddTag.mockReturnValue(mockAddTag) + mockUseItems.mockReturnValue(mockItems) + mockUseGetItemColor.mockReturnValue((item: Item | undefined) => item?.color ?? '#3b82f6') + }) + + describe('Rendering', () => { + it('renders with default value', async () => { + render( + + + , + ) + await waitFor(() => { + expect(screen.getByText('world')).toBeInTheDocument() + }) + }) + + it('renders label when labelTitle is provided', async () => { + render( + + + , + ) + await waitFor(() => { + expect(screen.getByText(/Description/)).toBeInTheDocument() + }) + }) + + it('renders without label when labelTitle is not provided', async () => { + render( + + + , + ) + await waitFor(() => { + expect(screen.getByText('Test')).toBeInTheDocument() + }) + }) + + it('renders menu by default', async () => { + render( + + + , + ) + await waitFor(() => { + expect(document.querySelector('.editor-wrapper')).toBeInTheDocument() + }) + }) + + it('hides menu when showMenu=false', async () => { + render( + + + , + ) + await waitFor(() => { + expect(screen.getByText('Test')).toBeInTheDocument() + }) + }) + }) + + describe('Content Editing', () => { + it('calls updateFormValue when content changes', async () => { + const updateFormValue = vi.fn() + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Initial')).toBeInTheDocument() + }) + + const editor = document.querySelector('.ProseMirror') + expect(editor).toBeInTheDocument() + + if (editor) { + fireEvent.input(editor, { target: { textContent: 'Updated content' } }) + } + }) + }) + + describe('Hashtag Rendering', () => { + it('renders hashtag with correct styling', async () => { + render( + + + , + ) + await waitFor(() => { + const hashtag = screen.getByText('#nature') + expect(hashtag).toHaveClass('hashtag') + }) + }) + }) + + describe('Item Mention Rendering', () => { + it('renders item mention with correct styling', async () => { + render( + + + , + ) + await waitFor(() => { + const mention = screen.getByText('@Alice') + expect(mention).toHaveClass('item-mention') + }) + }) + }) + + describe('Placeholder', () => { + it('renders editor wrapper when empty', async () => { + render( + + + , + ) + await waitFor(() => { + const editor = document.querySelector('.ProseMirror') + expect(editor).toBeInTheDocument() + }) + }) + }) +}) + diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.spec.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.spec.tsx new file mode 100644 index 00000000..a83bf8cc --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.spec.tsx @@ -0,0 +1,193 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { TextView } from './TextView' + +import type { Item } from '#types/Item' +import type { Tag } from '#types/Tag' + +const mockNavigate = vi.fn() + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useNavigate: () => mockNavigate, + } +}) + +vi.mock('#components/Map/hooks/useTags') +vi.mock('#components/Map/hooks/useItems') +vi.mock('#components/Map/hooks/useItemColor') +vi.mock('#components/Map/hooks/useFilter') + +const mockTags: Tag[] = [ + { id: '1', name: 'nature', color: '#22c55e' }, + { id: '2', name: 'tech', color: '#3b82f6' }, +] + +const mockItems: Item[] = [ + { id: 'abc-123', name: 'Alice', color: '#ef4444' } as Item, + { id: 'def-456', name: 'Bob', color: '#8b5cf6' } as Item, +] + +const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('', () => { + let mockUseTags: ReturnType + let mockUseItems: ReturnType + let mockUseGetItemColor: ReturnType + let mockUseAddFilterTag: ReturnType + let mockAddFilterTag: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + mockNavigate.mockClear() + mockAddFilterTag = vi.fn() + + const { useTags } = await import('#components/Map/hooks/useTags') + const { useItems } = await import('#components/Map/hooks/useItems') + const { useGetItemColor } = await import('#components/Map/hooks/useItemColor') + const { useAddFilterTag } = await import('#components/Map/hooks/useFilter') + + mockUseTags = vi.mocked(useTags) + mockUseItems = vi.mocked(useItems) + mockUseGetItemColor = vi.mocked(useGetItemColor) + mockUseAddFilterTag = vi.mocked(useAddFilterTag) + + mockUseTags.mockReturnValue(mockTags) + mockUseItems.mockReturnValue(mockItems) + mockUseGetItemColor.mockReturnValue((item: Item | undefined) => item?.color ?? '#3b82f6') + mockUseAddFilterTag.mockReturnValue(mockAddFilterTag) + }) + + describe('Rendering', () => { + it('renders markdown content via TipTap', async () => { + render( + + + , + ) + await waitFor(() => { + expect(screen.getByText('world')).toBeInTheDocument() + }) + }) + + it('returns null for empty text', () => { + const { container } = render( + + + , + ) + expect(container.firstChild).toBeNull() + }) + + it('returns null for null text', () => { + const { container } = render( + + + , + ) + expect(container.firstChild).toBeNull() + }) + + it('shows login message when text is undefined', async () => { + render( + + + , + ) + await waitFor(() => { + expect(screen.getByText('Login')).toBeInTheDocument() + }) + }) + + it('uses item.text when item prop is provided', async () => { + const item = { id: '1', name: 'Test', text: 'Item content here' } as Item + render( + + + , + ) + await waitFor(() => { + expect(screen.getByText(/Item content here/)).toBeInTheDocument() + }) + }) + }) + + describe('Truncation', () => { + it('truncates long text when truncate=true', async () => { + const longText = 'A'.repeat(150) + render( + + + , + ) + await waitFor(() => { + expect(screen.getByText(/\.\.\.$/)).toBeInTheDocument() + }) + }) + }) + + describe('Hashtag Rendering', () => { + it('renders hashtag with correct styling', async () => { + render( + + + , + ) + await waitFor(() => { + const hashtag = screen.getByText('#nature') + expect(hashtag).toHaveClass('hashtag') + }) + }) + + it('calls addFilterTag when hashtag is clicked', async () => { + render( + + + , + ) + await waitFor(() => { + expect(screen.getByText('#nature')).toBeInTheDocument() + }) + const hashtag = screen.getByText('#nature') + fireEvent.click(hashtag) + expect(mockAddFilterTag).toHaveBeenCalledWith(mockTags[0]) + }) + }) + + describe('Item Mention Rendering', () => { + it('renders item mention with correct styling', async () => { + render( + + + , + ) + await waitFor(() => { + const mention = screen.getByText('@Alice') + expect(mention).toHaveClass('item-mention') + }) + }) + }) + + describe('Link Navigation', () => { + it('navigates internally for relative links', async () => { + render( + + + , + ) + await waitFor(() => { + expect(screen.getByText('profile')).toBeInTheDocument() + }) + const link = screen.getByText('profile') + fireEvent.click(link) + expect(mockNavigate).toHaveBeenCalledWith('/profile/123') + }) + }) +}) + diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextViewStatic.spec.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextViewStatic.spec.tsx new file mode 100644 index 00000000..b4ee51e8 --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextViewStatic.spec.tsx @@ -0,0 +1,241 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { TextViewStatic } from './TextViewStatic' + +import type { Item } from '#types/Item' +import type { Tag } from '#types/Tag' + +const mockNavigate = vi.fn() + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useNavigate: () => mockNavigate, + } +}) + +vi.mock('#components/Map/hooks/useTags') +vi.mock('#components/Map/hooks/useItems') +vi.mock('#components/Map/hooks/useItemColor') +vi.mock('#components/Map/hooks/useFilter') + +const mockTags: Tag[] = [ + { id: '1', name: 'nature', color: '#22c55e' }, + { id: '2', name: 'tech', color: '#3b82f6' }, +] + +const mockItems: Item[] = [ + { id: 'abc-123', name: 'Alice', color: '#ef4444' } as Item, + { id: 'def-456', name: 'Bob', color: '#8b5cf6' } as Item, +] + +const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('', () => { + let mockUseTags: ReturnType + let mockUseItems: ReturnType + let mockUseGetItemColor: ReturnType + let mockUseAddFilterTag: ReturnType + let mockAddFilterTag: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + mockNavigate.mockClear() + mockAddFilterTag = vi.fn() + + const { useTags } = await import('#components/Map/hooks/useTags') + const { useItems } = await import('#components/Map/hooks/useItems') + const { useGetItemColor } = await import('#components/Map/hooks/useItemColor') + const { useAddFilterTag } = await import('#components/Map/hooks/useFilter') + + mockUseTags = vi.mocked(useTags) + mockUseItems = vi.mocked(useItems) + mockUseGetItemColor = vi.mocked(useGetItemColor) + mockUseAddFilterTag = vi.mocked(useAddFilterTag) + + mockUseTags.mockReturnValue(mockTags) + mockUseItems.mockReturnValue(mockItems) + mockUseGetItemColor.mockReturnValue((item: Item | undefined) => item?.color ?? '#3b82f6') + mockUseAddFilterTag.mockReturnValue(mockAddFilterTag) + }) + + describe('Rendering', () => { + it('renders markdown content as HTML', () => { + render( + + + , + ) + expect(screen.getByText('world')).toBeInTheDocument() + expect(screen.getByText('world').tagName).toBe('STRONG') + }) + + it('returns null for empty text', () => { + const { container } = render( + + + , + ) + expect(container.firstChild).toBeNull() + }) + + it('returns null for null text', () => { + const { container } = render( + + + , + ) + expect(container.firstChild).toBeNull() + }) + + it('shows login message when text is undefined', () => { + render( + + + , + ) + expect(screen.getByText('Login')).toBeInTheDocument() + }) + + it('uses item.text when item prop is provided', () => { + const item = { id: '1', name: 'Test', text: 'Item content here' } as Item + render( + + + , + ) + expect(screen.getByText(/Item content here/)).toBeInTheDocument() + }) + + it('uses rawText prop directly without processing', () => { + render( + + + , + ) + expect(screen.getByText('text')).toBeInTheDocument() + }) + }) + + describe('Truncation', () => { + it('truncates long text when truncate=true', () => { + const longText = 'A'.repeat(150) + render( + + + , + ) + expect(screen.getByText(/\.\.\.$/)).toBeInTheDocument() + }) + + it('does not truncate when truncate=false', () => { + const longText = 'A'.repeat(150) + render( + + + , + ) + expect(screen.queryByText(/\.\.\.$/)).not.toBeInTheDocument() + }) + }) + + describe('Hashtag Rendering', () => { + it('renders hashtag with correct color', () => { + render( + + + , + ) + const hashtag = screen.getByText('#nature') + expect(hashtag).toHaveStyle({ color: '#22c55e' }) + }) + + it('renders unknown hashtag with inherit color', () => { + render( + + + , + ) + const hashtag = screen.getByText('#sometag') + expect(hashtag).toHaveStyle({ color: 'inherit' }) + }) + + it('calls addFilterTag when hashtag is clicked', () => { + render( + + + , + ) + const hashtag = screen.getByText('#nature') + fireEvent.click(hashtag) + expect(mockAddFilterTag).toHaveBeenCalledWith(mockTags[0]) + }) + }) + + describe('Item Mention Rendering', () => { + it('renders item mention as link with correct color', () => { + render( + + + , + ) + const mention = screen.getByText('@Alice') + expect(mention.tagName).toBe('A') + expect(mention).toHaveAttribute('href', '/item/abc-123') + expect(mention).toHaveStyle({ color: '#ef4444' }) + }) + + it('renders unknown item mention with fallback color', () => { + render( + + + , + ) + const mention = screen.getByText('@Unknown') + expect(mention).toBeInTheDocument() + }) + }) + + describe('Link Navigation', () => { + it('navigates internally for relative links', () => { + render( + + + , + ) + const link = screen.getByText('profile') + fireEvent.click(link) + expect(mockNavigate).toHaveBeenCalledWith('/profile/123') + }) + + it('does not navigate for external links', () => { + render( + + + , + ) + const link = screen.getByText('Example') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + describe('Video Embed', () => { + it('renders YouTube video as iframe', () => { + render( + + + , + ) + const iframe = document.querySelector('iframe') + expect(iframe).toBeInTheDocument() + expect(iframe).toHaveAttribute('src', 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ') + }) + }) +}) +