From 06eef7def2b450a50010629b1810bb1ddc86ab23 Mon Sep 17 00:00:00 2001
From: d-oit <6849456+d-oit@users.noreply.github.com>
Date: Thu, 30 Jul 2026 18:14:43 +0200
Subject: [PATCH] test: add branch-coverage tests for mindmap-view.tsx
---
.../views/mindmap-view-coverage.test.tsx | 354 ++++++++++++++++++
1 file changed, 354 insertions(+)
create mode 100644 src/components/studio/views/mindmap-view-coverage.test.tsx
diff --git a/src/components/studio/views/mindmap-view-coverage.test.tsx b/src/components/studio/views/mindmap-view-coverage.test.tsx
new file mode 100644
index 00000000..bfea3f79
--- /dev/null
+++ b/src/components/studio/views/mindmap-view-coverage.test.tsx
@@ -0,0 +1,354 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import type { ReactNode } from 'react'
+import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'
+
+vi.mock('framer-motion', () => ({
+ motion: {
+ div: ({ children, initial: _i, animate: _a, transition: _t, exit: _e, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
+
)}>{children}
+ ),
+ },
+ AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}>,
+}))
+
+vi.mock('lucide-react', () => {
+ const I = ({ className }: { className?: string }) => (
+
+ )
+ return {
+ BrainCircuit: I,
+ Plus: I,
+ Trash2: I,
+ Edit3: I,
+ Undo2: I,
+ Redo2: I,
+ RefreshCw: I,
+ Download: I,
+ ChevronRight: I,
+ ChevronDown: I,
+ Sliders: I,
+ }
+})
+
+vi.mock('@/lib/studio/use-reduced-motion', () => ({
+ useReducedMotion: () => true,
+}))
+
+vi.mock('@/lib/utils', () => ({
+ cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
+}))
+
+vi.mock('../ui/shared-primitives', () => ({
+ Divider: () =>
,
+}))
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
+}))
+
+vi.mock('./export-helpers', () => ({
+ todayStamp: () => '2026-01-01',
+ downloadBlob: vi.fn(),
+}))
+
+const mockSelectEntity = vi.fn()
+const mockSetView = vi.fn()
+const mockCommitEntity = vi.fn()
+const mockDeleteEntity = vi.fn()
+const mockStartEdit = vi.fn()
+const mockUndo = vi.fn()
+const mockRedo = vi.fn()
+
+const childEntity = {
+ id: 'ent-2',
+ name: 'Child Entity',
+ type: 'note' as const,
+ description: 'child',
+ content: '',
+ tags: [],
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ links: [
+ { targetId: 'ent-3', relation: 'contains' },
+ ],
+}
+
+const grandchildEntity = {
+ id: 'ent-3',
+ name: 'Grandchild Entity',
+ type: 'project' as const,
+ description: 'grandchild',
+ content: '',
+ tags: [],
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ links: [],
+}
+
+const mockEntities = [
+ {
+ id: 'ent-1',
+ name: 'Root Entity',
+ type: 'concept' as const,
+ description: 'root',
+ content: '',
+ tags: [],
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ links: [
+ { targetId: 'ent-2', relation: 'contains' },
+ ],
+ },
+ childEntity,
+ grandchildEntity,
+]
+
+let currentEntities = mockEntities
+let currentHistoryIndex = 0
+let currentEntityHistory: unknown[][] = [[]]
+
+vi.mock('@/lib/studio/store', () => ({
+ useStudioStore: (selector: (s: Record) => unknown) =>
+ selector({
+ entities: currentEntities,
+ selectEntity: mockSelectEntity,
+ setView: mockSetView,
+ commitEntity: mockCommitEntity,
+ deleteEntity: mockDeleteEntity,
+ startEdit: mockStartEdit,
+ undo: mockUndo,
+ redo: mockRedo,
+ entityHistory: currentEntityHistory,
+ historyIndex: currentHistoryIndex,
+ }),
+}))
+
+import { MindMapView } from './mindmap-view'
+import * as exportHelpers from './export-helpers'
+
+describe('MindMapView branch coverage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentEntities = mockEntities
+ currentHistoryIndex = 0
+ currentEntityHistory = [[]]
+ vi.spyOn(crypto, 'randomUUID').mockReturnValue('new-child-id')
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ })
+
+ it('shows empty state when no entities', () => {
+ currentEntities = []
+ render()
+ expect(screen.getByText(/Add entities with links to build a mind map/)).toBeDefined()
+ expect(screen.getByText('No data')).toBeDefined()
+ })
+
+ it('selects entity and switches to editor on node click', () => {
+ render()
+ const node = screen.getByText('Root Entity')
+ fireEvent.click(node)
+ expect(mockSelectEntity).toHaveBeenCalledWith('ent-1')
+ expect(mockSetView).toHaveBeenCalledWith('editor')
+ })
+
+ it('selects entity and switches to editor on Enter key', () => {
+ render()
+ const node = screen.getByText('Root Entity')
+ fireEvent.keyDown(node, { key: 'Enter' })
+ expect(mockSelectEntity).toHaveBeenCalledWith('ent-1')
+ expect(mockSetView).toHaveBeenCalledWith('editor')
+ })
+
+ it('selects entity and switches to editor on Space key', () => {
+ render()
+ const node = screen.getByText('Root Entity')
+ fireEvent.keyDown(node, { key: ' ' })
+ expect(mockSelectEntity).toHaveBeenCalledWith('ent-1')
+ expect(mockSetView).toHaveBeenCalledWith('editor')
+ })
+
+ it('focuses a node and tracks focusedNodeId', () => {
+ render()
+ const node = screen.getByText('Root Entity')
+ fireEvent.focus(node.parentElement as HTMLElement)
+ expect(node.parentElement).toHaveAttribute('tabIndex', '0')
+ })
+
+ it('expands a collapsed node with ArrowRight', async () => {
+ render()
+ const node = screen.getByText('Root Entity')
+ fireEvent.keyDown(node, { key: 'ArrowRight' })
+ await waitFor(() => {
+ expect(screen.getByText('Child Entity')).toBeDefined()
+ })
+ })
+
+ it('collapses an expanded node with ArrowLeft', async () => {
+ render()
+ const node = screen.getByText('Root Entity')
+ fireEvent.keyDown(node, { key: 'ArrowRight' })
+ await waitFor(() => {
+ expect(screen.getByText('Child Entity')).toBeDefined()
+ })
+ fireEvent.keyDown(node, { key: 'ArrowLeft' })
+ // Child should still be rendered because root is always expanded at level 0
+ expect(screen.getByText('Child Entity')).toBeDefined()
+ })
+
+ it('focuses next node with ArrowDown', async () => {
+ render()
+ const node = screen.getByText('Root Entity')
+ const parent = node.closest('[role="treeitem"]') as HTMLElement
+ fireEvent.keyDown(parent, { key: 'ArrowDown' })
+ // ArrowDown calls focus on the next treeitem; we can only verify no throw
+ expect(parent).toBeDefined()
+ })
+
+ it('focuses previous node with ArrowUp', async () => {
+ render()
+ const node = screen.getByText('Root Entity')
+ const parent = node.closest('[role="treeitem"]') as HTMLElement
+ fireEvent.keyDown(parent, { key: 'ArrowUp' })
+ expect(parent).toBeDefined()
+ })
+
+ it('jumps to first node with Home', () => {
+ render()
+ const node = screen.getByText('Root Entity')
+ const parent = node.closest('[role="treeitem"]') as HTMLElement
+ fireEvent.keyDown(parent, { key: 'Home' })
+ expect(parent).toBeDefined()
+ })
+
+ it('jumps to last node with End', () => {
+ render()
+ const node = screen.getByText('Root Entity')
+ const parent = node.closest('[role="treeitem"]') as HTMLElement
+ fireEvent.keyDown(parent, { key: 'End' })
+ expect(parent).toBeDefined()
+ })
+
+ it('adds child when toolbar button is clicked and a node is focused', () => {
+ render()
+ fireEvent.focus(screen.getByText('Root Entity').closest('[role="treeitem"]') as HTMLElement)
+ fireEvent.click(screen.getByLabelText('Add child'))
+ expect(mockCommitEntity).toHaveBeenCalledTimes(2)
+ })
+
+ it('starts editing focused node when rename button is clicked', () => {
+ render()
+ fireEvent.focus(screen.getByText('Root Entity').closest('[role="treeitem"]') as HTMLElement)
+ fireEvent.click(screen.getByLabelText('Rename'))
+ expect(mockStartEdit).toHaveBeenCalledWith('ent-1')
+ })
+
+ it('deletes focused node when delete button is clicked', () => {
+ render()
+ fireEvent.focus(screen.getByText('Root Entity').closest('[role="treeitem"]') as HTMLElement)
+ fireEvent.click(screen.getByLabelText('Delete'))
+ expect(mockDeleteEntity).toHaveBeenCalledWith('ent-1')
+ })
+
+ it('adds child via global Ctrl+Tab keyboard shortcut', () => {
+ render()
+ const canvas = screen.getByRole('tree', { name: 'Knowledge mind map' }).parentElement as HTMLElement
+ fireEvent.focus(screen.getByText('Root Entity').closest('[role="treeitem"]') as HTMLElement)
+ fireEvent.keyDown(canvas, { key: 'Tab', ctrlKey: true })
+ expect(mockCommitEntity).toHaveBeenCalledTimes(2)
+ })
+
+ it('starts editing via global F2 keyboard shortcut', () => {
+ render()
+ const canvas = screen.getByRole('tree', { name: 'Knowledge mind map' }).parentElement as HTMLElement
+ fireEvent.focus(screen.getByText('Root Entity').closest('[role="treeitem"]') as HTMLElement)
+ fireEvent.keyDown(canvas, { key: 'F2' })
+ expect(mockStartEdit).toHaveBeenCalledWith('ent-1')
+ })
+
+ it('deletes focused node via global Delete keyboard shortcut', () => {
+ render()
+ const canvas = screen.getByRole('tree', { name: 'Knowledge mind map' }).parentElement as HTMLElement
+ fireEvent.focus(screen.getByText('Root Entity').closest('[role="treeitem"]') as HTMLElement)
+ fireEvent.keyDown(canvas, { key: 'Delete' })
+ expect(mockDeleteEntity).toHaveBeenCalledWith('ent-1')
+ })
+
+ it('deletes focused node via global Backspace keyboard shortcut', () => {
+ render()
+ const canvas = screen.getByRole('tree', { name: 'Knowledge mind map' }).parentElement as HTMLElement
+ fireEvent.focus(screen.getByText('Root Entity').closest('[role="treeitem"]') as HTMLElement)
+ fireEvent.keyDown(canvas, { key: 'Backspace' })
+ expect(mockDeleteEntity).toHaveBeenCalledWith('ent-1')
+ })
+
+ it('changes root entity via selector', () => {
+ render()
+ const select = screen.getByLabelText('Root entity')
+ fireEvent.change(select, { target: { value: 'ent-2' } })
+ expect(select).toHaveValue('ent-2')
+ })
+
+ it('changes tree depth via slider', () => {
+ render()
+ const slider = screen.getByLabelText('Tree depth')
+ fireEvent.change(slider, { target: { value: '5' } })
+ expect(slider).toHaveValue('5')
+ })
+
+ it('toggles compact mode', () => {
+ render()
+ const button = screen.getByText('Compact')
+ fireEvent.click(button)
+ expect(button).toHaveAttribute('aria-pressed', 'true')
+ })
+
+ it('syncs tree when sync button is clicked', () => {
+ render()
+ fireEvent.click(screen.getByLabelText('Sync'))
+ expect(screen.getByText('Root: Root Entity')).toBeDefined()
+ })
+
+ it('exports PNG when export button is clicked', () => {
+ const downloadBlobMock = vi.spyOn(exportHelpers, 'downloadBlob').mockImplementation(() => {})
+ render()
+ fireEvent.click(screen.getByLabelText('Export PNG'))
+ expect(downloadBlobMock).not.toHaveBeenCalled()
+ })
+
+ it('undoes last action when undo is clicked and history allows', () => {
+ currentHistoryIndex = 1
+ currentEntityHistory = [[], []]
+ render()
+ fireEvent.click(screen.getByLabelText('Undo'))
+ expect(mockUndo).toHaveBeenCalled()
+ })
+
+ it('redoes action when redo is clicked and history allows', () => {
+ currentHistoryIndex = 0
+ currentEntityHistory = [[], []]
+ render()
+ fireEvent.click(screen.getByLabelText('Redo'))
+ expect(mockRedo).toHaveBeenCalled()
+ })
+
+ it('renders expand/collapse chevron for nodes with children', () => {
+ render()
+ expect(screen.getByLabelText('Collapse')).toBeDefined()
+ })
+
+ it('toggles node expansion when chevron is clicked', () => {
+ render()
+ // The root is always expanded; the child (level 1) starts collapsed.
+ const expandButton = screen.getByLabelText('Expand')
+ fireEvent.click(expandButton)
+ // After expanding the child, both root and child show Collapse.
+ expect(screen.getAllByLabelText('Collapse').length).toBe(2)
+ fireEvent.click(screen.getAllByLabelText('Collapse')[1])
+ // The child collapses again; the Expand label reappears.
+ expect(screen.getByLabelText('Expand')).toBeDefined()
+ })
+})