test: add component tests for TipTap editor integration

- Add TextViewStatic.spec.tsx (16 tests): static HTML renderer tests
- Add TextView.spec.tsx (10 tests): TipTap read-only viewer tests
- Add RichTextEditor.spec.tsx (9 tests): editable editor tests
- Update setupTest.ts with TipTap DOM mocks (Range, Document APIs)

Tests cover rendering, truncation, hashtag/mention styling and clicks,
link navigation, and video embed rendering.
This commit is contained in:
mahula 2026-01-19 10:29:54 +01:00
parent 8e5c6a0907
commit 942559247e
4 changed files with 633 additions and 0 deletions

View File

@ -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()
}

View File

@ -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 }) => (
<MemoryRouter>{children}</MemoryRouter>
)
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('<RichTextEditor />', () => {
let mockUseTags: ReturnType<typeof vi.fn>
let mockUseAddTag: ReturnType<typeof vi.fn>
let mockUseItems: ReturnType<typeof vi.fn>
let mockUseGetItemColor: ReturnType<typeof vi.fn>
let mockAddTag: ReturnType<typeof vi.fn>
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(
<Wrapper>
<RichTextEditor defaultValue='Hello **world**' />
</Wrapper>,
)
await waitFor(() => {
expect(screen.getByText('world')).toBeInTheDocument()
})
})
it('renders label when labelTitle is provided', async () => {
render(
<Wrapper>
<RichTextEditor defaultValue='Test' labelTitle='Description' />
</Wrapper>,
)
await waitFor(() => {
expect(screen.getByText(/Description/)).toBeInTheDocument()
})
})
it('renders without label when labelTitle is not provided', async () => {
render(
<Wrapper>
<RichTextEditor defaultValue='Test' />
</Wrapper>,
)
await waitFor(() => {
expect(screen.getByText('Test')).toBeInTheDocument()
})
})
it('renders menu by default', async () => {
render(
<Wrapper>
<RichTextEditor defaultValue='Test' />
</Wrapper>,
)
await waitFor(() => {
expect(document.querySelector('.editor-wrapper')).toBeInTheDocument()
})
})
it('hides menu when showMenu=false', async () => {
render(
<Wrapper>
<RichTextEditor defaultValue='Test' showMenu={false} />
</Wrapper>,
)
await waitFor(() => {
expect(screen.getByText('Test')).toBeInTheDocument()
})
})
})
describe('Content Editing', () => {
it('calls updateFormValue when content changes', async () => {
const updateFormValue = vi.fn()
render(
<Wrapper>
<RichTextEditor defaultValue='Initial' updateFormValue={updateFormValue} />
</Wrapper>,
)
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(
<Wrapper>
<RichTextEditor defaultValue='Check out #nature' />
</Wrapper>,
)
await waitFor(() => {
const hashtag = screen.getByText('#nature')
expect(hashtag).toHaveClass('hashtag')
})
})
})
describe('Item Mention Rendering', () => {
it('renders item mention with correct styling', async () => {
render(
<Wrapper>
<RichTextEditor defaultValue='Thanks [@Alice](/item/abc-123)' />
</Wrapper>,
)
await waitFor(() => {
const mention = screen.getByText('@Alice')
expect(mention).toHaveClass('item-mention')
})
})
})
describe('Placeholder', () => {
it('renders editor wrapper when empty', async () => {
render(
<Wrapper>
<RichTextEditor defaultValue='' placeholder='Enter description...' />
</Wrapper>,
)
await waitFor(() => {
const editor = document.querySelector('.ProseMirror')
expect(editor).toBeInTheDocument()
})
})
})
})

View File

@ -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 }) => (
<MemoryRouter>{children}</MemoryRouter>
)
describe('<TextView />', () => {
let mockUseTags: ReturnType<typeof vi.fn>
let mockUseItems: ReturnType<typeof vi.fn>
let mockUseGetItemColor: ReturnType<typeof vi.fn>
let mockUseAddFilterTag: ReturnType<typeof vi.fn>
let mockAddFilterTag: ReturnType<typeof vi.fn>
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(
<Wrapper>
<TextView text='Hello **world**' />
</Wrapper>,
)
await waitFor(() => {
expect(screen.getByText('world')).toBeInTheDocument()
})
})
it('returns null for empty text', () => {
const { container } = render(
<Wrapper>
<TextView text='' />
</Wrapper>,
)
expect(container.firstChild).toBeNull()
})
it('returns null for null text', () => {
const { container } = render(
<Wrapper>
<TextView text={null} />
</Wrapper>,
)
expect(container.firstChild).toBeNull()
})
it('shows login message when text is undefined', async () => {
render(
<Wrapper>
<TextView text={undefined} />
</Wrapper>,
)
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(
<Wrapper>
<TextView item={item} />
</Wrapper>,
)
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(
<Wrapper>
<TextView text={longText} truncate={true} />
</Wrapper>,
)
await waitFor(() => {
expect(screen.getByText(/\.\.\.$/)).toBeInTheDocument()
})
})
})
describe('Hashtag Rendering', () => {
it('renders hashtag with correct styling', async () => {
render(
<Wrapper>
<TextView text='Check out #nature' />
</Wrapper>,
)
await waitFor(() => {
const hashtag = screen.getByText('#nature')
expect(hashtag).toHaveClass('hashtag')
})
})
it('calls addFilterTag when hashtag is clicked', async () => {
render(
<Wrapper>
<TextView text='Click #nature here' />
</Wrapper>,
)
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(
<Wrapper>
<TextView text='Thanks [@Alice](/item/abc-123)' />
</Wrapper>,
)
await waitFor(() => {
const mention = screen.getByText('@Alice')
expect(mention).toHaveClass('item-mention')
})
})
})
describe('Link Navigation', () => {
it('navigates internally for relative links', async () => {
render(
<Wrapper>
<TextView text='Go to [profile](/profile/123)' />
</Wrapper>,
)
await waitFor(() => {
expect(screen.getByText('profile')).toBeInTheDocument()
})
const link = screen.getByText('profile')
fireEvent.click(link)
expect(mockNavigate).toHaveBeenCalledWith('/profile/123')
})
})
})

View File

@ -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 }) => (
<MemoryRouter>{children}</MemoryRouter>
)
describe('<TextViewStatic />', () => {
let mockUseTags: ReturnType<typeof vi.fn>
let mockUseItems: ReturnType<typeof vi.fn>
let mockUseGetItemColor: ReturnType<typeof vi.fn>
let mockUseAddFilterTag: ReturnType<typeof vi.fn>
let mockAddFilterTag: ReturnType<typeof vi.fn>
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(
<Wrapper>
<TextViewStatic text='Hello **world**' />
</Wrapper>,
)
expect(screen.getByText('world')).toBeInTheDocument()
expect(screen.getByText('world').tagName).toBe('STRONG')
})
it('returns null for empty text', () => {
const { container } = render(
<Wrapper>
<TextViewStatic text='' />
</Wrapper>,
)
expect(container.firstChild).toBeNull()
})
it('returns null for null text', () => {
const { container } = render(
<Wrapper>
<TextViewStatic text={null} />
</Wrapper>,
)
expect(container.firstChild).toBeNull()
})
it('shows login message when text is undefined', () => {
render(
<Wrapper>
<TextViewStatic text={undefined} />
</Wrapper>,
)
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(
<Wrapper>
<TextViewStatic item={item} />
</Wrapper>,
)
expect(screen.getByText(/Item content here/)).toBeInTheDocument()
})
it('uses rawText prop directly without processing', () => {
render(
<Wrapper>
<TextViewStatic rawText='Raw **text** content' />
</Wrapper>,
)
expect(screen.getByText('text')).toBeInTheDocument()
})
})
describe('Truncation', () => {
it('truncates long text when truncate=true', () => {
const longText = 'A'.repeat(150)
render(
<Wrapper>
<TextViewStatic text={longText} truncate={true} />
</Wrapper>,
)
expect(screen.getByText(/\.\.\.$/)).toBeInTheDocument()
})
it('does not truncate when truncate=false', () => {
const longText = 'A'.repeat(150)
render(
<Wrapper>
<TextViewStatic text={longText} truncate={false} />
</Wrapper>,
)
expect(screen.queryByText(/\.\.\.$/)).not.toBeInTheDocument()
})
})
describe('Hashtag Rendering', () => {
it('renders hashtag with correct color', () => {
render(
<Wrapper>
<TextViewStatic text='Check out #nature' />
</Wrapper>,
)
const hashtag = screen.getByText('#nature')
expect(hashtag).toHaveStyle({ color: '#22c55e' })
})
it('renders unknown hashtag with inherit color', () => {
render(
<Wrapper>
<TextViewStatic text='Unknown #sometag here' />
</Wrapper>,
)
const hashtag = screen.getByText('#sometag')
expect(hashtag).toHaveStyle({ color: 'inherit' })
})
it('calls addFilterTag when hashtag is clicked', () => {
render(
<Wrapper>
<TextViewStatic text='Click #nature here' />
</Wrapper>,
)
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(
<Wrapper>
<TextViewStatic text='Thanks [@Alice](/item/abc-123)' />
</Wrapper>,
)
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(
<Wrapper>
<TextViewStatic text='See [@Unknown](/item/xyz-999)' />
</Wrapper>,
)
const mention = screen.getByText('@Unknown')
expect(mention).toBeInTheDocument()
})
})
describe('Link Navigation', () => {
it('navigates internally for relative links', () => {
render(
<Wrapper>
<TextViewStatic text='Go to [profile](/profile/123)' />
</Wrapper>,
)
const link = screen.getByText('profile')
fireEvent.click(link)
expect(mockNavigate).toHaveBeenCalledWith('/profile/123')
})
it('does not navigate for external links', () => {
render(
<Wrapper>
<TextViewStatic text='Visit [Example](https://example.com)' />
</Wrapper>,
)
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(
<Wrapper>
<TextViewStatic text='Watch <https://www.youtube.com/watch?v=dQw4w9WgXcQ>' />
</Wrapper>,
)
const iframe = document.querySelector('iframe')
expect(iframe).toBeInTheDocument()
expect(iframe).toHaveAttribute('src', 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ')
})
})
})