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 unknown[]), 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 Record, blocksToHTMLLossy: vi.fn(() => ''), _tiptapEditor: { commands: { setContent: vi.fn() } }, focus: vi.fn(), setTextCursorPosition: vi.fn(), })) // Mock BlockNote components vi.mock('@blocknote/core', () => ({ BlockNoteSchema: { create: () => ({}) }, defaultInlineContentSpecs: {}, filterSuggestionItems: vi.fn(() => []), })) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock const mockFilterSuggestionItems = vi.fn((...args: any[]) => args[0] ?? []) vi.mock('@blocknote/core/extensions', () => ({ filterSuggestionItems: (...args: unknown[]) => mockFilterSuggestionItems(...args), })) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock const capturedGetItemsByTrigger: Record Promise> = {} // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock let capturedGetItems: ((query: string) => Promise) | null = null vi.mock('@blocknote/react', () => ({ createReactInlineContentSpec: () => ({ render: () => null }), useCreateBlockNote: () => mockEditor, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock SuggestionMenuController: (props: any) => { capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems if (props.triggerCharacter === '[[') capturedGetItems = props.getItems return null }, })) vi.mock('@blocknote/mantine', () => ({ BlockNoteView: ({ children, editable }: { children?: React.ReactNode; editable?: boolean }) =>
{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, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 1024, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [], } 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], onNavigateWikilink: vi.fn(), inspectorCollapsed: true, onToggleInspector: vi.fn(), inspectorWidth: 280, onInspectorResize: vi.fn(), inspectorEntry: null as VaultEntry | null, inspectorContent: null as string | null, 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( ) // Tab bar renders when a tab is active expect(screen.getByTestId('blocknote-view')).toBeInTheDocument() }) it('renders breadcrumb bar with action buttons', () => { render( ) // Breadcrumb bar shows action buttons (e.g. search, archive) expect(screen.getByTitle('Search in file')).toBeInTheDocument() }) it('shows BlockNote editor when a tab is active', () => { render( ) expect(screen.getByTestId('blocknote-view')).toBeInTheDocument() }) it('renders editor for modified file without breadcrumb status', () => { render( 'modified'} /> ) // Editor still renders; breadcrumb bar no longer shows status indicators expect(screen.getByTestId('blocknote-view')).toBeInTheDocument() }) it('renders editor for new file without breadcrumb status', () => { render( 'new'} /> ) // Editor still renders; breadcrumb bar no longer shows status indicators expect(screen.getByTestId('blocknote-view')).toBeInTheDocument() }) it('renders diff toggle button when file is modified', () => { render( 'modified'} 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() }) describe('trashed note behavior', () => { const trashedEntry: VaultEntry = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 } const trashedTab = { entry: trashedEntry, content: mockContent } function renderTrashed(overrides: Partial[0]> = {}) { return render() } it('shows banner and read-only editor when note is trashed', () => { renderTrashed() expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument() expect(screen.getByText('This note is in the Trash')).toBeInTheDocument() expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false') }) it('does not show banner and sets editable for normal notes', () => { render() expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument() expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true') }) it('calls onRestoreNote when banner restore is clicked', () => { const onRestoreNote = vi.fn() renderTrashed({ onRestoreNote }) fireEvent.click(screen.getByTestId('trashed-banner-restore')) expect(onRestoreNote).toHaveBeenCalledWith(trashedEntry.path) }) it('calls onDeleteNote when banner delete is clicked', () => { const onDeleteNote = vi.fn() renderTrashed({ onDeleteNote }) fireEvent.click(screen.getByTestId('trashed-banner-delete')) expect(onDeleteNote).toHaveBeenCalledWith(trashedEntry.path) }) it('shows trash banner immediately when entry changes to trashed (reactive)', () => { const { rerender } = render( ) expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument() const trashedEntryUpdated = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 } const updatedTab = { entry: trashedEntryUpdated, content: mockContent } rerender( ) expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument() expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false') }) it('removes trash banner immediately when entry is restored (reactive)', () => { const { rerender } = render( ) expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument() const restoredEntry = { ...trashedEntry, trashed: false, trashedAt: null } const restoredTab = { entry: restoredEntry, content: mockContent } rerender( ) expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument() expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true') }) }) }) describe('click empty editor space', () => { it('focuses editor at end of last block when clicking empty space below content', () => { mockEditor.focus.mockClear() mockEditor.setTextCursorPosition.mockClear() render( ) const container = document.querySelector('.editor__blocknote-container') expect(container).toBeTruthy() // Click directly on the container (simulates clicking empty space below content) fireEvent.click(container!) expect(mockEditor.setTextCursorPosition).toHaveBeenCalledWith('1', 'end') expect(mockEditor.focus).toHaveBeenCalled() }) it('does not interfere with clicks on contenteditable elements', () => { mockEditor.focus.mockClear() mockEditor.setTextCursorPosition.mockClear() render( ) // Simulate clicking on a contenteditable child (which ProseMirror would handle) const container = document.querySelector('.editor__blocknote-container')! const editableDiv = document.createElement('div') editableDiv.setAttribute('contenteditable', 'true') container.appendChild(editableDiv) fireEvent.click(editableDiv) expect(mockEditor.setTextCursorPosition).not.toHaveBeenCalled() // Clean up container.removeChild(editableDiv) }) it('does not focus editor when note is not editable (trashed)', () => { mockEditor.focus.mockClear() mockEditor.setTextCursorPosition.mockClear() const trashedEntry: VaultEntry = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 } render( ) const container = document.querySelector('.editor__blocknote-container') expect(container).toBeTruthy() fireEvent.click(container!) expect(mockEditor.setTextCursorPosition).not.toHaveBeenCalled() expect(mockEditor.focus).not.toHaveBeenCalled() }) }) describe('archived note behavior', () => { it('shows archive banner immediately when entry changes to archived (reactive)', () => { const { rerender } = render( ) expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument() const archivedEntry = { ...mockEntry, archived: true } const archivedTab = { entry: archivedEntry, content: mockContent } rerender( ) expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument() }) it('removes archive banner immediately when entry is unarchived (reactive)', () => { const archivedEntry: VaultEntry = { ...mockEntry, archived: true } const archivedTab = { entry: archivedEntry, content: mockContent } const { rerender } = render( ) expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument() const unarchivedEntry = { ...archivedEntry, archived: false } const unarchivedTab = { entry: unarchivedEntry, content: mockContent } rerender( ) expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument() }) }) describe('wikilink autocomplete', () => { const entries: VaultEntry[] = [ { ...mockEntry, title: 'Alpha Project', filename: 'alpha.md', aliases: ['al'] }, { ...mockEntry, title: 'Beta Review', filename: 'beta.md', path: '/vault/beta.md', aliases: [] }, { ...mockEntry, title: 'Gamma Notes', filename: 'gamma.md', path: '/vault/gamma.md', aliases: ['gam'] }, ] function renderWithEntries() { capturedGetItems = null mockFilterSuggestionItems.mockClear() render( ) } it('returns empty array for query shorter than 2 characters', async () => { renderWithEntries() expect(capturedGetItems).toBeTruthy() expect(await capturedGetItems!('')).toEqual([]) expect(await capturedGetItems!('a')).toEqual([]) // filterSuggestionItems should NOT be called for short queries expect(mockFilterSuggestionItems).not.toHaveBeenCalled() }) it('returns items for query of 2+ characters', async () => { renderWithEntries() const items = await capturedGetItems!('Al') expect(items.length).toBeGreaterThan(0) expect(mockFilterSuggestionItems).toHaveBeenCalled() }) it('limits results to MAX_RESULTS (20)', async () => { // Create many entries that will all match const manyEntries = Array.from({ length: 50 }, (_, i) => ({ ...mockEntry, title: `Match Item ${i}`, filename: `match-${i}.md`, path: `/vault/match-${i}.md`, aliases: [], })) capturedGetItems = null mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) render( ) const items = await capturedGetItems!('Match') expect(items.length).toBeLessThanOrEqual(20) mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) }) it('each item has onItemClick that inserts wikilink', async () => { renderWithEntries() mockEditor.insertInlineContent.mockClear() const items = await capturedGetItems!('Alpha') expect(items.length).toBeGreaterThan(0) items[0].onItemClick() expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([ { type: 'wikilink', props: { target: 'vault/project/test|Alpha Project' } }, ' ', ]) }) it('deduplicates entries with the same path', async () => { const dupEntries: VaultEntry[] = [ { ...mockEntry, title: 'Dup Note', filename: 'dup.md', path: '/vault/dup.md', aliases: [] }, { ...mockEntry, title: 'Dup Note Copy', filename: 'dup.md', path: '/vault/dup.md', aliases: [] }, { ...mockEntry, title: 'Other Note', filename: 'other.md', path: '/vault/other.md', aliases: [] }, ] capturedGetItems = null mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) render( ) const items = await capturedGetItems!('Note') const paths = items.map((i: { path: string }) => i.path) expect(new Set(paths).size).toBe(paths.length) mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) }) it('shows correct noteType and color for typed entries, neutral for untyped', async () => { const mixedEntries: VaultEntry[] = [ { ...mockEntry, title: 'Test Project', filename: 'proj.md', path: '/vault/proj.md', isA: 'Project', aliases: [] }, { ...mockEntry, title: 'Test Plain', filename: 'plain.md', path: '/vault/plain.md', isA: null, aliases: [] }, { ...mockEntry, title: 'Test Explicit', filename: 'explicit.md', path: '/vault/explicit.md', isA: 'Note', aliases: [] }, ] capturedGetItems = null mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) render( ) const items = await capturedGetItems!('Test') // Typed entries should have noteType and color const project = items.find((i: { title: string }) => i.title === 'Test Project') expect(project).toBeDefined() expect(project!.noteType).toBe('Project') expect(project!.typeColor).toBeTruthy() // Untyped entries (isA: null or 'Note') should have no noteType (grey/neutral) const plainNote = items.find((i: { title: string }) => i.title === 'Test Plain') expect(plainNote).toBeDefined() expect(plainNote!.noteType).toBeUndefined() expect(plainNote!.typeColor).toBeUndefined() const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit') expect(explicitNote).toBeDefined() expect(explicitNote!.noteType).toBeUndefined() expect(explicitNote!.typeColor).toBeUndefined() mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) }) it('disambiguates entries with the same title by appending folder name', async () => { const sameTitle: VaultEntry[] = [ { ...mockEntry, title: 'Standup', filename: 'standup.md', path: '/vault/work/standup.md', aliases: [] }, { ...mockEntry, title: 'Standup', filename: 'standup.md', path: '/vault/personal/standup.md', aliases: [] }, ] capturedGetItems = null mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) render( ) const items = await capturedGetItems!('Standup') expect(items).toHaveLength(2) const titles = items.map((i: { title: string }) => i.title) expect(new Set(titles).size).toBe(2) expect(titles).toContain('Standup (work)') expect(titles).toContain('Standup (personal)') mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) }) }) describe('person @mention autocomplete', () => { const personEntry: VaultEntry = { ...mockEntry, title: 'Matteo Cellini', filename: 'matteo-cellini.md', path: '/vault/person/matteo-cellini.md', isA: 'Person', aliases: ['Matteo'], } const nonPersonEntry: VaultEntry = { ...mockEntry, title: 'Build Laputa App', filename: 'laputa-app.md', path: '/vault/project/laputa-app.md', isA: 'Project', aliases: [], } const entries = [personEntry, nonPersonEntry] // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock let getPersonItems: ((query: string) => Promise) | null = null function renderForMention() { mockFilterSuggestionItems.mockClear() mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) render( ) getPersonItems = capturedGetItemsByTrigger['@'] ?? null } it('registers a SuggestionMenuController with @ trigger', () => { renderForMention() expect(getPersonItems).toBeTruthy() }) it('returns only Person entries for matching query', async () => { renderForMention() const items = await getPersonItems!('Mat') expect(items.length).toBe(1) expect(items[0].title).toBe('Matteo Cellini') }) it('excludes non-Person entries', async () => { renderForMention() const items = await getPersonItems!('Lap') expect(items).toHaveLength(0) }) it('works with single-character query', async () => { renderForMention() const items = await getPersonItems!('M') expect(items.length).toBeGreaterThan(0) }) it('inserts a wikilink when person item is clicked', async () => { renderForMention() mockEditor.insertInlineContent.mockClear() const items = await getPersonItems!('Matteo') expect(items.length).toBeGreaterThan(0) items[0].onItemClick() expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([ { type: 'wikilink', props: { target: 'vault/person/matteo-cellini|Matteo Cellini' } }, ' ', ]) }) it('shows Person type badge on results', async () => { renderForMention() const items = await getPersonItems!('Matteo') expect(items[0].noteType).toBe('Person') expect(items[0].typeColor).toBeTruthy() }) })