1139 lines
37 KiB
TypeScript
1139 lines
37 KiB
TypeScript
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
|
|
import type { ComponentProps, PropsWithChildren, ReactElement } from 'react'
|
|
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
|
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
|
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
|
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation(() => ({
|
|
matches: false,
|
|
media: '',
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
})),
|
|
})
|
|
|
|
vi.mock('@tauri-apps/api/core', () => ({
|
|
convertFileSrc: vi.fn((path: string) => `asset://localhost/${encodeURIComponent(path)}`),
|
|
invoke: vi.fn(),
|
|
}))
|
|
|
|
// 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(),
|
|
headless: false,
|
|
onMount: vi.fn((cb: () => void) => { cb(); return () => {} }),
|
|
prosemirrorView: {} as Record<string, unknown>,
|
|
blocksToHTMLLossy: vi.fn(() => ''),
|
|
blocksToMarkdownLossy: vi.fn(() => '# Test Project\n\nThis is a test note with some words to count.\n'),
|
|
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
|
focus: vi.fn(),
|
|
setTextCursorPosition: vi.fn(),
|
|
}))
|
|
const blockNoteCreation = vi.hoisted(() => ({
|
|
options: [] as unknown[],
|
|
}))
|
|
|
|
// Mock BlockNote components
|
|
vi.mock('@blocknote/core', () => ({
|
|
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
|
|
createCodeBlockSpec: vi.fn(() => ({})),
|
|
createExtension: (factory: unknown) => () => factory,
|
|
defaultInlineContentSpecs: {},
|
|
filterSuggestionItems: vi.fn(() => []),
|
|
}))
|
|
|
|
vi.mock('@blocknote/code-block', () => ({
|
|
codeBlockOptions: {},
|
|
}))
|
|
|
|
// 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<string, (query: string) => Promise<any[]>> = {}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
|
let capturedGetItems: ((query: string) => Promise<any[]>) | null = null
|
|
vi.mock('@blocknote/react', () => ({
|
|
createReactBlockSpec: () => () => ({}),
|
|
createReactInlineContentSpec: () => ({ render: () => null }),
|
|
useCreateBlockNote: (options: unknown) => {
|
|
blockNoteCreation.options.push(options)
|
|
return mockEditor
|
|
},
|
|
FormattingToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
|
|
LinkToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
|
|
getFormattingToolbarItems: () => [],
|
|
getDefaultReactSlashMenuItems: () => [],
|
|
ComponentsContext: {
|
|
Provider: ({ children }: PropsWithChildren) => <>{children}</>,
|
|
},
|
|
BlockNoteViewRaw: ({ children, editable }: PropsWithChildren<{ editable?: boolean }>) => (
|
|
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
|
|
<div
|
|
contentEditable={editable !== false}
|
|
data-testid="blocknote-editable"
|
|
suppressContentEditableWarning
|
|
/>
|
|
{children}
|
|
</div>
|
|
),
|
|
FormattingToolbarController: () => null,
|
|
LinkToolbarController: () => null,
|
|
EditLinkButton: () => null,
|
|
DeleteLinkButton: () => null,
|
|
SideMenuController: () => null,
|
|
// 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
|
|
},
|
|
useComponentsContext: () => ({
|
|
LinkToolbar: {
|
|
Button: ({
|
|
children,
|
|
label,
|
|
onClick,
|
|
}: PropsWithChildren<{ label?: string; onClick?: () => void }>) => (
|
|
<button onClick={onClick} type="button">
|
|
{label}
|
|
{children}
|
|
</button>
|
|
),
|
|
},
|
|
}),
|
|
useDictionary: () => ({
|
|
link_toolbar: {
|
|
open: { tooltip: 'Open in a new tab' },
|
|
},
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@blocknote/mantine', () => ({
|
|
components: {},
|
|
}))
|
|
|
|
vi.mock('@blocknote/mantine/style.css', () => ({}))
|
|
|
|
vi.mock('./tolariaEditorFormatting', () => ({
|
|
TolariaFormattingToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
|
|
TolariaFormattingToolbarController: () => null,
|
|
}))
|
|
|
|
import { Editor } from './Editor'
|
|
import {
|
|
applyPendingRawExitContent,
|
|
rememberPendingRawExitContent,
|
|
syncActiveTabIntoRawBuffer,
|
|
} from './editorRawModeSync'
|
|
import type { VaultEntry } from '../types'
|
|
import { bindVaultConfigStore, resetVaultConfigStore } from '../utils/vaultConfigStore'
|
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
|
|
type EditorComponentProps = ComponentProps<typeof Editor>
|
|
|
|
function render(ui: ReactElement) {
|
|
return rtlRender(ui, { wrapper: TooltipProvider })
|
|
}
|
|
|
|
const mockEntry: VaultEntry = {
|
|
path: '/vault/project/test.md',
|
|
filename: 'test.md',
|
|
title: 'Test Project',
|
|
isA: 'Project',
|
|
aliases: [],
|
|
belongsTo: [],
|
|
relatedTo: [],
|
|
status: 'Active',
|
|
archived: false,
|
|
modifiedAt: 1700000000,
|
|
createdAt: null,
|
|
fileSize: 1024,
|
|
snippet: '',
|
|
wordCount: 0,
|
|
relationships: {},
|
|
icon: null,
|
|
color: null,
|
|
order: null,
|
|
template: null, sort: null,
|
|
outgoingLinks: [],
|
|
sidebarLabel: null,
|
|
view: null,
|
|
visible: null,
|
|
properties: {},
|
|
organized: false,
|
|
favorite: false,
|
|
favoriteIndex: null,
|
|
listPropertiesDisplay: [],
|
|
hasH1: false,
|
|
}
|
|
|
|
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 otherEntry: VaultEntry = {
|
|
...mockEntry,
|
|
path: '/vault/other.md',
|
|
filename: 'other.md',
|
|
title: 'Other Note',
|
|
}
|
|
const otherTab = { entry: otherEntry, content: '# Other\n' }
|
|
|
|
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(),
|
|
}
|
|
|
|
function renderEditor(overrides: Partial<EditorComponentProps> = {}) {
|
|
return render(<Editor {...defaultProps} {...overrides} />)
|
|
}
|
|
|
|
describe('Editor', () => {
|
|
beforeEach(() => {
|
|
blockNoteCreation.options = []
|
|
})
|
|
|
|
it('shows empty state when no tabs are open', () => {
|
|
const quickOpenHint = formatShortcutDisplay({ display: '⌘P / ⌘O' })
|
|
const newNoteHint = formatShortcutDisplay({ display: '⌘N' })
|
|
const { container } = renderEditor()
|
|
expect(screen.getByText('Select a note to start editing')).toBeInTheDocument()
|
|
const shortcutHint = Array.from(container.querySelectorAll('span.text-xs.text-muted-foreground'))
|
|
.find((element) => element.textContent === `${quickOpenHint} to search · ${newNoteHint} to create`)
|
|
|
|
expect(shortcutHint).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders an invisible drag region in the empty state', () => {
|
|
const { container } = renderEditor()
|
|
const dragRegion = container.querySelector('[data-testid="editor-empty-state-drag-region"]')
|
|
|
|
expect(dragRegion).toHaveAttribute('data-tauri-drag-region')
|
|
expect(dragRegion).toHaveAttribute('aria-hidden', 'true')
|
|
})
|
|
|
|
it.each([
|
|
['renders tab bar with open tabs', {}],
|
|
['shows BlockNote editor when a tab is active', {}],
|
|
['renders editor for modified file without breadcrumb status', { getNoteStatus: () => 'modified' as const }],
|
|
['renders editor for new file without breadcrumb status', { getNoteStatus: () => 'new' as const }],
|
|
])('%s', (_label, overrides) => {
|
|
renderEditor({
|
|
tabs: [mockTab],
|
|
activeTabPath: mockEntry.path,
|
|
...overrides,
|
|
})
|
|
|
|
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders an in-app image preview for binary image tabs', () => {
|
|
const imageEntry: VaultEntry = {
|
|
...mockEntry,
|
|
path: '/vault/assets/photo.png',
|
|
filename: 'photo.png',
|
|
title: 'photo.png',
|
|
fileKind: 'binary',
|
|
}
|
|
|
|
renderEditor({
|
|
tabs: [{ entry: imageEntry, content: '' }],
|
|
activeTabPath: imageEntry.path,
|
|
entries: [imageEntry],
|
|
})
|
|
|
|
const preview = screen.getByTestId('file-preview')
|
|
expect(preview).toHaveAttribute('tabindex', '0')
|
|
expect(screen.getByRole('img', { name: 'photo.png' })).toHaveAttribute(
|
|
'src',
|
|
'asset://localhost/%2Fvault%2Fassets%2Fphoto.png',
|
|
)
|
|
expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('shows a graceful fallback when an image preview fails to render', () => {
|
|
const imageEntry: VaultEntry = {
|
|
...mockEntry,
|
|
path: '/vault/assets/broken.png',
|
|
filename: 'broken.png',
|
|
title: 'broken.png',
|
|
fileKind: 'binary',
|
|
}
|
|
|
|
renderEditor({
|
|
tabs: [{ entry: imageEntry, content: '' }],
|
|
activeTabPath: imageEntry.path,
|
|
entries: [imageEntry],
|
|
})
|
|
|
|
fireEvent.error(screen.getByRole('img', { name: 'broken.png' }))
|
|
|
|
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('Image preview failed')
|
|
expect(screen.getByRole('button', { name: 'Open in default app' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows an explicit unsupported-file fallback for non-image binary tabs', () => {
|
|
const binaryEntry: VaultEntry = {
|
|
...mockEntry,
|
|
path: '/vault/assets/archive.zip',
|
|
filename: 'archive.zip',
|
|
title: 'archive.zip',
|
|
fileKind: 'binary',
|
|
}
|
|
|
|
renderEditor({
|
|
tabs: [{ entry: binaryEntry, content: '' }],
|
|
activeTabPath: binaryEntry.path,
|
|
entries: [binaryEntry],
|
|
})
|
|
|
|
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('Preview unavailable')
|
|
expect(screen.getByText('ZIP file')).toBeInTheDocument()
|
|
})
|
|
|
|
it('moves focus back to the note list when Escape is pressed on the file preview', () => {
|
|
const imageEntry: VaultEntry = {
|
|
...mockEntry,
|
|
path: '/vault/assets/photo.png',
|
|
filename: 'photo.png',
|
|
title: 'photo.png',
|
|
fileKind: 'binary',
|
|
}
|
|
|
|
render(
|
|
<>
|
|
<div data-testid="note-list-container" tabIndex={0} />
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[{ entry: imageEntry, content: '' }]}
|
|
activeTabPath={imageEntry.path}
|
|
entries={[imageEntry]}
|
|
/>
|
|
</>,
|
|
)
|
|
|
|
const preview = screen.getByTestId('file-preview')
|
|
preview.focus()
|
|
fireEvent.keyDown(preview, { key: 'Escape' })
|
|
|
|
expect(screen.getByTestId('note-list-container')).toHaveFocus()
|
|
})
|
|
|
|
it('passes the runtime CSP style nonce into BlockNote and TipTap', () => {
|
|
renderEditor({
|
|
tabs: [mockTab],
|
|
activeTabPath: mockEntry.path,
|
|
})
|
|
|
|
expect(blockNoteCreation.options.at(-1)).toMatchObject({
|
|
_tiptapOptions: {
|
|
injectNonce: RUNTIME_STYLE_NONCE,
|
|
},
|
|
})
|
|
})
|
|
|
|
it('disables native text assistance on the rich editor editable surface', () => {
|
|
renderEditor({
|
|
tabs: [mockTab],
|
|
activeTabPath: mockEntry.path,
|
|
})
|
|
|
|
const editable = screen.getByTestId('blocknote-editable')
|
|
expect(editable).toHaveAttribute('spellcheck', 'false')
|
|
expect(editable).toHaveAttribute('autocorrect', 'off')
|
|
expect(editable).toHaveAttribute('autocomplete', 'off')
|
|
expect(editable).toHaveAttribute('autocapitalize', 'off')
|
|
})
|
|
|
|
it('renders breadcrumb bar with action buttons', () => {
|
|
renderEditor({
|
|
tabs: [mockTab],
|
|
activeTabPath: mockEntry.path,
|
|
})
|
|
|
|
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Delete this note' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('hides the legacy title field for untitled draft notes', () => {
|
|
const draftEntry: VaultEntry = {
|
|
...mockEntry,
|
|
path: '/vault/untitled-note-1700000000.md',
|
|
filename: 'untitled-note-1700000000.md',
|
|
title: 'Untitled Note 1700000000',
|
|
hasH1: false,
|
|
}
|
|
const draftTab = {
|
|
entry: draftEntry,
|
|
content: '---\ntype: Note\nstatus: Active\n---\n',
|
|
}
|
|
|
|
render(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[draftTab]}
|
|
activeTabPath={draftEntry.path}
|
|
entries={[draftEntry]}
|
|
getNoteStatus={() => 'unsaved'}
|
|
/>
|
|
)
|
|
|
|
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
|
|
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders diff toggle button when file is modified', () => {
|
|
render(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[mockTab]}
|
|
activeTabPath={mockEntry.path}
|
|
getNoteStatus={() => 'modified'}
|
|
onLoadDiff={async () => '+ added line'}
|
|
/>
|
|
)
|
|
const diffBtn = screen.getByRole('button', { name: 'Show the current 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()
|
|
})
|
|
|
|
it('does not apply note A raw content to note B when raw mode closes during note B load', async () => {
|
|
resetVaultConfigStore()
|
|
bindVaultConfigStore(
|
|
{
|
|
zoom: null,
|
|
view_mode: null,
|
|
editor_mode: null,
|
|
tag_colors: null,
|
|
status_colors: null,
|
|
property_display_modes: null,
|
|
inbox: null,
|
|
},
|
|
vi.fn(),
|
|
)
|
|
|
|
const rawToggleRef = { current: (() => {}) as () => void }
|
|
const onContentChange = vi.fn()
|
|
const noteA = {
|
|
entry: {
|
|
...mockEntry,
|
|
path: '/vault/project/note-a.md',
|
|
filename: 'note-a.md',
|
|
title: 'Note A',
|
|
},
|
|
content: '---\ntitle: Note A\n---\n\n# Note A\n\nAlpha body.',
|
|
}
|
|
const noteBEntry: VaultEntry = {
|
|
...mockEntry,
|
|
path: '/vault/project/note-b.md',
|
|
filename: 'note-b.md',
|
|
title: 'Note B',
|
|
}
|
|
const noteB = {
|
|
entry: noteBEntry,
|
|
content: '---\ntitle: Note B\n---\n\n# Note B\n\nBravo body.',
|
|
}
|
|
|
|
const { rerender } = render(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[noteA]}
|
|
activeTabPath={noteA.entry.path}
|
|
entries={[noteA.entry, noteBEntry]}
|
|
onContentChange={onContentChange}
|
|
rawToggleRef={rawToggleRef}
|
|
/>,
|
|
)
|
|
|
|
await vi.waitFor(() => {
|
|
expect(typeof rawToggleRef.current).toBe('function')
|
|
})
|
|
|
|
await act(async () => {
|
|
await rawToggleRef.current()
|
|
})
|
|
onContentChange.mockClear()
|
|
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
|
mockEditor.replaceBlocks.mockClear()
|
|
|
|
rerender(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[noteA]}
|
|
activeTabPath={noteB.entry.path}
|
|
entries={[noteA.entry, noteBEntry]}
|
|
onContentChange={onContentChange}
|
|
rawToggleRef={rawToggleRef}
|
|
/>,
|
|
)
|
|
|
|
await act(async () => {
|
|
await rawToggleRef.current()
|
|
})
|
|
|
|
expect(onContentChange).not.toHaveBeenCalledWith(noteB.entry.path, noteA.content)
|
|
|
|
rerender(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[noteA, noteB]}
|
|
activeTabPath={noteB.entry.path}
|
|
entries={[noteA.entry, noteB.entry]}
|
|
onContentChange={onContentChange}
|
|
rawToggleRef={rawToggleRef}
|
|
/>,
|
|
)
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(expect.stringContaining('Note B'))
|
|
})
|
|
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalledWith(expect.stringContaining('Note A'))
|
|
|
|
resetVaultConfigStore()
|
|
})
|
|
|
|
it('updates the open raw editor when tab content changes externally', async () => {
|
|
resetVaultConfigStore()
|
|
bindVaultConfigStore(
|
|
{
|
|
zoom: null,
|
|
view_mode: null,
|
|
editor_mode: null,
|
|
tag_colors: null,
|
|
status_colors: null,
|
|
property_display_modes: null,
|
|
inbox: null,
|
|
},
|
|
vi.fn(),
|
|
)
|
|
|
|
const rawToggleRef = { current: (() => {}) as () => void }
|
|
const initialContent = '---\nowner: [[Alice]]\nstatus: Active\n---\n\n# Test Project\n\nBody.\n'
|
|
const updatedContent = '---\nowner: [[Bob]]\nstatus: Active\n---\n\n# Test Project\n\nBody.\n'
|
|
const initialTab = { entry: mockEntry, content: initialContent }
|
|
const updatedTab = { entry: mockEntry, content: updatedContent }
|
|
|
|
const { rerender } = render(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[initialTab]}
|
|
activeTabPath={mockEntry.path}
|
|
entries={[mockEntry]}
|
|
rawToggleRef={rawToggleRef}
|
|
/>,
|
|
)
|
|
|
|
await vi.waitFor(() => {
|
|
expect(typeof rawToggleRef.current).toBe('function')
|
|
})
|
|
|
|
await act(async () => {
|
|
await rawToggleRef.current()
|
|
})
|
|
|
|
await vi.waitFor(() => {
|
|
expect(screen.getByTestId('raw-editor-codemirror').textContent).toContain('owner: [[Alice]]')
|
|
})
|
|
|
|
rerender(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[updatedTab]}
|
|
activeTabPath={mockEntry.path}
|
|
entries={[mockEntry]}
|
|
rawToggleRef={rawToggleRef}
|
|
/>,
|
|
)
|
|
|
|
await vi.waitFor(() => {
|
|
expect(screen.getByTestId('raw-editor-codemirror').textContent).toContain('owner: [[Bob]]')
|
|
})
|
|
|
|
resetVaultConfigStore()
|
|
})
|
|
})
|
|
|
|
describe('applyPendingRawExitContent', () => {
|
|
it('overrides only the matching tab when raw content is newer than tab state', () => {
|
|
const pending = {
|
|
path: mockEntry.path,
|
|
content: '---\ntype: Note\nstatus: Active\n---\n| Head 1 | Head 2 | Head 3 |\n| --- | --- | --- |\n| A | B | C |\n',
|
|
}
|
|
|
|
const result = applyPendingRawExitContent([mockTab, otherTab], pending)
|
|
|
|
expect(result[0]).toEqual({ ...mockTab, content: pending.content })
|
|
expect(result[1]).toBe(otherTab)
|
|
})
|
|
|
|
it('returns the original tabs array when the pending raw content is already synced', () => {
|
|
const tabs = [mockTab, otherTab]
|
|
const pending = { path: mockEntry.path, content: mockContent }
|
|
|
|
expect(applyPendingRawExitContent(tabs, pending)).toBe(tabs)
|
|
})
|
|
})
|
|
|
|
describe('raw-mode sync content guards', () => {
|
|
it('does not emit a content change when entering raw mode normalizes markdown', () => {
|
|
const onContentChange = vi.fn()
|
|
const rawLatestContentRef = { current: null as string | null }
|
|
|
|
const result = syncActiveTabIntoRawBuffer({
|
|
editor: mockEditor as never,
|
|
activeTabPath: mockEntry.path,
|
|
activeTabContent: mockContent,
|
|
rawLatestContentRef,
|
|
})
|
|
|
|
expect(result).toBe('---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n')
|
|
expect(rawLatestContentRef.current).toBe(result)
|
|
expect(onContentChange).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('captures the latest serialized markdown when entering raw mode', () => {
|
|
const rawLatestContentRef = { current: null as string | null }
|
|
|
|
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce('# Test Project\n\nUpdated body\n')
|
|
|
|
const result = syncActiveTabIntoRawBuffer({
|
|
editor: mockEditor as never,
|
|
activeTabPath: mockEntry.path,
|
|
activeTabContent: mockContent,
|
|
rawLatestContentRef,
|
|
})
|
|
|
|
expect(result).toBe('---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nUpdated body\n')
|
|
expect(rawLatestContentRef.current).toBe(result)
|
|
})
|
|
|
|
it('keeps raw-mode serialization portable for vault attachment images', () => {
|
|
const rawLatestContentRef = { current: null as string | null }
|
|
|
|
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce(
|
|
'# Test Project\n\n\n',
|
|
)
|
|
|
|
const result = syncActiveTabIntoRawBuffer({
|
|
editor: mockEditor as never,
|
|
activeTabPath: mockEntry.path,
|
|
activeTabContent: mockContent,
|
|
rawLatestContentRef,
|
|
vaultPath: '/vault',
|
|
})
|
|
|
|
expect(result).toBe(
|
|
'---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\n\n',
|
|
)
|
|
expect(rawLatestContentRef.current).toBe(result)
|
|
})
|
|
|
|
it('serializes rich math nodes back to Markdown source when entering raw mode', () => {
|
|
const rawLatestContentRef = { current: null as string | null }
|
|
const originalDocument = mockEditor.document
|
|
const originalSerializer = mockEditor.blocksToMarkdownLossy.getMockImplementation()
|
|
|
|
try {
|
|
mockEditor.document = [
|
|
{
|
|
id: 'math-inline',
|
|
type: 'paragraph',
|
|
content: [
|
|
{ type: 'text', text: 'Inline ', styles: {} },
|
|
{ type: 'mathInline', props: { latex: 'E=mc^2' } },
|
|
],
|
|
props: {},
|
|
children: [],
|
|
},
|
|
{
|
|
id: 'math-block',
|
|
type: 'mathBlock',
|
|
props: { latex: '\\int_0^1 x\\,dx' },
|
|
children: [],
|
|
},
|
|
]
|
|
mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => (
|
|
(blocks as Array<{ content?: Array<{ text?: string }> }>)
|
|
.map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '')
|
|
.join('\n\n')
|
|
))
|
|
|
|
const result = syncActiveTabIntoRawBuffer({
|
|
editor: mockEditor as never,
|
|
activeTabPath: mockEntry.path,
|
|
activeTabContent: mockContent,
|
|
rawLatestContentRef,
|
|
})
|
|
|
|
expect(result).toBe(
|
|
'---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\nInline $E=mc^2$\n\n$$\n\\int_0^1 x\\,dx\n$$\n',
|
|
)
|
|
expect(rawLatestContentRef.current).toBe(result)
|
|
} finally {
|
|
mockEditor.document = originalDocument
|
|
mockEditor.blocksToMarkdownLossy.mockImplementation(originalSerializer)
|
|
}
|
|
})
|
|
|
|
it('does not emit a content change when leaving raw mode without user edits', () => {
|
|
const onContentChange = vi.fn()
|
|
const normalizedContent = '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n'
|
|
|
|
const result = rememberPendingRawExitContent({
|
|
activeTabPath: mockEntry.path,
|
|
activeTabContent: mockContent,
|
|
rawInitialContent: normalizedContent,
|
|
rawLatestContentRef: { current: normalizedContent },
|
|
onContentChange,
|
|
})
|
|
|
|
expect(result).toBeNull()
|
|
expect(onContentChange).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('emits a content change when leaving raw mode with edited markdown', () => {
|
|
const onContentChange = vi.fn()
|
|
const normalizedContent = '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n'
|
|
const editedContent = `${normalizedContent}\nUpdated in raw mode\n`
|
|
|
|
const result = rememberPendingRawExitContent({
|
|
activeTabPath: mockEntry.path,
|
|
activeTabContent: mockContent,
|
|
rawInitialContent: normalizedContent,
|
|
rawLatestContentRef: { current: editedContent },
|
|
onContentChange,
|
|
})
|
|
|
|
expect(result).toEqual({ path: mockEntry.path, content: editedContent })
|
|
expect(onContentChange).toHaveBeenCalledWith(mockEntry.path, editedContent)
|
|
})
|
|
})
|
|
|
|
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(
|
|
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />
|
|
)
|
|
|
|
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(
|
|
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />
|
|
)
|
|
|
|
// 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('restores the cursor to the H1 when clicking the title block', async () => {
|
|
mockEditor.focus.mockClear()
|
|
mockEditor.setTextCursorPosition.mockClear()
|
|
mockEditor.document = [
|
|
{ id: 'title', type: 'heading', content: [{ type: 'text', text: 'Alpha Project', styles: {} }], props: { level: 1 }, children: [] },
|
|
{ id: 'body', type: 'paragraph', content: [], props: {}, children: [] },
|
|
]
|
|
|
|
render(
|
|
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />
|
|
)
|
|
|
|
const container = document.querySelector('.editor__blocknote-container')!
|
|
const editableDiv = document.createElement('div')
|
|
editableDiv.setAttribute('contenteditable', 'true')
|
|
const heading = document.createElement('h1')
|
|
heading.textContent = 'Alpha Project'
|
|
heading.setAttribute('data-content-type', 'heading')
|
|
heading.setAttribute('data-level', '1')
|
|
editableDiv.appendChild(heading)
|
|
container.appendChild(editableDiv)
|
|
|
|
fireEvent.click(heading)
|
|
await act(() => Promise.resolve())
|
|
|
|
expect(mockEditor.setTextCursorPosition).toHaveBeenCalledWith('title', 'end')
|
|
expect(mockEditor.focus).toHaveBeenCalled()
|
|
|
|
container.removeChild(editableDiv)
|
|
})
|
|
|
|
})
|
|
|
|
describe('archived note behavior', () => {
|
|
it('shows archive banner immediately when entry changes to archived (reactive)', () => {
|
|
const { rerender } = render(
|
|
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
|
|
)
|
|
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
|
|
|
|
const archivedEntry = { ...mockEntry, archived: true }
|
|
const archivedTab = { entry: archivedEntry, content: mockContent }
|
|
rerender(
|
|
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
|
|
)
|
|
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(
|
|
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
|
)
|
|
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
|
|
|
|
const unarchivedEntry = { ...archivedEntry, archived: false }
|
|
const unarchivedTab = { entry: unarchivedEntry, content: mockContent }
|
|
rerender(
|
|
<Editor {...defaultProps} entries={[unarchivedEntry]} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
|
)
|
|
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(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[mockTab]}
|
|
activeTabPath={mockEntry.path}
|
|
entries={entries}
|
|
/>
|
|
)
|
|
}
|
|
|
|
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('normalizes BlockNote trigger-prefixed wikilink queries before filtering', async () => {
|
|
renderWithEntries()
|
|
const items = await capturedGetItems!('[[Al')
|
|
expect(items.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
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(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[mockTab]}
|
|
activeTabPath={mockEntry.path}
|
|
entries={manyEntries}
|
|
/>
|
|
)
|
|
|
|
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' } },
|
|
' ',
|
|
], { updateSelection: true })
|
|
})
|
|
|
|
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(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[mockTab]}
|
|
activeTabPath={mockEntry.path}
|
|
entries={dupEntries}
|
|
/>
|
|
)
|
|
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 Note chips and icons for explicit Note entries while keeping untyped entries neutral', 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(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[mockTab]}
|
|
activeTabPath={mockEntry.path}
|
|
entries={mixedEntries}
|
|
/>
|
|
)
|
|
const items = await capturedGetItems!('Test')
|
|
// Typed entries should have noteType, color, and a left-side icon
|
|
const project = items.find((i: { title: string }) => i.title === 'Test Project')
|
|
expect(project).toBeDefined()
|
|
expect(project!.noteType).toBe('Project')
|
|
expect(project!.typeColor).toBeTruthy()
|
|
expect(project!.TypeIcon).toBeTruthy()
|
|
|
|
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
|
|
expect(explicitNote).toBeDefined()
|
|
expect(explicitNote!.noteType).toBe('Note')
|
|
expect(explicitNote!.typeColor).toBeTruthy()
|
|
expect(explicitNote!.TypeIcon).toBeTruthy()
|
|
|
|
// Untyped entries should remain neutral
|
|
const plainNote = items.find((i: { title: string }) => i.title === 'Test Plain')
|
|
expect(plainNote).toBeDefined()
|
|
expect(plainNote!.noteType).toBeUndefined()
|
|
expect(plainNote!.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(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[mockTab]}
|
|
activeTabPath={mockEntry.path}
|
|
entries={sameTitle}
|
|
/>
|
|
)
|
|
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<any[]>) | null = null
|
|
|
|
function renderForMention() {
|
|
mockFilterSuggestionItems.mockClear()
|
|
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
|
|
render(
|
|
<Editor
|
|
{...defaultProps}
|
|
tabs={[mockTab]}
|
|
activeTabPath={mockEntry.path}
|
|
entries={entries}
|
|
/>
|
|
)
|
|
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' } },
|
|
' ',
|
|
], { updateSelection: true })
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|