Files
tolaria/src/components/Editor.test.tsx
lucaronin 1dfbbb8a9e feat: add order property to VaultEntry and install @dnd-kit
- Add `order: number | null` to VaultEntry (TS) and VaultEntry (Rust)
- Add order to Frontmatter struct and SKIP_KEYS in vault.rs
- Update all mock Type documents with order values (0-10)
- Add order: null to all non-Type mock entries and test fixtures
- Install @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:27:19 +01:00

269 lines
7.5 KiB
TypeScript

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) => <div data-testid="blocknote-view">{children}</div>,
}))
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<string, string>,
gitHistory: [],
onCreateNote: vi.fn(),
}
describe('Editor', () => {
it('shows empty state when no tabs are open', () => {
render(<Editor {...defaultProps} />)
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(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
/>
)
expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0)
})
it('renders breadcrumb bar with note info', () => {
render(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
/>
)
// 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(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
onCloseTab={onCloseTab}
/>
)
// 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(
<Editor
{...defaultProps}
tabs={[mockTab, { entry: secondEntry, content: '# Dev' }]}
activeTabPath={mockEntry.path}
onSwitchTab={onSwitchTab}
/>
)
fireEvent.click(screen.getByText('Dev Topic'))
expect(onSwitchTab).toHaveBeenCalledWith(secondEntry.path)
})
it('renders new note button in tab bar', () => {
const onCreateNote = vi.fn()
render(
<Editor
{...defaultProps}
onCreateNote={onCreateNote}
/>
)
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(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
/>
)
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
})
it('shows modified indicator when file is modified', () => {
render(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
isModified={() => true}
/>
)
// Modified indicator shows "M" in the breadcrumb
expect(screen.getByText('M')).toBeInTheDocument()
})
it('renders diff toggle button when file is modified', () => {
render(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
isModified={() => true}
onLoadDiff={async () => '+ added line'}
/>
)
const diffBtn = screen.getByTitle('Show diff')
expect(diffBtn).toBeInTheDocument()
})
it('includes inspector panel', () => {
render(
<Editor
{...defaultProps}
inspectorCollapsed={false}
inspectorEntry={mockEntry}
inspectorContent={mockContent}
/>
)
// 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(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
/>
)
// 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()
})
})