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')
+ })
+ })
+})
+