import { render, screen, fireEvent } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' // Hoisted mock editor — available before vi.mock factory runs. // Tests can reconfigure spies (e.g. mockTryParse.mockResolvedValue) before rendering. const mockEditor = vi.hoisted(() => ({ tryParseMarkdownToBlocks: vi.fn(async () => [] as any[]), replaceBlocks: vi.fn(), insertBlocks: vi.fn(), document: [{ id: '1', type: 'paragraph', content: [], props: {}, children: [] }], insertInlineContent: vi.fn(), onMount: vi.fn((cb: () => void) => { cb(); return () => {} }), prosemirrorView: {} as any, blocksToHTMLLossy: vi.fn(() => ''), _tiptapEditor: { commands: { setContent: vi.fn() } }, })) // Mock BlockNote components vi.mock('@blocknote/core', () => ({ BlockNoteSchema: { create: () => ({}) }, defaultInlineContentSpecs: {}, filterSuggestionItems: vi.fn(() => []), })) vi.mock('@blocknote/core/extensions', () => ({ filterSuggestionItems: vi.fn(() => []), })) vi.mock('@blocknote/react', () => ({ createReactInlineContentSpec: () => ({ render: () => null }), useCreateBlockNote: () => mockEditor, SuggestionMenuController: () => null, })) vi.mock('@blocknote/mantine', () => ({ BlockNoteView: ({ children }: any) =>
{children}
, })) vi.mock('@blocknote/mantine/style.css', () => ({})) import { Editor } from './Editor' import type { VaultEntry } from '../types' const mockEntry: VaultEntry = { path: '/vault/project/test.md', filename: 'test.md', title: 'Test Project', isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: 'Active', owner: 'Luca', cadence: null, archived: false, modifiedAt: 1700000000, createdAt: null, fileSize: 1024, snippet: '', relationships: {}, icon: null, color: null, order: null, } const mockContent = `--- title: Test Project is_a: Project Status: Active --- # Test Project This is a test note with some words to count. ` const mockTab = { entry: mockEntry, content: mockContent } const defaultProps = { tabs: [] as { entry: VaultEntry; content: string }[], activeTabPath: null as string | null, entries: [mockEntry], onSwitchTab: vi.fn(), onCloseTab: vi.fn(), onNavigateWikilink: vi.fn(), inspectorCollapsed: true, onToggleInspector: vi.fn(), inspectorWidth: 280, onInspectorResize: vi.fn(), inspectorEntry: null as VaultEntry | null, inspectorContent: null as string | null, allContent: {} as Record, gitHistory: [], onCreateNote: vi.fn(), } describe('Editor', () => { it('shows empty state when no tabs are open', () => { render() expect(screen.getByText('Select a note to start editing')).toBeInTheDocument() expect(screen.getByText(/Cmd\+P to search/)).toBeInTheDocument() }) it('renders tab bar with open tabs', () => { render( ) expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0) }) it('renders breadcrumb bar with note info', () => { render( ) // Breadcrumb shows type and title expect(screen.getByText('Project')).toBeInTheDocument() // Word count shown expect(screen.getByText(/words/)).toBeInTheDocument() }) it('calls onCloseTab when close button is clicked', () => { const onCloseTab = vi.fn() render( ) // Find the close button (X icon) in the tab const closeButtons = document.querySelectorAll('button') const tabCloseBtn = Array.from(closeButtons).find(btn => { const svg = btn.querySelector('svg') return svg && btn.closest('[class*="group"]') }) if (tabCloseBtn) { fireEvent.click(tabCloseBtn) expect(onCloseTab).toHaveBeenCalledWith(mockEntry.path) } }) it('calls onSwitchTab when clicking a tab', () => { const secondEntry: VaultEntry = { ...mockEntry, path: '/vault/topic/dev.md', title: 'Dev Topic', isA: 'Topic', } const onSwitchTab = vi.fn() render( ) fireEvent.click(screen.getByText('Dev Topic')) expect(onSwitchTab).toHaveBeenCalledWith(secondEntry.path) }) it('renders new note button in tab bar', () => { const onCreateNote = vi.fn() render( ) const newNoteBtn = screen.getByTitle('New note') expect(newNoteBtn).toBeInTheDocument() fireEvent.click(newNoteBtn) expect(onCreateNote).toHaveBeenCalled() }) it('shows BlockNote editor when a tab is active', () => { render( ) expect(screen.getByTestId('blocknote-view')).toBeInTheDocument() }) it('shows modified indicator when file is modified', () => { render( true} /> ) // Modified indicator shows "M" in the breadcrumb expect(screen.getByText('M')).toBeInTheDocument() }) it('renders diff toggle button when file is modified', () => { render( true} onLoadDiff={async () => '+ added line'} /> ) const diffBtn = screen.getByTitle('Show diff') expect(diffBtn).toBeInTheDocument() }) it('includes inspector panel', () => { render( ) // Inspector renders "Properties" header expect(screen.getAllByText('Properties').length).toBeGreaterThan(0) }) // Regression: editor content did not appear on first load because BlockNote's // replaceBlocks/insertBlocks internally calls flushSync, which fails silently // when invoked from within React's useEffect. Fix: defer via queueMicrotask. it('applies parsed content blocks via deferred microtask (regression: flushSync-in-lifecycle)', async () => { const testBlocks = [ { id: 'b1', type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }], props: {}, children: [] }, ] mockEditor.tryParseMarkdownToBlocks.mockResolvedValue(testBlocks) mockEditor.replaceBlocks.mockClear() mockEditor.insertBlocks.mockClear() render( ) // Content swap is deferred via queueMicrotask — should NOT be called synchronously expect(mockEditor.replaceBlocks).not.toHaveBeenCalled() // After microtask + async parse resolve, blocks should be applied await vi.waitFor(() => { expect(mockEditor.replaceBlocks).toHaveBeenCalled() }) // Clean up mock for other tests mockEditor.tryParseMarkdownToBlocks.mockResolvedValue([]) mockEditor.replaceBlocks.mockClear() mockEditor.insertBlocks.mockClear() }) })