test: increase strategic coverage

This commit is contained in:
lucaronin
2026-04-21 16:54:52 +02:00
parent 8ae1ade220
commit b785c53ef9
36 changed files with 7194 additions and 93 deletions

View File

@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ConflictResolverModal } from './ConflictResolverModal'
import type { ConflictFileState } from '../hooks/useConflictResolver'
function renderModal(fileStates: ConflictFileState[], overrides: Record<string, unknown> = {}) {
const props = {
open: true,
fileStates,
allResolved: true,
committing: false,
error: null,
onResolveFile: vi.fn(),
onOpenInEditor: vi.fn(),
onCommit: vi.fn(),
onClose: vi.fn(),
...overrides,
}
render(<ConflictResolverModal {...props} />)
return props
}
describe('ConflictResolverModal extra coverage', () => {
it('supports keyboard navigation and actions across the focused file', () => {
const props = renderModal([
{ file: 'notes/project.md', resolution: null, resolving: false },
{ file: 'notes/plan.md', resolution: null, resolving: false },
])
const list = screen.getByTestId('conflict-file-list')
fireEvent.keyDown(list, { key: 'ArrowDown' })
fireEvent.keyDown(list, { key: 'T' })
expect(props.onResolveFile).toHaveBeenCalledWith('notes/plan.md', 'theirs')
fireEvent.keyDown(list, { key: 'ArrowUp' })
fireEvent.keyDown(list, { key: 'k' })
expect(props.onResolveFile).toHaveBeenCalledWith('notes/project.md', 'ours')
fireEvent.keyDown(list, { key: 'o' })
expect(props.onOpenInEditor).toHaveBeenCalledWith('notes/project.md')
fireEvent.keyDown(list, { key: 'Enter' })
expect(props.onCommit).toHaveBeenCalledTimes(1)
fireEvent.keyDown(list, { key: 'Escape' })
expect(props.onClose).toHaveBeenCalled()
})
it('ignores binary open shortcuts, resolving rows, and modifier-assisted actions', () => {
const props = renderModal([
{ file: 'images/photo.png', resolution: null, resolving: false },
{ file: 'notes/plan.md', resolution: null, resolving: true },
], { allResolved: false })
const list = screen.getByTestId('conflict-file-list')
fireEvent.keyDown(list, { key: 'o' })
expect(props.onOpenInEditor).not.toHaveBeenCalled()
fireEvent.focus(screen.getByTestId('conflict-file-notes/plan.md'))
fireEvent.keyDown(list, { key: 'K' })
fireEvent.keyDown(list, { key: 'T', ctrlKey: true })
expect(props.onResolveFile).not.toHaveBeenCalled()
expect(props.onCommit).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,119 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { ConflictFileState } from '../hooks/useConflictResolver'
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean
onOpenChange: (open: boolean) => void
children: React.ReactNode
}) => (
open ? (
<div data-testid="dialog-root">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>close</button>
</div>
) : null
),
DialogContent: ({
children,
onKeyDown,
}: {
children: React.ReactNode
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void
}) => (
<div role="dialog" tabIndex={0} onKeyDown={onKeyDown}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
}))
import { ConflictResolverModal } from './ConflictResolverModal'
function makeFileStates(overrides?: Partial<ConflictFileState>[]): ConflictFileState[] {
const defaults: ConflictFileState[] = [
{ file: 'notes/project.md', resolution: null, resolving: false },
{ file: 'notes/plan.md', resolution: null, resolving: false },
]
if (!overrides) return defaults
return defaults.map((state, index) => ({ ...state, ...(overrides[index] ?? {}) }))
}
function renderModal(overrides: Partial<React.ComponentProps<typeof ConflictResolverModal>> = {}) {
const props: React.ComponentProps<typeof ConflictResolverModal> = {
open: true,
fileStates: makeFileStates(),
allResolved: false,
committing: false,
error: null,
onResolveFile: vi.fn(),
onOpenInEditor: vi.fn(),
onCommit: vi.fn(),
onClose: vi.fn(),
...overrides,
}
render(<ConflictResolverModal {...props} />)
return props
}
describe('ConflictResolverModal keyboard behavior', () => {
it('navigates rows with the keyboard and routes shortcuts to the focused file', () => {
const props = renderModal()
const dialog = screen.getByRole('dialog')
fireEvent.keyDown(dialog, { key: 'ArrowDown' })
fireEvent.keyDown(dialog, { key: 'k' })
expect(props.onResolveFile).toHaveBeenCalledWith('notes/plan.md', 'ours')
fireEvent.keyDown(dialog, { key: 'ArrowUp' })
fireEvent.keyDown(dialog, { key: 'T' })
expect(props.onResolveFile).toHaveBeenCalledWith('notes/project.md', 'theirs')
fireEvent.keyDown(dialog, { key: 'o' })
expect(props.onOpenInEditor).toHaveBeenCalledWith('notes/project.md')
})
it('commits on Enter when all files are resolved and closes through Escape and open-change callbacks', () => {
const props = renderModal({ allResolved: true })
const dialog = screen.getByRole('dialog')
fireEvent.keyDown(dialog, { key: 'Enter' })
expect(props.onCommit).toHaveBeenCalledTimes(1)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(props.onClose).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByTestId('dialog-close'))
expect(props.onClose).toHaveBeenCalledTimes(2)
})
it('ignores shortcut variants that should be blocked', () => {
const props = renderModal({
fileStates: [{ file: 'images/photo.png', resolution: null, resolving: true }],
})
const dialog = screen.getByRole('dialog')
fireEvent.keyDown(dialog, { key: 'k' })
fireEvent.keyDown(dialog, { key: 'o' })
fireEvent.keyDown(dialog, { key: 't', metaKey: true })
expect(props.onResolveFile).not.toHaveBeenCalled()
expect(props.onOpenInEditor).not.toHaveBeenCalled()
})
})

View File

@@ -1,6 +1,6 @@
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render as rtlRender, screen, fireEvent } from '@testing-library/react'
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { TooltipProvider } from './ui/tooltip'
import type { ReferencedByItem } from './InspectorPanels'
@@ -343,6 +343,15 @@ describe('DynamicRelationshipsPanel', () => {
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
})
it('clicks a search result to add the relationship and close the inline editor', () => {
renderEditableRelationships()
openInlineAdd('AI')
fireEvent.click(screen.getByText('AI'))
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
})
it('does not add duplicate refs', () => {
renderEditableRelationships({ frontmatter: { 'Belongs to': ['[[topic/ai]]'] } })
const input = openInlineAdd('AI')
@@ -350,6 +359,15 @@ describe('DynamicRelationshipsPanel', () => {
expect(onUpdateProperty).not.toHaveBeenCalled()
})
it('ignores empty inline-add Enter presses', () => {
renderEditableRelationships()
const input = openInlineAdd()
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateProperty).not.toHaveBeenCalled()
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
})
it('closes inline add on Escape', () => {
renderEditableRelationships()
const input = openInlineAdd()
@@ -455,6 +473,40 @@ describe('DynamicRelationshipsPanel', () => {
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
})
it('submits from the relationship-name input on Enter and cancels the form from the note input on Escape', () => {
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
fireEvent.click(screen.getByText('+ Add relationship'))
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
fireEvent.keyDown(screen.getByPlaceholderText('Relationship name'), { key: 'Enter' })
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[topic/ai]]')
fireEvent.click(screen.getByText('+ Add relationship'))
fireEvent.keyDown(screen.getByPlaceholderText('Note title'), { key: 'Escape' })
expect(screen.queryByPlaceholderText('Relationship name')).not.toBeInTheDocument()
})
it('hides the target dropdown after blur once the grace timeout expires', () => {
vi.useFakeTimers()
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
fireEvent.click(screen.getByText('+ Add relationship'))
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
const noteInput = screen.getByPlaceholderText('Note title')
fireEvent.focus(noteInput)
fireEvent.change(noteInput, { target: { value: 'New Person' } })
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
act(() => {
fireEvent.blur(noteInput)
vi.advanceTimersByTime(150)
})
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
vi.useRealTimers()
})
it('creates note and adds relationship via form', async () => {
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
fireEvent.click(screen.getByText('+ Add relationship'))

View File

@@ -0,0 +1,325 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import { DisplayModeSelector, SmartPropertyValueCell } from './PropertyValueCells'
const { createValueButtonMock } = vi.hoisted(() => ({
createValueButtonMock:
(testId: string, nextValue: (value: string) => string) =>
({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid={testId} onClick={() => onSave(nextValue(value))}>
{value}
</button>
),
}))
vi.mock('./EditableValue', () => ({
EditableValue: createValueButtonMock('editable-value', (value) => `${value}-saved`),
TagPillList: ({
items,
label,
onSave,
}: {
items: string[]
label: string
onSave: (items: string[]) => void
}) => (
<button data-testid="tag-pill-list" onClick={() => onSave([...items, 'omega'])}>
{label}:{items.join(',')}
</button>
),
UrlValue: createValueButtonMock('url-value', (value) => `${value}/updated`),
}))
vi.mock('./StatusDropdown', () => ({
StatusDropdown: ({
onSave,
onCancel,
}: {
onSave: (value: string) => void
onCancel: () => void
}) => (
<div>
<button data-testid="status-save" onClick={() => onSave('Done')}>save</button>
<button data-testid="status-cancel" onClick={onCancel}>cancel</button>
</div>
),
}))
vi.mock('./TagsDropdown', () => ({
TagsDropdown: ({
onToggle,
onClose,
}: {
onToggle: (tag: string) => void
onClose: () => void
}) => (
<div data-testid="tags-dropdown">
<button data-testid="tags-toggle-alpha" onClick={() => onToggle('alpha')}>alpha</button>
<button data-testid="tags-toggle-beta" onClick={() => onToggle('beta')}>beta</button>
<button data-testid="tags-close" onClick={onClose}>close</button>
</div>
),
}))
vi.mock('./ColorInput', () => ({
ColorEditableValue: createValueButtonMock('color-value', (value) => value.toUpperCase()),
}))
vi.mock('./IconEditableValue', () => ({
IconEditableValue: createValueButtonMock('icon-value', (value) => `${value}-icon`),
}))
vi.mock('@/components/ui/calendar', () => ({
Calendar: ({ onSelect }: { onSelect: (value?: Date) => void }) => (
<div>
<button data-testid="date-picker-calendar" onClick={() => onSelect(new Date(2026, 3, 23))}>
pick
</button>
<button data-testid="date-picker-empty" onClick={() => onSelect(undefined)}>
empty
</button>
</div>
),
}))
vi.mock('@/components/ui/popover', () => ({
Popover: ({
children,
onOpenChange,
}: {
children: ReactNode
onOpenChange?: (open: boolean) => void
}) => (
<div>
{children}
<button data-testid="popover-close" onClick={() => onOpenChange?.(false)}>close</button>
</div>
),
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
}))
function makeRect(right: number, bottom: number): DOMRect {
return {
x: 0,
y: 0,
width: 24,
height: 24,
top: 0,
left: 0,
right,
bottom,
toJSON: () => ({}),
} as DOMRect
}
describe('PropertyValueCells extra', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('normalizes relationship property keys and positions the display-mode menu within the viewport', () => {
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockReturnValue(makeRect(100, 20))
const onSelect = vi.fn()
render(
<DisplayModeSelector
propKey=" Belongs-To "
currentMode="text"
autoMode="text"
onSelect={onSelect}
/>,
)
fireEvent.click(screen.getByTestId('display-mode-trigger'))
expect(screen.getByTestId('display-mode-icon-relationship')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-menu').style.left).toBe('8px')
expect(screen.getByTestId('display-mode-menu').style.top).toBe('24px')
const backdrop = Array.from(document.body.querySelectorAll('div')).find(
(node) => node.className === 'fixed inset-0 z-[12000]',
)
fireEvent.click(backdrop as HTMLDivElement)
expect(screen.queryByTestId('display-mode-menu')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('display-mode-trigger'))
fireEvent.click(screen.getByTestId('display-mode-option-date'))
expect(onSelect).toHaveBeenCalledWith(' Belongs-To ', 'date')
rectSpy.mockRestore()
})
it('closes empty date pickers without saving and ignores undefined selections', () => {
const onSave = vi.fn()
const onStartEdit = vi.fn()
render(
<SmartPropertyValueCell
propKey="Due"
value=""
displayMode="date"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('date-picker-empty'))
fireEvent.click(screen.getByTestId('popover-close'))
expect(onSave).not.toHaveBeenCalled()
expect(onStartEdit).toHaveBeenCalledWith(null)
})
it('renders number displays, restores invalid input on escape, and falls back to editable text', () => {
const onSave = vi.fn()
const onStartEdit = vi.fn()
const { rerender } = render(
<SmartPropertyValueCell
propKey="Count"
value="12"
displayMode="number"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('number-display'))
expect(onStartEdit).toHaveBeenCalledWith('Count')
rerender(
<SmartPropertyValueCell
propKey="Count"
value="12"
displayMode="number"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={vi.fn()}
/>,
)
fireEvent.change(screen.getByTestId('number-input'), { target: { value: 'oops' } })
fireEvent.keyDown(screen.getByTestId('number-input'), { key: 'Escape' })
expect(onStartEdit).toHaveBeenCalledWith(null)
rerender(
<SmartPropertyValueCell
propKey="Title"
value="Plain text"
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('editable-value'))
expect(onSave).toHaveBeenCalledWith('Title', 'Plain text-saved')
})
it('handles string booleans, tag toggles, and tag removals', () => {
const onSaveList = vi.fn()
const onStartEdit = vi.fn()
const onUpdate = vi.fn()
const { rerender } = render(
<SmartPropertyValueCell
propKey="Published"
value="false"
displayMode="boolean"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={vi.fn()}
onSaveList={onSaveList}
onUpdate={onUpdate}
/>,
)
const checkbox = screen.getByRole('checkbox') as HTMLInputElement
expect(checkbox.checked).toBe(false)
fireEvent.click(checkbox)
expect(onUpdate).toHaveBeenCalledWith('Published', true)
rerender(
<SmartPropertyValueCell
propKey="Archived"
value={0}
displayMode="boolean"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={vi.fn()}
onSaveList={onSaveList}
onUpdate={onUpdate}
/>,
)
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
rerender(
<SmartPropertyValueCell
propKey="Tags"
value={['alpha']}
displayMode="tags"
isEditing={true}
vaultStatuses={[]}
vaultTags={['alpha', 'beta']}
onStartEdit={onStartEdit}
onSave={vi.fn()}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('tags-toggle-alpha'))
fireEvent.click(screen.getByTitle('Remove alpha'))
fireEvent.click(screen.getByTestId('tags-add-button'))
fireEvent.click(screen.getByTestId('tags-toggle-beta'))
expect(onSaveList).toHaveBeenCalledWith('Tags', [])
expect(onStartEdit).toHaveBeenCalledWith('Tags')
expect(onSaveList).toHaveBeenCalledWith('Tags', ['alpha', 'beta'])
rerender(
<SmartPropertyValueCell
propKey="Category"
value="solo"
displayMode="tags"
isEditing={true}
vaultStatuses={[]}
vaultTags={['solo', 'beta']}
onStartEdit={onStartEdit}
onSave={vi.fn()}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('tags-toggle-beta'))
expect(onSaveList).toHaveBeenCalledWith('Category', ['solo', 'beta'])
})
})

View File

@@ -0,0 +1,390 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
const { createDropdownModule, createPrimarySecondaryActions } = vi.hoisted(() => {
const renderMockActionMenu = (
containerTestId: string,
actions: Array<{ testId: string; label: string; onClick: () => void }>,
) => (
<div data-testid={containerTestId}>
{actions.map(({ testId, label, onClick }) => (
<button key={testId} data-testid={testId} onClick={onClick}>
{label}
</button>
))}
</div>
)
const createDropdownModule = (
exportName: string,
containerTestId: string,
createActions: (callbacks: Record<string, (...args: never[]) => void>) => Array<{
testId: string
label: string
onClick: () => void
}>,
) => ({
[exportName]: (callbacks: Record<string, (...args: never[]) => void>) =>
renderMockActionMenu(containerTestId, createActions(callbacks)),
})
const createPrimarySecondaryActions = (options: {
primary: { testId: string; label: string; onClick: () => void }
secondary: { testId: string; label: string; onClick: () => void }
}) => [options.primary, options.secondary]
return { createDropdownModule, createPrimarySecondaryActions }
})
vi.mock('./EditableValue', () => ({
EditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="editable-value" onClick={() => onSave(`${value}-saved`)}>
{value}
</button>
),
TagPillList: ({
items,
label,
onSave,
}: {
items: string[]
label: string
onSave: (items: string[]) => void
}) => (
<button data-testid="tag-pill-list" onClick={() => onSave([...items, 'gamma'])}>
{label}:{items.join(',')}
</button>
),
UrlValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="url-value" onClick={() => onSave('https://saved.example')}>
{value}
</button>
),
}))
vi.mock('./StatusDropdown', () =>
createDropdownModule('StatusDropdown', 'status-dropdown', ({ onSave, onCancel }) =>
createPrimarySecondaryActions({
primary: { testId: 'status-save', label: 'save', onClick: () => onSave('Done') },
secondary: { testId: 'status-cancel', label: 'cancel', onClick: onCancel },
})),
)
vi.mock('./TagsDropdown', () =>
createDropdownModule('TagsDropdown', 'tags-dropdown', ({ onToggle, onClose }) =>
createPrimarySecondaryActions({
primary: { testId: 'tags-toggle', label: 'toggle', onClick: () => onToggle('beta') },
secondary: { testId: 'tags-close', label: 'close', onClick: onClose },
})),
)
vi.mock('./ColorInput', () => ({
ColorEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="color-value" onClick={() => onSave('#00ff00')}>
{value}
</button>
),
}))
vi.mock('./IconEditableValue', () => ({
IconEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
<button data-testid="icon-value" onClick={() => onSave('sparkles')}>
{value}
</button>
),
}))
vi.mock('@/components/ui/calendar', () => ({
Calendar: ({ onSelect }: { onSelect: (value: Date) => void }) => (
<button
data-testid="date-picker-calendar"
onClick={() => onSelect(new Date(2026, 3, 22))}
>
pick
</button>
),
}))
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverContent: ({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
}))
import { DisplayModeSelector, SmartPropertyValueCell } from './PropertyValueCells'
describe('PropertyValueCells', () => {
it('shows the relationship icon and resets to auto mode when the auto option is selected', () => {
const onSelect = vi.fn()
render(
<DisplayModeSelector
propKey="Related to"
currentMode="text"
autoMode="number"
onSelect={onSelect}
/>,
)
expect(screen.getByTestId('display-mode-icon-relationship')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('display-mode-trigger'))
fireEvent.click(screen.getByTestId('display-mode-option-number'))
expect(onSelect).toHaveBeenCalledWith('Related to', null)
})
it('selects explicit display modes from the menu', () => {
const onSelect = vi.fn()
render(
<DisplayModeSelector
propKey="Status"
currentMode="status"
autoMode="text"
onSelect={onSelect}
/>,
)
fireEvent.click(screen.getByTestId('display-mode-trigger'))
fireEvent.click(screen.getByTestId('display-mode-option-date'))
expect(onSelect).toHaveBeenCalledWith('Status', 'date')
})
it('handles status and tags editing interactions', () => {
const onSave = vi.fn()
const onSaveList = vi.fn()
const onStartEdit = vi.fn()
const { rerender } = render(
<SmartPropertyValueCell
propKey="Status"
value="Doing"
displayMode="status"
isEditing={true}
vaultStatuses={['Doing', 'Done']}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('status-save'))
fireEvent.click(screen.getByTestId('status-cancel'))
expect(onSave).toHaveBeenCalledWith('Status', 'Done')
expect(onStartEdit).toHaveBeenCalledWith(null)
rerender(
<SmartPropertyValueCell
propKey="Tags"
value={['alpha']}
displayMode="tags"
isEditing={true}
vaultStatuses={[]}
vaultTags={['alpha', 'beta']}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTitle('Remove alpha'))
fireEvent.click(screen.getByTestId('tags-add-button'))
fireEvent.click(screen.getByTestId('tags-toggle'))
fireEvent.click(screen.getByTestId('tags-close'))
expect(onSaveList).toHaveBeenCalledWith('Tags', [])
expect(onStartEdit).toHaveBeenCalledWith('Tags')
expect(onSaveList).toHaveBeenCalledWith('Tags', ['alpha', 'beta'])
expect(onStartEdit).toHaveBeenCalledWith(null)
})
it('covers number and date editing branches', () => {
const onSave = vi.fn()
const onSaveList = vi.fn()
const onStartEdit = vi.fn()
const { rerender } = render(
<SmartPropertyValueCell
propKey="Count"
value="12"
displayMode="number"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '' } })
fireEvent.blur(screen.getByTestId('number-input'))
expect(onSave).toHaveBeenCalledWith('Count', '')
rerender(
<SmartPropertyValueCell
propKey="Count"
value="12"
displayMode="number"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '19' } })
fireEvent.keyDown(screen.getByTestId('number-input'), { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('Count', '19')
rerender(
<SmartPropertyValueCell
propKey="Count"
value="12"
displayMode="number"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.change(screen.getByTestId('number-input'), { target: { value: 'nope' } })
fireEvent.blur(screen.getByTestId('number-input'))
expect(onStartEdit).toHaveBeenCalledWith(null)
rerender(
<SmartPropertyValueCell
propKey="Due"
value="2026-04-20"
displayMode="date"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('date-picker-calendar'))
expect(onSave).toHaveBeenCalledWith('Due', '2026-04-22')
rerender(
<SmartPropertyValueCell
propKey="Due"
value="2026-04-20"
displayMode="date"
isEditing={true}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('date-picker-clear'))
expect(onSave).toHaveBeenCalledWith('Due', '')
})
it('auto-detects scalar display modes and delegates array values correctly', () => {
const onSave = vi.fn()
const onSaveList = vi.fn()
const onStartEdit = vi.fn()
const onUpdate = vi.fn()
const { rerender } = render(
<SmartPropertyValueCell
propKey="Flag"
value={true}
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
onUpdate={onUpdate}
/>,
)
fireEvent.click(screen.getByRole('checkbox'))
expect(onUpdate).toHaveBeenCalledWith('Flag', false)
rerender(
<SmartPropertyValueCell
propKey="Website"
value="https://example.com"
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('url-value'))
expect(onSave).toHaveBeenCalledWith('Website', 'https://saved.example')
rerender(
<SmartPropertyValueCell
propKey="Accent"
value="#ff0000"
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('color-value'))
expect(onSave).toHaveBeenCalledWith('Accent', '#00ff00')
rerender(
<SmartPropertyValueCell
propKey="_icon"
value="spark"
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('icon-value'))
expect(onSave).toHaveBeenCalledWith('_icon', 'sparkles')
rerender(
<SmartPropertyValueCell
propKey="Labels"
value={['alpha', 'beta']}
displayMode="text"
isEditing={false}
vaultStatuses={[]}
vaultTags={[]}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
/>,
)
fireEvent.click(screen.getByTestId('tag-pill-list'))
expect(onSaveList).toHaveBeenCalledWith('Labels', ['alpha', 'beta', 'gamma'])
})
})

View File

@@ -0,0 +1,315 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MutableRefObject } from 'react'
const {
buildRawEditorAutocompleteStateMock,
buildRawEditorBaseItemsMock,
buildTypeEntryMapMock,
detectYamlErrorMock,
extractWikilinkQueryMock,
getRawEditorDropdownPositionMock,
noteSearchListState,
replaceActiveWikilinkQueryMock,
trackEventMock,
useCodeMirrorMock,
viewRefState,
} = vi.hoisted(() => ({
buildRawEditorAutocompleteStateMock: vi.fn(),
buildRawEditorBaseItemsMock: vi.fn(),
buildTypeEntryMapMock: vi.fn(),
detectYamlErrorMock: vi.fn(),
extractWikilinkQueryMock: vi.fn(),
getRawEditorDropdownPositionMock: vi.fn(),
noteSearchListState: { lastProps: null as null | Record<string, unknown> },
replaceActiveWikilinkQueryMock: vi.fn(),
trackEventMock: vi.fn(),
useCodeMirrorMock: vi.fn(),
viewRefState: { current: null as null | Record<string, unknown> },
}))
vi.mock('../hooks/useCodeMirror', () => ({
useCodeMirror: useCodeMirrorMock,
}))
vi.mock('../utils/rawEditorUtils', () => ({
buildRawEditorAutocompleteState: buildRawEditorAutocompleteStateMock,
buildRawEditorBaseItems: buildRawEditorBaseItemsMock,
detectYamlError: detectYamlErrorMock,
extractWikilinkQuery: extractWikilinkQueryMock,
getRawEditorDropdownPosition: getRawEditorDropdownPositionMock,
replaceActiveWikilinkQuery: replaceActiveWikilinkQueryMock,
}))
vi.mock('../utils/typeColors', () => ({
buildTypeEntryMap: buildTypeEntryMapMock,
}))
vi.mock('../lib/telemetry', () => ({
trackEvent: trackEventMock,
}))
vi.mock('./NoteSearchList', () => ({
NoteSearchList: (props: {
items: Array<{ title: string; onItemClick: () => void }>
onItemClick: (item: { title: string; onItemClick: () => void }) => void
onItemHover: (index: number) => void
selectedIndex: number
}) => {
noteSearchListState.lastProps = props
return (
<div data-testid="note-search-list">
{props.items.map((item, index) => (
<button
key={item.title}
data-testid={`note-search-item-${index}`}
onMouseEnter={() => props.onItemHover(index)}
onClick={() => props.onItemClick(item)}
>
{item.title}
</button>
))}
<div data-testid="note-search-selected-index">{props.selectedIndex}</div>
</div>
)
},
}))
import { RawEditorView } from './RawEditorView'
function entry(title: string, path = `/vault/note/${title}.md`) {
return {
path,
filename: `${title}.md`,
title,
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: null,
createdAt: null,
fileSize: 0,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
outgoingLinks: [],
properties: {},
}
}
function createMockView(docText = '[[Target') {
return {
state: {
doc: { toString: () => docText },
selection: { main: { head: docText.length } },
},
dispatch: vi.fn(),
focus: vi.fn(),
}
}
describe('RawEditorView behavior coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
noteSearchListState.lastProps = null
viewRefState.current = createMockView()
useCodeMirrorMock.mockImplementation((_containerRef: unknown, _content: string, callbacks: unknown) => {
useCodeMirrorMock.mock.calls[useCodeMirrorMock.mock.calls.length - 1]![2] = callbacks
return viewRefState
})
buildRawEditorBaseItemsMock.mockReturnValue([{ title: 'Base item' }])
buildTypeEntryMapMock.mockReturnValue({ Note: { title: 'Note' } })
detectYamlErrorMock.mockImplementation((doc: string) => (
doc.includes('broken') ? 'Broken YAML' : null
))
extractWikilinkQueryMock.mockReturnValue(null)
getRawEditorDropdownPositionMock.mockReturnValue({ top: 12, left: 34 })
replaceActiveWikilinkQueryMock.mockReturnValue({
text: '[[Inserted]]',
cursor: 12,
})
})
afterEach(() => {
vi.useRealTimers()
})
it('debounces content changes, exposes the latest content ref, flushes saves, and cleans up on unmount', () => {
const onContentChange = vi.fn()
const onSave = vi.fn()
const latestContentRef = { current: null } as MutableRefObject<string | null>
const { rerender, unmount } = render(
<RawEditorView
content="---\ntitle: Start\n---"
path="/vault/a.md"
entries={[entry('Alpha')]}
onContentChange={onContentChange}
onSave={onSave}
latestContentRef={latestContentRef}
/>,
)
const callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
onDocChange: (doc: string) => void
onSave: () => void
}
act(() => {
callbacks.onDocChange('broken content')
})
expect(latestContentRef.current).toBe('broken content')
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Broken YAML')
act(() => {
vi.advanceTimersByTime(500)
})
expect(onContentChange).toHaveBeenCalledWith('/vault/a.md', 'broken content')
rerender(
<RawEditorView
content="fixed"
path="/vault/b.md"
entries={[entry('Alpha')]}
onContentChange={onContentChange}
onSave={onSave}
latestContentRef={latestContentRef}
/>,
)
act(() => {
callbacks.onDocChange('pending change')
callbacks.onSave()
})
expect(onContentChange).toHaveBeenLastCalledWith('/vault/b.md', 'pending change')
expect(onSave).toHaveBeenCalledTimes(1)
act(() => {
callbacks.onDocChange('flush on unmount')
})
unmount()
expect(onContentChange).toHaveBeenLastCalledWith('/vault/b.md', 'flush on unmount')
})
it('opens the autocomplete dropdown, updates selection, inserts wikilinks, and tracks the insert event', () => {
extractWikilinkQueryMock.mockReturnValue('alp')
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }: { onInsertTarget: (target: string) => void }) => ({
items: [
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
{ title: 'Beta', path: '/vault/beta.md', onItemClick: () => onInsertTarget('Beta') },
],
selectedIndex: 0,
}))
const onContentChange = vi.fn()
const mockView = createMockView('[[alp')
viewRefState.current = mockView
render(
<RawEditorView
content="[[alp"
path="/vault/a.md"
entries={[entry('Alpha'), entry('Beta')]}
onContentChange={onContentChange}
onSave={vi.fn()}
vaultPath="/vault"
/>,
)
const callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
onCursorActivity: (view: unknown) => void
onEscape: () => boolean
}
act(() => {
callbacks.onCursorActivity(mockView)
})
expect(buildRawEditorAutocompleteStateMock).toHaveBeenCalledWith(expect.objectContaining({
query: 'alp',
vaultPath: '/vault',
}))
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('0')
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'ArrowDown' })
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('1')
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'ArrowUp' })
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('0')
fireEvent.mouseEnter(screen.getByTestId('note-search-item-1'))
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('1')
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'Enter' })
expect(replaceActiveWikilinkQueryMock).toHaveBeenCalledWith('[[alp', '[[alp'.length, 'Beta')
expect(mockView.dispatch).toHaveBeenCalledWith({
changes: { from: 0, to: 5, insert: '[[Inserted]]' },
selection: { anchor: 12 },
})
expect(onContentChange).toHaveBeenCalledWith('/vault/a.md', '[[Inserted]]')
expect(trackEventMock).toHaveBeenCalledWith('wikilink_inserted')
expect(mockView.focus).toHaveBeenCalledTimes(1)
expect(callbacks.onEscape()).toBe(false)
})
it('clears autocomplete when the query is too short and reports escape handling while open', () => {
extractWikilinkQueryMock
.mockReturnValueOnce('a')
.mockReturnValueOnce('alpha')
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }: { onInsertTarget: (target: string) => void }) => ({
items: [
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
],
selectedIndex: 0,
}))
const mockView = createMockView('[[alpha')
viewRefState.current = mockView
render(
<RawEditorView
content="[[alpha"
path="/vault/a.md"
entries={[entry('Alpha')]}
onContentChange={vi.fn()}
onSave={vi.fn()}
/>,
)
let callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
onCursorActivity: (view: unknown) => void
onEscape: () => boolean
}
act(() => {
callbacks.onCursorActivity(mockView)
})
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
callbacks = useCodeMirrorMock.mock.calls.at(-1)![2] as typeof callbacks
act(() => {
callbacks.onCursorActivity(mockView)
})
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
callbacks = useCodeMirrorMock.mock.calls.at(-1)![2] as typeof callbacks
expect(callbacks.onEscape()).toBe(true)
})
})

View File

@@ -0,0 +1,276 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
trackEventMock,
buildTypeEntryMapMock,
buildRawEditorBaseItemsMock,
detectYamlErrorMock,
extractWikilinkQueryMock,
buildRawEditorAutocompleteStateMock,
getRawEditorDropdownPositionMock,
replaceActiveWikilinkQueryMock,
useCodeMirrorMock,
} = vi.hoisted(() => ({
trackEventMock: vi.fn(),
buildTypeEntryMapMock: vi.fn(() => new Map()),
buildRawEditorBaseItemsMock: vi.fn(() => []),
detectYamlErrorMock: vi.fn(() => null),
extractWikilinkQueryMock: vi.fn(() => null),
buildRawEditorAutocompleteStateMock: vi.fn(),
getRawEditorDropdownPositionMock: vi.fn(() => ({ top: 48, left: 96 })),
replaceActiveWikilinkQueryMock: vi.fn(),
useCodeMirrorMock: vi.fn(),
}))
type CodeMirrorCallbacks = {
onCursorActivity: (view: unknown) => void
onDocChange: (doc: string) => void
onEscape: () => boolean
onSave: () => void
}
let latestCallbacks: CodeMirrorCallbacks | null = null
let latestViewRef: MutableRefObject<{
dispatch: ReturnType<typeof vi.fn>
focus: ReturnType<typeof vi.fn>
state: {
doc: { toString: () => string }
selection: { main: { head: number } }
}
} | null>
vi.mock('../lib/telemetry', () => ({
trackEvent: trackEventMock,
}))
vi.mock('../utils/typeColors', () => ({
buildTypeEntryMap: buildTypeEntryMapMock,
}))
vi.mock('../utils/rawEditorUtils', () => ({
buildRawEditorAutocompleteState: buildRawEditorAutocompleteStateMock,
buildRawEditorBaseItems: buildRawEditorBaseItemsMock,
detectYamlError: detectYamlErrorMock,
extractWikilinkQuery: extractWikilinkQueryMock,
getRawEditorDropdownPosition: getRawEditorDropdownPositionMock,
replaceActiveWikilinkQuery: replaceActiveWikilinkQueryMock,
}))
vi.mock('../hooks/useCodeMirror', () => ({
useCodeMirror: useCodeMirrorMock,
}))
import { RawEditorView } from './RawEditorView'
vi.mock('./NoteSearchList', () => ({
NoteSearchList: ({
items,
selectedIndex,
onItemClick,
onItemHover,
}: {
items: Array<{ title: string }>
selectedIndex: number
onItemClick: (item: { title: string }) => void
onItemHover: (index: number) => void
}) => (
<div data-testid="raw-editor-note-search-list">
{items.map((item, index) => (
<button
key={item.title}
data-testid={`autocomplete-item-${index}`}
data-selected={index === selectedIndex}
onMouseEnter={() => onItemHover(index)}
onClick={() => onItemClick(item)}
>
{item.title}
</button>
))}
</div>
),
}))
function createEntry(title: string) {
return {
path: `/vault/${title.toLowerCase().replace(/\s+/g, '-')}.md`,
filename: `${title.toLowerCase().replace(/\s+/g, '-')}.md`,
title,
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: null,
createdAt: null,
fileSize: 0,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
outgoingLinks: [],
properties: {},
}
}
const defaultProps = {
content: '# Raw note',
path: '/vault/raw-note.md',
entries: [createEntry('Alpha'), createEntry('Beta')],
onContentChange: vi.fn(),
onSave: vi.fn(),
vaultPath: '/vault',
}
function renderView(overrides: Partial<typeof defaultProps> = {}) {
const props = { ...defaultProps, ...overrides }
render(<RawEditorView {...props} />)
return props
}
describe('RawEditorView additional coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
latestViewRef = {
current: {
dispatch: vi.fn(),
focus: vi.fn(),
state: {
doc: { toString: () => 'Before [[Al' },
selection: { main: { head: 11 } },
},
},
}
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }) => ({
selectedIndex: 0,
items: [
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
{ title: 'Beta', path: '/vault/beta.md', onItemClick: () => onInsertTarget('Beta') },
],
}))
replaceActiveWikilinkQueryMock.mockReturnValue({
text: 'Before [[Alpha]]',
cursor: 15,
})
useCodeMirrorMock.mockImplementation((_container, _content, callbacks: CodeMirrorCallbacks) => {
latestCallbacks = callbacks
return latestViewRef
})
})
it('debounces content updates, exposes latest content, flushes on save, and flushes pending edits on unmount', async () => {
vi.useFakeTimers()
const latestContentRef = { current: null as string | null }
const onContentChange = vi.fn()
const onSave = vi.fn()
const { unmount } = render(
<RawEditorView
{...defaultProps}
latestContentRef={latestContentRef}
onContentChange={onContentChange}
onSave={onSave}
/>,
)
expect(latestContentRef.current).toBe('# Raw note')
act(() => {
latestCallbacks?.onDocChange('draft 1')
latestCallbacks?.onDocChange('draft 2')
})
expect(onContentChange).not.toHaveBeenCalled()
expect(latestContentRef.current).toBe('draft 2')
await act(async () => {
vi.advanceTimersByTimeAsync(500)
})
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 2')
act(() => {
latestCallbacks?.onDocChange('draft 3')
latestCallbacks?.onSave()
})
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 3')
expect(onSave).toHaveBeenCalledTimes(1)
act(() => {
latestCallbacks?.onDocChange('draft 4')
})
unmount()
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 4')
})
it('renders YAML errors from the parser result', () => {
detectYamlErrorMock.mockReturnValue('Missing closing delimiter')
renderView()
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Missing closing delimiter')
})
it('opens autocomplete from cursor activity, navigates items, inserts a wikilink, and closes on escape', async () => {
extractWikilinkQueryMock.mockReturnValue('Al')
const onContentChange = vi.fn()
renderView({ onContentChange })
act(() => {
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
})
expect(buildRawEditorAutocompleteStateMock).toHaveBeenCalled()
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
const presentation = screen.getByRole('presentation')
fireEvent.keyDown(presentation, { key: 'ArrowDown' })
fireEvent.keyDown(presentation, { key: 'ArrowUp' })
fireEvent.keyDown(presentation, { key: 'Enter' })
await waitFor(() => {
expect(replaceActiveWikilinkQueryMock).toHaveBeenCalledWith('Before [[Al', 11, 'Alpha')
})
expect(latestViewRef.current?.dispatch).toHaveBeenCalledWith({
changes: { from: 0, to: 'Before [[Al'.length, insert: 'Before [[Alpha]]' },
selection: { anchor: 15 },
})
expect(trackEventMock).toHaveBeenCalledWith('wikilink_inserted')
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'Before [[Alpha]]')
expect(latestViewRef.current?.focus).toHaveBeenCalledTimes(1)
extractWikilinkQueryMock.mockReturnValue(null)
act(() => {
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
})
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
extractWikilinkQueryMock.mockReturnValue('Al')
act(() => {
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
})
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
let escaped = false
act(() => {
escaped = latestCallbacks?.onEscape() ?? false
})
expect(escaped).toBe(true)
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,304 @@
import { act, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import type { VaultEntry } from '../types'
const state = vi.hoisted(() => ({
capturedToolbarProps: null as null | Record<string, unknown>,
capturedSuggestionProps: {} as Record<string, Record<string, unknown>>,
capturedImageDropArgs: null as null | Record<string, unknown>,
hoverGuardMock: vi.fn(),
imageDropState: { isDragOver: false },
linkActivationMock: vi.fn(),
wikilinkEntriesRef: { current: [] as VaultEntry[] },
}))
vi.mock('@blocknote/react', () => ({
ComponentsContext: {
Provider: ({ children }: { children?: ReactNode }) => <>{children}</>,
},
BlockNoteViewRaw: (props: {
children?: ReactNode
editable?: boolean
className?: string
formattingToolbar?: boolean
slashMenu?: boolean
sideMenu?: boolean
}) => {
const {
children,
editable,
className,
formattingToolbar,
slashMenu,
sideMenu,
...restProps
} = props
void formattingToolbar
void slashMenu
void sideMenu
return (
<div
data-testid="blocknote-view"
data-editable={editable !== false ? 'true' : 'false'}
className={className}
{...restProps}
>
{children}
</div>
)
},
SideMenuController: () => <div data-testid="side-menu-controller" />,
SuggestionMenuController: (props: Record<string, unknown>) => {
state.capturedSuggestionProps[String(props.triggerCharacter)] = props
return <div data-testid={`suggestion-${String(props.triggerCharacter)}`} />
},
useCreateBlockNote: vi.fn(),
}))
vi.mock('@blocknote/mantine', () => ({
components: {},
}))
vi.mock('@mantine/core', async () => {
const React = await vi.importActual<typeof import('react')>('react')
return {
MantineContext: React.createContext(null),
MantineProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
}
})
vi.mock('../hooks/useTheme', () => ({
useEditorTheme: () => ({ cssVars: { '--editor-accent': '#abc' } }),
}))
vi.mock('../hooks/useImageDrop', () => ({
useImageDrop: (args: Record<string, unknown>) => {
state.capturedImageDropArgs = args
return state.imageDropState
},
}))
vi.mock('../utils/typeColors', () => ({
buildTypeEntryMap: () => ({}),
}))
vi.mock('../utils/wikilinkSuggestions', () => ({
MIN_QUERY_LENGTH: 2,
deduplicateByPath: <T,>(items: T[]) => items,
preFilterWikilinks: () => [],
}))
vi.mock('../utils/personMentionSuggestions', () => ({
PERSON_MENTION_MIN_QUERY: 1,
filterPersonMentions: () => [],
}))
vi.mock('../utils/suggestionEnrichment', () => ({
attachClickHandlers: <T,>(items: T[]) => items,
enrichSuggestionItems: <T,>(items: T[]) => items,
}))
vi.mock('./WikilinkSuggestionMenu', () => ({
WikilinkSuggestionMenu: () => <div data-testid="wikilink-suggestion-menu" />,
}))
vi.mock('./editorSchema', () => ({
_wikilinkEntriesRef: state.wikilinkEntriesRef,
}))
vi.mock('./blockNoteSideMenuHoverGuard', () => ({
useBlockNoteSideMenuHoverGuard: (containerRef: unknown) => state.hoverGuardMock(containerRef),
}))
vi.mock('./tolariaEditorFormattingConfig', () => ({
getTolariaSlashMenuItems: vi.fn(async () => []),
}))
vi.mock('./tolariaEditorFormatting', () => ({
TolariaFormattingToolbar: () => <div data-testid="tolaria-formatting-toolbar" />,
TolariaFormattingToolbarController: (props: Record<string, unknown>) => {
state.capturedToolbarProps = props
return <div data-testid="tolaria-formatting-toolbar-controller" />
},
}))
vi.mock('./tolariaBlockNoteSideMenu', () => ({
TolariaSideMenu: () => <div data-testid="tolaria-side-menu" />,
}))
vi.mock('./useEditorLinkActivation', () => ({
useEditorLinkActivation: (containerRef: unknown, onNavigateWikilink: unknown) => (
state.linkActivationMock(containerRef, onNavigateWikilink)
),
}))
import { SingleEditorView } from './SingleEditorView'
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/project/alpha.md',
filename: 'alpha.md',
title: 'Alpha',
isA: 'Project',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
archived: false,
modifiedAt: 1,
createdAt: 1,
fileSize: 10,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
...overrides,
}
}
function createEditor() {
const cursorBlock = { id: 'cursor-block', type: 'paragraph', content: [], children: [] }
return {
document: [
{ id: 'heading-block', type: 'heading', content: [], children: [] },
cursorBlock,
],
tryParseMarkdownToBlocks: vi.fn(async () => [
{ type: 'table', content: { type: 'tableContent' } },
]),
blocksToHTMLLossy: vi.fn(() => '<table>seeded</table>'),
_tiptapEditor: { commands: { setContent: vi.fn() } },
focus: vi.fn(),
getTextCursorPosition: vi.fn(() => ({ block: cursorBlock })),
insertBlocks: vi.fn(),
insertInlineContent: vi.fn(),
setTextCursorPosition: vi.fn(),
}
}
describe('SingleEditorView', () => {
beforeEach(() => {
vi.clearAllMocks()
state.capturedToolbarProps = null
state.capturedSuggestionProps = {}
state.capturedImageDropArgs = null
state.imageDropState.isDragOver = false
state.wikilinkEntriesRef.current = []
delete window.__laputaTest
})
it('registers the seeded BlockNote test bridge, applies column widths, and cleans it up on unmount', async () => {
const editor = createEditor()
const entries = [makeEntry()]
const { unmount } = render(
<SingleEditorView
editor={editor as never}
entries={entries}
onNavigateWikilink={vi.fn()}
/>,
)
expect(state.wikilinkEntriesRef.current).toEqual(entries)
expect(typeof window.__laputaTest?.seedBlockNoteTable).toBe('function')
await act(async () => {
await window.__laputaTest?.seedBlockNoteTable?.([120, null, 80])
})
expect(editor.blocksToHTMLLossy).toHaveBeenCalledWith([
expect.objectContaining({
type: 'table',
content: expect.objectContaining({
type: 'tableContent',
columnWidths: [120, null, 80],
}),
}),
expect.objectContaining({ type: 'paragraph' }),
])
expect(editor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<table>seeded</table>')
expect(editor.focus).toHaveBeenCalled()
unmount()
expect(window.__laputaTest?.seedBlockNoteTable).toBeUndefined()
})
it('shows the drag overlay and inserts dropped images after the active cursor block', () => {
state.imageDropState.isDragOver = true
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
vaultPath="/vault"
/>,
)
expect(screen.getByText('Drop image here')).toBeInTheDocument()
act(() => {
(state.capturedImageDropArgs?.onImageUrl as (url: string) => void)('https://example.com/image.png')
})
expect(editor.insertBlocks).toHaveBeenCalledWith(
[{ type: 'image', props: { url: 'https://example.com/image.png' } }],
expect.objectContaining({ id: 'cursor-block' }),
'after',
)
})
it('wires the toolbar mouse guard and suggestion item click handlers', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
expect(state.hoverGuardMock).toHaveBeenCalledOnce()
expect(state.linkActivationMock).toHaveBeenCalledOnce()
const onMouseDownCapture = (
(state.capturedToolbarProps?.floatingUIOptions as { elementProps: { onMouseDownCapture: (event: { target: HTMLElement; preventDefault: () => void }) => void } })
).elementProps.onMouseDownCapture
const menuTrigger = document.createElement('button')
menuTrigger.setAttribute('aria-haspopup', 'menu')
const menuPreventDefault = vi.fn()
onMouseDownCapture({ target: menuTrigger, preventDefault: menuPreventDefault })
expect(menuPreventDefault).not.toHaveBeenCalled()
const normalTarget = document.createElement('div')
const normalPreventDefault = vi.fn()
onMouseDownCapture({ target: normalTarget, preventDefault: normalPreventDefault })
expect(normalPreventDefault).toHaveBeenCalledOnce()
const onWikiItemClick = vi.fn()
const onMentionItemClick = vi.fn()
;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: onWikiItemClick })
;(state.capturedSuggestionProps['@'].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: onMentionItemClick })
expect(onWikiItemClick).toHaveBeenCalledOnce()
expect(onMentionItemClick).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as statusStyles from '../utils/statusStyles'
import { StatusDropdown } from './StatusDropdown'
describe('StatusDropdown extra coverage', () => {
const onSave = vi.fn()
const onCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('wraps keyboard navigation to the create option and scrolls highlighted items into view', () => {
const scrollSpy = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => {})
render(
<StatusDropdown
value="Active"
vaultStatuses={['Doing']}
onSave={onSave}
onCancel={onCancel}
/>,
)
const input = screen.getByTestId('status-search-input')
fireEvent.change(input, { target: { value: 'Needs Review' } })
fireEvent.keyDown(input, { key: 'ArrowUp' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(scrollSpy).toHaveBeenCalled()
expect(onSave).toHaveBeenCalledWith('Needs Review')
scrollSpy.mockRestore()
})
it('opens color pickers and persists the selected accent color', () => {
const setStatusColorSpy = vi.spyOn(statusStyles, 'setStatusColor')
render(
<StatusDropdown
value="Active"
vaultStatuses={['Active']}
onSave={onSave}
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByTestId('status-color-swatch-Active'))
expect(screen.getByTestId('color-picker-Active')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('color-option-green'))
expect(setStatusColorSpy).toHaveBeenCalledWith('Active', 'green')
expect(screen.queryByTestId('color-picker-Active')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,198 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
isWithinFormattingToolbarHoverBridge,
shouldSuppressFormattingToolbarHoverUpdate,
useBlockNoteFormattingToolbarHoverGuard,
} from './blockNoteFormattingToolbarHoverGuard'
function rect(left: number, top: number, width: number, height: number) {
return DOMRect.fromRect({ x: left, y: top, width, height })
}
function setRect(element: HTMLElement, nextRect: DOMRect) {
element.getBoundingClientRect = () => nextRect
}
function setupHoverDom() {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
const wrapper = document.createElement('div')
wrapper.className = 'bn-visual-media-wrapper'
fileBlock.appendChild(wrapper)
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(wrapper, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
return { container, toolbar }
}
function createEditor(options: {
selectionType?: string | null
selectionId?: string
cursorType?: string
cursorId?: string
setTextCursorPosition?: ReturnType<typeof vi.fn>
}) {
const {
selectionType = 'paragraph',
selectionId = 'other-block',
cursorType = 'paragraph',
cursorId = 'cursor-block',
setTextCursorPosition = vi.fn(),
} = options
return {
getSelection: () => (
selectionType
? { blocks: [{ type: selectionType, id: selectionId }] }
: null
),
getTextCursorPosition: () => ({ block: { type: cursorType, id: cursorId } }),
setTextCursorPosition,
}
}
function dispatchBridgeMousemove(target: EventTarget = document.body) {
const event = new MouseEvent('mousemove', {
bubbles: true,
clientX: 368,
clientY: 104,
})
Object.defineProperty(event, 'target', { configurable: true, value: target })
const stopPropagation = vi.fn()
event.stopPropagation = stopPropagation
act(() => {
window.dispatchEvent(event)
})
return stopPropagation
}
describe('blockNoteFormattingToolbarHoverGuard extra coverage', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
it('treats invisible rects and missing bridge inputs as non-suppressing', () => {
expect(
isWithinFormattingToolbarHoverBridge(
{ x: 12, y: 12 },
rect(10, 10, 0, 20),
rect(10, 10, 20, 20),
),
).toBe(false)
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 12, y: 12 },
container: null,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(false)
})
it('remembers the last selected file block while open and clears it when closed', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
const removeSpy = vi.spyOn(window, 'removeEventListener')
const { container } = setupHoverDom()
const setTextCursorPosition = vi.fn()
const editor = createEditor({ setTextCursorPosition })
const { rerender, unmount } = renderHook(
({ selectedFileBlockId, isOpen }) => useBlockNoteFormattingToolbarHoverGuard({
editor: editor as never,
container,
selectedFileBlockId,
isOpen,
}),
{
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
},
)
expect(addSpy).toHaveBeenCalledWith('mousemove', expect.any(Function), true)
rerender({ selectedFileBlockId: null, isOpen: true })
const stopPropagation = dispatchBridgeMousemove()
expect(setTextCursorPosition).toHaveBeenCalledWith('image-block')
expect(stopPropagation).toHaveBeenCalledTimes(1)
rerender({ selectedFileBlockId: null, isOpen: false })
rerender({ selectedFileBlockId: null, isOpen: true })
dispatchBridgeMousemove()
expect(setTextCursorPosition).toHaveBeenCalledTimes(1)
unmount()
expect(removeSpy).toHaveBeenCalledWith('mousemove', expect.any(Function), true)
})
it('skips listener setup when the environment is invalid and safely ignores already-active or failing restores', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
const { container } = setupHoverDom()
const alreadySelectedEditor = createEditor({
selectionType: null,
cursorType: 'image',
cursorId: 'image-block',
setTextCursorPosition: vi.fn(),
})
renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
editor: alreadySelectedEditor as never,
container: null,
selectedFileBlockId: 'image-block',
isOpen: true,
}))
expect(addSpy).not.toHaveBeenCalled()
const { unmount } = renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
editor: alreadySelectedEditor as never,
container,
selectedFileBlockId: 'image-block',
isOpen: true,
}))
dispatchBridgeMousemove()
expect(alreadySelectedEditor.setTextCursorPosition).not.toHaveBeenCalled()
unmount()
const throwingEditor = createEditor({
selectionType: null,
cursorType: 'paragraph',
cursorId: 'other-block',
setTextCursorPosition: vi.fn(() => {
throw new Error('gone')
}),
})
renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
editor: throwingEditor as never,
container,
selectedFileBlockId: 'image-block',
isOpen: true,
}))
expect(() => dispatchBridgeMousemove()).not.toThrow()
})
})

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import {
isWithinFormattingToolbarHoverBridge,
shouldSuppressFormattingToolbarHoverUpdate,
useBlockNoteFormattingToolbarHoverGuard,
} from './blockNoteFormattingToolbarHoverGuard'
function rect(left: number, top: number, width: number, height: number) {
@@ -12,7 +14,69 @@ function setRect(element: HTMLElement, nextRect: DOMRect) {
element.getBoundingClientRect = () => nextRect
}
function appendFileBlock({
container,
blockId,
innerClass,
}: {
container: HTMLElement
blockId: string
innerClass: string
}) {
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = blockId
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
const inner = document.createElement('div')
inner.className = innerClass
fileBlock.appendChild(inner)
block.appendChild(fileBlock)
container.appendChild(block)
return inner
}
function createToolbarHoverScenario(withToolbarButton = false) {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
const toolbarButton = withToolbarButton ? document.createElement('button') : null
if (toolbarButton) toolbar.appendChild(toolbarButton)
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
return { container, toolbarButton }
}
function makeEditor(overrides: Record<string, unknown> = {}) {
return {
getSelection: vi.fn(() => null),
getTextCursorPosition: vi.fn(() => ({ block: { id: 'paragraph-block', type: 'paragraph' } })),
setTextCursorPosition: vi.fn(),
...overrides,
}
}
describe('blockNoteFormattingToolbarHoverGuard', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('treats the gap between the selected image block and toolbar as part of the hover bridge', () => {
expect(
isWithinFormattingToolbarHoverBridge(
@@ -23,92 +87,210 @@ describe('blockNoteFormattingToolbarHoverGuard', () => {
).toBe(true)
})
it('suppresses hover updates when the pointer is already over the toolbar', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
const toolbarButton = document.createElement('button')
toolbar.appendChild(toolbarButton)
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
it('ignores invisible rectangles and missing hover bridge context', () => {
expect(
isWithinFormattingToolbarHoverBridge(
{ x: 10, y: 10 },
rect(300, 130, 0, 90),
rect(322, 78, 96, 24),
),
).toBe(false)
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: toolbarButton,
eventTarget: document.body,
point: { x: 350, y: 90 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('suppresses hover updates while the pointer crosses the image-toolbar bridge', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 368, y: 104 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('leaves unrelated pointer movement alone', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 520, y: 220 },
container,
container: null,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(false)
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 350, y: 90 },
container: document.createElement('div'),
doc: document,
selectedFileBlockId: null,
}),
).toBe(false)
})
it.each([
{
name: 'suppresses hover updates when the pointer is already over the toolbar',
point: { x: 350, y: 90 },
eventTarget: 'toolbarButton',
expected: true,
withToolbarButton: true,
},
{
name: 'suppresses hover updates while the pointer crosses the image-toolbar bridge',
point: { x: 368, y: 104 },
eventTarget: 'body',
expected: true,
withToolbarButton: false,
},
{
name: 'leaves unrelated pointer movement alone',
point: { x: 520, y: 220 },
eventTarget: 'body',
expected: false,
withToolbarButton: false,
},
])('$name', ({ point, eventTarget, expected, withToolbarButton }) => {
const { container, toolbarButton } = createToolbarHoverScenario(withToolbarButton)
const target = eventTarget === 'toolbarButton' ? toolbarButton : document.body
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: target as EventTarget,
point,
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(expected)
})
it('uses the filename bridge fallback and restores the last selected file block while the toolbar stays open', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const bridge = appendFileBlock({
container,
blockId: 'image-block',
innerClass: 'bn-file-name-with-icon',
})
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(bridge, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
const editor = makeEditor()
const { rerender } = renderHook(
({ selectedFileBlockId, isOpen }) =>
useBlockNoteFormattingToolbarHoverGuard({
editor: editor as never,
container,
selectedFileBlockId,
isOpen,
}),
{
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
},
)
rerender({ selectedFileBlockId: null, isOpen: true })
window.dispatchEvent(new MouseEvent('mousemove', {
clientX: 368,
clientY: 104,
bubbles: true,
}))
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('image-block')
vi.mocked(editor.setTextCursorPosition).mockClear()
rerender({ selectedFileBlockId: null, isOpen: false })
rerender({ selectedFileBlockId: null, isOpen: true })
window.dispatchEvent(new MouseEvent('mousemove', {
clientX: 368,
clientY: 104,
bubbles: true,
}))
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
})
it('uses the add-file-button fallback and swallows restore errors when the block disappears', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const bridge = appendFileBlock({
container,
blockId: 'image-block',
innerClass: 'bn-add-file-button',
})
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(bridge, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
const editor = makeEditor({
setTextCursorPosition: vi.fn(() => {
throw new Error('gone')
}),
})
const { rerender } = renderHook(
({ selectedFileBlockId, isOpen }) =>
useBlockNoteFormattingToolbarHoverGuard({
editor: editor as never,
container,
selectedFileBlockId,
isOpen,
}),
{
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
},
)
rerender({ selectedFileBlockId: null, isOpen: true })
expect(() => {
window.dispatchEvent(new MouseEvent('mousemove', {
clientX: 368,
clientY: 104,
bubbles: true,
}))
}).not.toThrow()
})
it('leaves the cursor alone when the selected file block is already active', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const bridge = appendFileBlock({
container,
blockId: 'image-block',
innerClass: 'bn-file-name-with-icon',
})
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(bridge, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
const editor = makeEditor({
getSelection: vi.fn(() => ({
blocks: [{ id: 'image-block', type: 'image' }],
})),
})
renderHook(() =>
useBlockNoteFormattingToolbarHoverGuard({
editor: editor as never,
container,
selectedFileBlockId: 'image-block',
isOpen: true,
}),
)
window.dispatchEvent(new MouseEvent('mousemove', {
clientX: 368,
clientY: 104,
bubbles: true,
}))
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import {
applySelectionIndex,
applySelectionRange,
readSelectionIndex,
readSelectionRange,
serializeInlineNode,
} from './inlineWikilinkDom'
function createChip(target: string): HTMLSpanElement {
const chip = document.createElement('span')
chip.dataset.chipTarget = target
chip.textContent = target
return chip
}
function createMixedInlineRoot(): HTMLDivElement {
const root = document.createElement('div')
root.append('A')
root.append(createChip('Project'))
root.append('B')
document.body.append(root)
return root
}
function setSelectionRange(
startContainer: Node,
startOffset: number,
endContainer: Node,
endOffset: number,
) {
const selection = window.getSelection()
const range = document.createRange()
range.setStart(startContainer, startOffset)
range.setEnd(endContainer, endOffset)
selection?.removeAllRanges()
selection?.addRange(range)
}
describe('inlineWikilinkDom', () => {
it('serializes text, chips, breaks, and nested content into inline markdown', () => {
const root = document.createElement('div')
const nested = document.createElement('span')
root.append('A\u00A0B\u200B\n')
root.append(createChip('Project'))
root.append(document.createElement('br'))
nested.textContent = 'Tail'
root.append(nested)
expect(serializeInlineNode(root)).toBe('A B [[Project]]Tail')
})
it('reads selections inside the editor and falls back to the editor end for outside selections', () => {
const root = createMixedInlineRoot()
const [startText, , endText] = Array.from(root.childNodes)
const outside = document.createElement('div')
outside.textContent = 'Outside'
document.body.append(outside)
setSelectionRange(startText as Node, 1, endText as Node, 1)
expect(readSelectionRange(root)).toEqual({ start: 1, end: 13 })
expect(readSelectionIndex(root)).toBe(13)
setSelectionRange(outside.firstChild as Node, 0, outside.firstChild as Node, 7)
expect(readSelectionRange(root)).toEqual({ start: 13, end: 13 })
})
it('applies selection ranges across chips and clamps to the editor end', () => {
const root = createMixedInlineRoot()
applySelectionRange(root, { start: 2, end: 999 })
const selection = window.getSelection()
expect(selection?.anchorNode?.textContent).toBe('B')
expect(selection?.anchorOffset).toBe(0)
expect(selection?.focusNode?.textContent).toBe('B')
expect(selection?.focusOffset).toBe(1)
})
it('applies collapsed indices before leading chips without requiring text nodes', () => {
const root = document.createElement('div')
root.append(createChip('Task'))
document.body.append(root)
applySelectionIndex(root, 0)
const selection = window.getSelection()
expect(selection?.anchorNode).toBe(root)
expect(selection?.anchorOffset).toBe(0)
expect(selection?.focusNode).toBe(root)
expect(selection?.focusOffset).toBe(0)
})
})

View File

@@ -0,0 +1,524 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModifiedFile, VaultEntry, ViewFile } from '../../types'
import { saveSortPreferences } from '../../utils/noteListHelpers'
import {
useChangeStatusResolver,
useListPropertyPicker,
useMultiSelectKeyboard,
useNoteListInteractions,
useNoteListSearch,
useNoteListSort,
} from './noteListHooks'
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: vi.fn((key: string) => { delete store[key] }),
clear: vi.fn(() => { store = {} }),
}
})()
Object.defineProperty(globalThis, 'localStorage', {
value: localStorageMock,
writable: true,
})
const {
multiSelectState,
noteListKeyboardState,
prefetchNoteContentMock,
routeNoteClickMock,
} = vi.hoisted(() => ({
multiSelectState: {
clear: vi.fn(),
selectAll: vi.fn(),
selectRange: vi.fn(),
setAnchor: vi.fn(),
isMultiSelecting: false,
},
noteListKeyboardState: {
highlightedPath: null as string | null,
handleKeyDown: vi.fn(),
lastOptions: null as null | Record<string, unknown>,
},
prefetchNoteContentMock: vi.fn(),
routeNoteClickMock: vi.fn(),
}))
vi.mock('../../hooks/useMultiSelect', () => ({
useMultiSelect: () => multiSelectState,
}))
vi.mock('../../hooks/useNoteListKeyboard', () => ({
useNoteListKeyboard: (options: Record<string, unknown>) => {
noteListKeyboardState.lastOptions = options
return {
highlightedPath: noteListKeyboardState.highlightedPath,
handleKeyDown: noteListKeyboardState.handleKeyDown,
}
},
}))
vi.mock('../../hooks/useTabManagement', () => ({
prefetchNoteContent: (path: string) => prefetchNoteContentMock(path),
}))
vi.mock('./noteListUtils', async () => {
const actual = await vi.importActual<typeof import('./noteListUtils')>('./noteListUtils')
return {
...actual,
routeNoteClick: (...args: unknown[]) => routeNoteClickMock(...args),
}
})
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/note/a.md',
filename: 'a.md',
title: 'Alpha',
isA: 'Project',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
archived: false,
modifiedAt: 1,
createdAt: 1,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
...overrides,
}
}
function makeDeletedEntry(): VaultEntry {
return makeEntry({
path: '/vault/note/deleted.md',
filename: 'deleted.md',
title: 'Deleted',
__deletedNotePreview: true,
__deletedRelativePath: 'note/deleted.md',
__changeAddedLines: 0,
__changeDeletedLines: 4,
__changeBinary: false,
} as Partial<VaultEntry>)
}
describe('noteListHooks extra', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorageMock.clear()
multiSelectState.isMultiSelecting = false
noteListKeyboardState.highlightedPath = null
noteListKeyboardState.lastOptions = null
routeNoteClickMock.mockImplementation((
entry: VaultEntry,
_event: unknown,
actions: { onReplace: (value: VaultEntry) => void },
) => {
actions.onReplace(entry)
})
})
it('toggles search visibility and clears the search when closing it', () => {
const { result } = renderHook(() => useNoteListSearch())
act(() => {
result.current.toggleSearch()
result.current.setSearch(' HELLO ')
})
expect(result.current.searchVisible).toBe(true)
expect(result.current.query).toBe('hello')
act(() => {
result.current.toggleSearch()
})
expect(result.current.searchVisible).toBe(false)
expect(result.current.search).toBe('')
})
it('migrates stored list sorting into type documents', async () => {
const typeDocument = makeEntry({
path: '/vault/types/project.md',
filename: 'project.md',
title: 'Project',
isA: 'Type',
sort: null,
})
const projectEntry = makeEntry()
const onUpdateTypeSort = vi.fn()
const updateEntry = vi.fn()
saveSortPreferences({
__list__: { option: 'title', direction: 'asc' },
})
const { result } = renderHook(() =>
useNoteListSort({
entries: [typeDocument, projectEntry],
selection: { kind: 'sectionGroup', type: 'Project', label: 'Projects' },
modifiedPathSet: new Set<string>(),
modifiedSuffixes: [],
onUpdateTypeSort,
updateEntry,
}),
)
await waitFor(() => {
expect(onUpdateTypeSort).toHaveBeenCalledWith(typeDocument.path, 'sort', 'title:asc')
expect(updateEntry).toHaveBeenCalledWith(typeDocument.path, { sort: 'title:asc' })
})
act(() => {
result.current.handleSortChange('__list__', 'modified', 'desc')
})
expect(onUpdateTypeSort).toHaveBeenCalledWith(typeDocument.path, 'sort', 'modified:desc')
expect(updateEntry).toHaveBeenCalledWith(typeDocument.path, { sort: 'modified:desc' })
})
it('stores list sorting locally when no persistence target is available', () => {
const entry = makeEntry({ isA: 'Note' })
const { result } = renderHook(() =>
useNoteListSort({
entries: [entry],
selection: { kind: 'filter', filter: 'all' },
modifiedPathSet: new Set<string>(),
modifiedSuffixes: [],
}),
)
act(() => {
result.current.handleSortChange('__list__', 'title', 'asc')
})
expect(result.current.sortPrefs.__list__).toEqual({ option: 'title', direction: 'asc' })
})
it('prefers selected view sort config and persists list sort changes back to the view definition', () => {
const onUpdateViewDefinition = vi.fn()
const view: ViewFile = {
filename: 'work.view',
definition: {
name: 'Work',
icon: null,
color: null,
sort: 'title:asc',
filters: { all: [] },
},
}
const { result } = renderHook(() =>
useNoteListSort({
entries: [makeEntry()],
selection: { kind: 'view', filename: view.filename },
modifiedPathSet: new Set<string>(),
modifiedSuffixes: [],
views: [view],
onUpdateViewDefinition,
}),
)
expect(result.current.listSort).toBe('title')
expect(result.current.listDirection).toBe('asc')
act(() => {
result.current.handleSortChange('__list__', 'modified', 'desc')
})
expect(onUpdateViewDefinition).toHaveBeenCalledWith(view.filename, { sort: 'modified:desc' })
})
it('handles keyboard shortcuts for multi-select flows and ignores select-all in focused inputs', () => {
const onArchive = vi.fn()
const onDelete = vi.fn()
multiSelectState.isMultiSelecting = true
renderHook(() => useMultiSelectKeyboard(multiSelectState as never, false, onArchive, onDelete))
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}))
})
expect(multiSelectState.clear).toHaveBeenCalled()
const input = document.createElement('input')
document.body.appendChild(input)
input.focus()
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'a',
metaKey: true,
bubbles: true,
cancelable: true,
}))
})
expect(multiSelectState.selectAll).not.toHaveBeenCalled()
input.blur()
input.remove()
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'a',
metaKey: true,
bubbles: true,
cancelable: true,
}))
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'e',
metaKey: true,
bubbles: true,
cancelable: true,
}))
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Delete',
metaKey: true,
bubbles: true,
cancelable: true,
}))
})
expect(multiSelectState.selectAll).toHaveBeenCalledOnce()
expect(onArchive).toHaveBeenCalledOnce()
expect(onDelete).toHaveBeenCalledOnce()
})
it('matches change status by relative path suffix and returns undefined outside the changes view', () => {
const modifiedFiles: ModifiedFile[] = [
{ path: '/vault/changes/note/alpha.md', relativePath: 'note/alpha.md', status: 'deleted' },
]
const enabled = renderHook(() => useChangeStatusResolver(true, modifiedFiles))
expect(enabled.result.current('/mirror/worktree/note/alpha.md')).toBe('deleted')
const disabled = renderHook(() => useChangeStatusResolver(false, modifiedFiles))
expect(disabled.result.current('/vault/changes/note/alpha.md')).toBeUndefined()
})
it('returns undefined for change-status lookups that do not match any modified file', () => {
const modifiedFiles: ModifiedFile[] = [
{ path: '/vault/note/a.md', relativePath: 'note/a.md', status: 'modified' },
]
const { result } = renderHook(() => useChangeStatusResolver(true, modifiedFiles))
expect(result.current('/vault/note/a.md')).toBe('modified')
expect(result.current('/vault/note/missing.md')).toBeUndefined()
})
it('builds a type property picker that persists the chosen columns', () => {
const typeDocument = makeEntry({
path: '/vault/types/project.md',
filename: 'project.md',
title: 'Project',
isA: 'Type',
listPropertiesDisplay: ['status'],
})
const projectEntry = makeEntry({
properties: { priority: 'High' },
relationships: { related_to: ['Beta'] },
})
const onUpdateTypeSort = vi.fn()
const { result } = renderHook(() =>
useListPropertyPicker({
entries: [typeDocument, projectEntry],
selection: { kind: 'sectionGroup', type: 'Project', label: 'Projects' },
inboxPeriod: 'month',
typeDocument,
typeEntryMap: { Project: typeDocument },
onUpdateTypeSort,
}),
)
expect(result.current.propertyPicker?.scope).toBe('type')
act(() => {
result.current.propertyPicker?.onSave(['status', 'priority'])
})
expect(onUpdateTypeSort).toHaveBeenCalledWith(
typeDocument.path,
'_list_properties_display',
['status', 'priority'],
)
})
it('uses the view property picker and saves view-specific list property display overrides', () => {
const onUpdateViewDefinition = vi.fn()
const view: ViewFile = {
filename: 'focus.view',
definition: {
name: 'Focus',
icon: null,
color: null,
sort: null,
filters: { all: [] },
listPropertiesDisplay: [],
},
}
const focusTypeDocument = makeEntry({
path: '/vault/types/project.md',
filename: 'project.md',
title: 'Project',
isA: 'Type',
listPropertiesDisplay: ['status'],
})
const projectEntry = makeEntry({
properties: { priority: 'High' },
relationships: { related_to: ['Beta'] },
})
const { result } = renderHook(() =>
useListPropertyPicker({
entries: [focusTypeDocument, projectEntry],
selection: { kind: 'view', filename: view.filename },
inboxPeriod: 'month',
typeDocument: null,
typeEntryMap: { Project: focusTypeDocument },
views: [view],
onUpdateViewDefinition,
}),
)
expect(result.current.propertyPicker?.scope).toBe('view')
act(() => {
result.current.propertyPicker?.onSave(null)
})
expect(onUpdateViewDefinition).toHaveBeenCalledWith(view.filename, {
listPropertiesDisplay: [],
})
})
it('routes deleted-note interactions through the deleted preview handlers and auto-triggers diffs for live changes', () => {
vi.useFakeTimers()
const deletedEntry = makeDeletedEntry()
const liveEntry = makeEntry({ path: '/vault/note/live.md', filename: 'live.md', title: 'Live' })
const onReplaceActiveTab = vi.fn()
const onOpenDeletedNote = vi.fn()
const onAutoTriggerDiff = vi.fn()
const { result } = renderHook(() =>
useNoteListInteractions({
searched: [deletedEntry, liveEntry],
searchedGroups: [],
selectedNotePath: deletedEntry.path,
selection: { kind: 'filter', filter: 'changes' },
noteListFilter: 'open',
isChangesView: true,
entityEntry: null,
searchVisible: false,
toggleSearch: vi.fn(),
onReplaceActiveTab,
onOpenDeletedNote,
onAutoTriggerDiff,
openContextMenuForEntry: vi.fn(),
onCreateNote: vi.fn(),
}),
)
const keyboardOptions = noteListKeyboardState.lastOptions as {
onOpen: (entry: VaultEntry) => void
onPrefetch: (entry: VaultEntry) => void
}
act(() => {
keyboardOptions.onOpen(deletedEntry)
keyboardOptions.onPrefetch(liveEntry)
routeNoteClickMock.mockImplementationOnce((
entry: VaultEntry,
_event: unknown,
actions: { onEnterNeighborhood?: (value: VaultEntry) => void },
) => {
actions.onEnterNeighborhood?.(entry)
})
result.current.handleClickNote(deletedEntry, {} as React.MouseEvent)
result.current.handleClickNote(liveEntry, {} as React.MouseEvent)
vi.advanceTimersByTime(50)
})
expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry)
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
expect(onAutoTriggerDiff).toHaveBeenCalledOnce()
expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry.path)
vi.useRealTimers()
})
it('opens live notes into Neighborhood mode and routes deleted clicks through the deleted preview action', async () => {
const deletedEntry = makeDeletedEntry()
const liveEntry = makeEntry({ path: '/vault/note/live.md', filename: 'live.md', title: 'Live' })
const onReplaceActiveTab = vi.fn(async () => {})
const onEnterNeighborhood = vi.fn()
const onOpenDeletedNote = vi.fn()
routeNoteClickMock.mockImplementation((
_entry: VaultEntry,
_event: unknown,
actions: { onEnterNeighborhood: () => void },
) => {
actions.onEnterNeighborhood()
})
const { result } = renderHook(() =>
useNoteListInteractions({
searched: [deletedEntry, liveEntry],
searchedGroups: [],
selectedNotePath: liveEntry.path,
selection: { kind: 'filter', filter: 'changes' },
noteListFilter: 'open',
isChangesView: true,
entityEntry: null,
searchVisible: false,
toggleSearch: vi.fn(),
onReplaceActiveTab,
onEnterNeighborhood,
onOpenDeletedNote,
openContextMenuForEntry: vi.fn(),
onCreateNote: vi.fn(),
}),
)
const keyboardOptions = noteListKeyboardState.lastOptions as {
onEnterNeighborhood: (entry: VaultEntry) => Promise<void>
}
await act(async () => {
await keyboardOptions.onEnterNeighborhood(deletedEntry)
await keyboardOptions.onEnterNeighborhood(liveEntry)
result.current.handleClickNote(deletedEntry, {} as React.MouseEvent)
})
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
expect(onEnterNeighborhood).toHaveBeenCalledWith(liveEntry)
expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry)
})
})

View File

@@ -0,0 +1,161 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const openExternalUrlMock = vi.fn()
vi.mock('@/components/ui/action-tooltip', () => ({
ActionTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
onClick,
onKeyDown,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" onClick={onClick} onKeyDown={onKeyDown} {...props}>
{children}
</button>
),
}))
vi.mock('@/lib/utils', () => ({
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
}))
vi.mock('../../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => openExternalUrlMock(...args),
}))
vi.mock('./useDismissibleLayer', () => ({
useDismissibleLayer: vi.fn(),
}))
import {
CommitBadge,
ConflictBadge,
NoRemoteBadge,
SyncBadge,
} from './StatusBarBadges'
describe('StatusBarBadges extra coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('opens commit links externally and falls back to a plain hash badge without a URL', () => {
const { rerender } = render(
<CommitBadge info={{ shortHash: 'abc1234', commitUrl: 'https://example.com/commit/abc1234' }} />,
)
fireEvent.click(screen.getByTestId('status-commit-link'))
expect(openExternalUrlMock).toHaveBeenCalledWith('https://example.com/commit/abc1234')
rerender(<CommitBadge info={{ shortHash: 'def5678', commitUrl: null }} />)
expect(screen.getByTestId('status-commit-hash')).toHaveTextContent('def5678')
})
it('renders actionable and passive no-remote badges', () => {
const onAddRemote = vi.fn()
const { rerender } = render(
<NoRemoteBadge
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
onAddRemote={onAddRemote}
/>,
)
fireEvent.click(screen.getByTestId('status-no-remote'))
expect(onAddRemote).toHaveBeenCalledTimes(1)
rerender(
<NoRemoteBadge
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
/>,
)
expect(screen.getByTestId('status-no-remote')).toHaveTextContent('No remote')
expect(screen.getByTestId('status-no-remote')).toHaveAttribute(
'title',
'This git vault has no remote configured. Commits stay local until you add one.',
)
})
it('shows sync popup details, handles pull actions, and covers no-remote summaries', () => {
const onTriggerSync = vi.fn()
const { rerender } = render(
<SyncBadge
status="idle"
lastSyncTime={Date.now() - 120_000}
remoteStatus={{ branch: 'main', ahead: 2, behind: 1, hasRemote: true }}
onTriggerSync={onTriggerSync}
/>,
)
fireEvent.click(screen.getByTestId('status-sync'))
expect(screen.getByTestId('git-status-popup')).toHaveTextContent('main')
expect(screen.getByText('↑ 2 ahead')).toBeInTheDocument()
expect(screen.getByText('↓ 1 behind')).toBeInTheDocument()
expect(screen.getByText(/Status: Synced/)).toBeInTheDocument()
const pullButton = screen.getByTestId('git-status-pull-btn')
fireEvent.mouseEnter(pullButton)
expect(pullButton.style.background).toBe('var(--hover)')
fireEvent.mouseLeave(pullButton)
expect(pullButton.style.background).toBe('transparent')
fireEvent.click(pullButton)
expect(onTriggerSync).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('git-status-popup')).not.toBeInTheDocument()
rerender(
<SyncBadge
status="idle"
lastSyncTime={null}
remoteStatus={null}
/>,
)
fireEvent.click(screen.getByTestId('status-sync'))
expect(screen.getByTestId('git-status-popup')).toHaveTextContent('No remote configured')
})
it('routes conflict and pull-required sync states to their dedicated actions', () => {
const onOpenConflictResolver = vi.fn()
const onPullAndPush = vi.fn()
const { rerender } = render(
<SyncBadge
status="conflict"
lastSyncTime={null}
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: true }}
onOpenConflictResolver={onOpenConflictResolver}
/>,
)
fireEvent.click(screen.getByTestId('status-sync'))
expect(onOpenConflictResolver).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('git-status-popup')).not.toBeInTheDocument()
rerender(
<SyncBadge
status="pull_required"
lastSyncTime={null}
remoteStatus={{ branch: 'main', ahead: 0, behind: 2, hasRemote: true }}
onPullAndPush={onPullAndPush}
/>,
)
fireEvent.click(screen.getByTestId('status-sync'))
expect(onPullAndPush).toHaveBeenCalledTimes(1)
})
it('renders clickable conflict badges with plural copy', () => {
const onClick = vi.fn()
render(<ConflictBadge count={2} onClick={onClick} />)
fireEvent.click(screen.getByTestId('status-conflict-count'))
expect(onClick).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('status-conflict-count')).toHaveTextContent('2 conflicts')
})
})

View File

@@ -0,0 +1,303 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
const {
blockHasTypeMock,
editorHasBlockWithTypeMock,
formattingToolbarStore,
hoverGuardMock,
positionPopoverState,
showState,
useBlockNoteEditorMock,
} = vi.hoisted(() => ({
blockHasTypeMock: vi.fn(() => true),
editorHasBlockWithTypeMock: vi.fn(() => true),
formattingToolbarStore: { setState: vi.fn() },
hoverGuardMock: vi.fn(),
positionPopoverState: { lastProps: null as null | Record<string, unknown> },
showState: { value: true },
useBlockNoteEditorMock: vi.fn(),
}))
function MockIcon() {
return <svg data-testid="mock-icon" />
}
vi.mock('@blocknote/react', () => ({
FormattingToolbar: ({ children }: { children?: ReactNode }) => (
<div data-testid="mock-formatting-toolbar">{children}</div>
),
getFormattingToolbarItems: () => [
<div key="blockTypeSelect" />,
<div key="boldStyleButton" />,
<div key="italicStyleButton" />,
<div key="strikeStyleButton" />,
<div key="createLinkButton" />,
],
PositionPopover: (props: Record<string, unknown> & { children?: ReactNode }) => {
positionPopoverState.lastProps = props
return <div data-testid="mock-position-popover">{props.children}</div>
},
useBlockNoteEditor: useBlockNoteEditorMock,
useComponentsContext: () => ({
FormattingToolbar: {
Button: ({
children,
icon,
label,
onClick,
}: {
children?: ReactNode
icon?: ReactNode
label: string
onClick: () => void
}) => (
<button onClick={onClick} type="button">
{icon}
{label}
{children}
</button>
),
},
}),
useEditorState: ({ editor, selector }: { editor: unknown; selector: (context: { editor: unknown }) => unknown }) => selector({ editor }),
useExtension: () => ({ store: formattingToolbarStore }),
useExtensionState: () => showState.value,
}))
vi.mock('@blocknote/core', () => ({
blockHasType: blockHasTypeMock,
defaultProps: { textAlignment: 'left' },
editorHasBlockWithType: editorHasBlockWithTypeMock,
}))
vi.mock('@blocknote/core/extensions', () => ({
FormattingToolbarExtension: Symbol('FormattingToolbarExtension'),
}))
vi.mock('@mantine/core', () => ({
Button: ({ children, ...props }: { children?: ReactNode }) => <button type="button" {...props}>{children}</button>,
CheckIcon: () => <span data-testid="mantine-check">check</span>,
Menu: Object.assign(
({ children }: { children?: ReactNode }) => <div data-testid="mantine-menu">{children}</div>,
{
Target: ({ children }: { children?: ReactNode }) => <>{children}</>,
Dropdown: ({ children, ...props }: { children?: ReactNode }) => <div {...props}>{children}</div>,
Item: ({ children, ...props }: { children?: ReactNode }) => <button type="button" {...props}>{children}</button>,
},
),
}))
vi.mock('lucide-react', () => ({
Bold: MockIcon,
ChevronDown: MockIcon,
Code2: MockIcon,
Italic: MockIcon,
Strikethrough: MockIcon,
}))
vi.mock('./tolariaEditorFormattingConfig', () => ({
filterTolariaFormattingToolbarItems: (items: ReactNode[]) => items,
getTolariaBlockTypeSelectItems: () => [
{ name: 'Paragraph', type: 'paragraph', props: {}, icon: MockIcon },
{ name: 'Heading 1', type: 'heading', props: { level: 1 }, icon: MockIcon },
],
}))
vi.mock('./blockNoteFormattingToolbarHoverGuard', () => ({
useBlockNoteFormattingToolbarHoverGuard: hoverGuardMock,
}))
import {
TolariaFormattingToolbar,
TolariaFormattingToolbarController,
} from './tolariaEditorFormatting'
function createMockEditor(blockType = 'image') {
const selectedBlock = {
id: 'file-block',
type: blockType,
props: { textAlignment: 'center', level: 1 },
content: [{ type: 'text', text: 'Selected block' }],
}
return {
isEditable: true,
schema: {
styleSchema: {
bold: { type: 'bold', propSchema: 'boolean' },
italic: { type: 'italic', propSchema: 'boolean' },
strike: { type: 'strike', propSchema: 'boolean' },
code: { type: 'code', propSchema: 'boolean' },
},
},
prosemirrorState: { selection: { from: 1, to: 5 } },
domElement: document.createElement('div'),
focus: vi.fn(),
getActiveStyles: () => ({ bold: true }),
getSelection: () => ({ blocks: [selectedBlock] }),
getTextCursorPosition: () => ({ block: selectedBlock }),
toggleStyles: vi.fn(),
transact: vi.fn((callback: () => void) => callback()),
updateBlock: vi.fn(),
}
}
describe('tolariaEditorFormatting behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
positionPopoverState.lastProps = null
showState.value = true
useBlockNoteEditorMock.mockReturnValue(createMockEditor())
})
it('renders toolbar controls, inserts the inline code button, and updates block types', () => {
const editor = createMockEditor('paragraph')
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbar />)
fireEvent.click(screen.getByRole('button', { name: /bold/i }))
fireEvent.click(screen.getByRole('button', { name: /inline code/i }))
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }))
expect(editor.focus).toHaveBeenCalled()
expect(editor.toggleStyles).toHaveBeenCalledWith({ bold: true })
expect(editor.toggleStyles).toHaveBeenCalledWith({ code: true })
expect(editor.transact).toHaveBeenCalledTimes(1)
expect(editor.updateBlock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'file-block' }),
{ type: 'heading', props: { level: 1 } },
)
})
it('controls the floating toolbar placement, hover guard, and escape-key close behavior', () => {
const editor = createMockEditor()
const toolbarComponent = () => <div data-testid="custom-toolbar">Toolbar</div>
useBlockNoteEditorMock.mockReturnValue(editor)
render(
<TolariaFormattingToolbarController
formattingToolbar={toolbarComponent}
floatingUIOptions={{ useFloatingOptions: { placement: 'top-start' } }}
/>,
)
expect(screen.getByTestId('custom-toolbar')).toBeInTheDocument()
expect(hoverGuardMock).toHaveBeenCalledWith({
editor,
container: editor.domElement,
selectedFileBlockId: 'file-block',
isOpen: true,
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({
open: true,
placement: 'top-start',
}),
}))
const onOpenChange = positionPopoverState.lastProps?.useFloatingOptions as {
onOpenChange: (open: boolean, event: unknown, reason?: string) => void
}
onOpenChange.onOpenChange(false, undefined, 'escape-key')
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
expect(editor.focus).toHaveBeenCalledTimes(1)
})
it('uses block alignment when deciding the floating placement', () => {
const editor = createMockEditor()
editor.getTextCursorPosition = () => ({
block: {
id: 'paragraph-block',
type: 'paragraph',
props: { textAlignment: 'right' },
content: [{ type: 'text', text: 'Paragraph' }],
},
})
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
useFloatingOptions: expect.objectContaining({
placement: 'top-end',
}),
}))
})
it('falls back to top-start and focuses the block type trigger on mouse down', () => {
const editor = createMockEditor('paragraph')
const focusSpy = vi.spyOn(HTMLButtonElement.prototype, 'focus').mockImplementation(() => {})
blockHasTypeMock.mockReturnValue(false)
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
fireEvent.mouseDown(screen.getAllByRole('button', { name: 'Paragraph' })[0] as HTMLButtonElement)
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
useFloatingOptions: expect.objectContaining({
placement: 'top-start',
}),
}))
expect(focusSpy).toHaveBeenCalled()
focusSpy.mockRestore()
})
it('keeps the toolbar open during close grace and clears the timeout on unmount', () => {
vi.useFakeTimers()
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
const editor = createMockEditor('paragraph')
useBlockNoteEditorMock.mockReturnValue(editor)
const { rerender, unmount } = render(<TolariaFormattingToolbarController />)
showState.value = false
rerender(<TolariaFormattingToolbarController />)
expect(screen.getByTestId('mock-position-popover')).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(50)
})
unmount()
expect(clearTimeoutSpy).toHaveBeenCalled()
clearTimeoutSpy.mockRestore()
vi.useRealTimers()
})
it('ignores internal pointer and focus transitions before closing on external blur', () => {
const editor = createMockEditor('paragraph')
useBlockNoteEditorMock.mockReturnValue(editor)
render(
<TolariaFormattingToolbarController
formattingToolbar={() => <button data-testid="toolbar-action" type="button">Toolbar</button>}
/>,
)
const toolbarWrapper = screen.getByTestId('toolbar-action').parentElement as HTMLElement
fireEvent.pointerEnter(toolbarWrapper)
fireEvent.pointerLeave(toolbarWrapper, { relatedTarget: screen.getByTestId('toolbar-action') })
fireEvent.focus(toolbarWrapper)
fireEvent.blur(toolbarWrapper, { relatedTarget: screen.getByTestId('toolbar-action') })
expect(screen.getByTestId('toolbar-action')).toBeInTheDocument()
fireEvent.pointerLeave(toolbarWrapper, { relatedTarget: document.body })
fireEvent.blur(toolbarWrapper, { relatedTarget: document.body })
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,163 @@
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { shouldStripAutoLinkedLocalFileMarkMock } = vi.hoisted(() => ({
shouldStripAutoLinkedLocalFileMarkMock: vi.fn(),
}))
vi.mock('../utils/editorLinkAutolink', () => ({
shouldStripAutoLinkedLocalFileMark: shouldStripAutoLinkedLocalFileMarkMock,
}))
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
function Harness({ editor }: { editor: unknown }) {
useFilenameAutolinkGuard(editor as never)
return null
}
function createEditor({
nodes = [],
docChanged = true,
withEvents = true,
withLinkMark = true,
}: {
nodes?: Array<{ node: unknown; pos: number }>
docChanged?: boolean
withEvents?: boolean
withLinkMark?: boolean
}) {
let updateHandler:
| ((payload: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => void)
| undefined
const removeMark = vi.fn()
const setMeta = vi.fn()
const dispatch = vi.fn()
const descendants = vi.fn((callback: (node: unknown, pos: number) => void) => {
for (const entry of nodes) {
callback(entry.node, entry.pos)
}
})
const tiptap = {
schema: {
marks: {
...(withLinkMark ? { link: 'link-mark' } : {}),
},
},
state: {
doc: { descendants },
tr: {
docChanged,
removeMark,
setMeta,
},
},
...(withEvents
? {
on: vi.fn((event: string, handler: typeof updateHandler) => {
if (event === 'update') {
updateHandler = handler
}
}),
off: vi.fn(),
}
: {}),
view: {
dispatch,
},
}
return {
editor: {
_tiptapEditor: tiptap,
},
tiptap,
descendants,
removeMark,
setMeta,
dispatch,
getUpdateHandler: () => updateHandler,
}
}
describe('useFilenameAutolinkGuard extra coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not subscribe when the editor lacks an event API', () => {
const fixture = createEditor({ withEvents: false })
render(<Harness editor={fixture.editor} />)
expect('on' in fixture.tiptap).toBe(false)
expect(fixture.descendants).not.toHaveBeenCalled()
})
it('skips traversal when the editor has no link mark registered', () => {
const fixture = createEditor({ withLinkMark: false })
render(<Harness editor={fixture.editor} />)
fixture.getUpdateHandler()?.({
transaction: {
docChanged: true,
getMeta: vi.fn(() => undefined),
},
})
expect(fixture.descendants).not.toHaveBeenCalled()
expect(fixture.dispatch).not.toHaveBeenCalled()
})
it('ignores text nodes whose marks are not accidental filename links', () => {
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(false)
const fixture = createEditor({
nodes: [
{
node: {
isText: true,
nodeSize: 8,
text: 'draft.md',
marks: [{ type: 'link-mark', attrs: { href: 'draft.md' } }],
},
pos: 5,
},
{
node: {
isText: true,
nodeSize: 6,
text: 'notes',
marks: [{ type: 'other-mark', attrs: { href: 'notes' } }],
},
pos: 20,
},
{
node: {
isText: false,
nodeSize: 3,
text: null,
marks: [],
},
pos: 40,
},
],
})
render(<Harness editor={fixture.editor} />)
fixture.getUpdateHandler()?.({
transaction: {
docChanged: true,
getMeta: vi.fn(() => undefined),
},
})
expect(shouldStripAutoLinkedLocalFileMarkMock).toHaveBeenCalledWith({
href: { raw: 'draft.md' },
text: { raw: 'draft.md' },
})
expect(fixture.removeMark).not.toHaveBeenCalled()
expect(fixture.dispatch).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,174 @@
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { shouldStripAutoLinkedLocalFileMarkMock } = vi.hoisted(() => ({
shouldStripAutoLinkedLocalFileMarkMock: vi.fn(),
}))
vi.mock('../utils/editorLinkAutolink', () => ({
shouldStripAutoLinkedLocalFileMark: shouldStripAutoLinkedLocalFileMarkMock,
}))
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
function Harness({ editor }: { editor: unknown }) {
useFilenameAutolinkGuard(editor as never)
return null
}
function createEditor({
nodes,
docChanged = true,
}: {
nodes: Array<{ node: unknown; pos: number }>
docChanged?: boolean
}) {
let updateHandler: ((payload: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => void) | undefined
const removeMark = vi.fn()
const setMeta = vi.fn()
const dispatch = vi.fn()
const descendants = vi.fn((callback: (node: unknown, pos: number) => void) => {
for (const entry of nodes) {
callback(entry.node, entry.pos)
}
})
const tr = {
docChanged,
removeMark,
setMeta,
}
const tiptap = {
schema: {
marks: {
link: 'link-mark',
},
},
state: {
doc: { descendants },
tr,
},
on: vi.fn((event: string, handler: typeof updateHandler) => {
if (event === 'update') {
updateHandler = handler
}
}),
off: vi.fn(),
view: {
dispatch,
},
}
return {
editor: {
_tiptapEditor: tiptap,
},
tiptap,
tr,
descendants,
dispatch,
getUpdateHandler: () => updateHandler,
}
}
describe('useFilenameAutolinkGuard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('removes accidental filename link marks and tags the transaction to avoid loops', () => {
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(true)
const fixture = createEditor({
nodes: [{
node: {
isText: true,
nodeSize: 8,
text: 'draft.md',
marks: [{
type: 'link-mark',
attrs: { href: 'draft.md' },
}],
},
pos: 4,
}],
})
const { unmount } = render(<Harness editor={fixture.editor} />)
const updateHandler = fixture.getUpdateHandler()
expect(updateHandler).toBeTypeOf('function')
updateHandler?.({
transaction: {
docChanged: true,
getMeta: vi.fn(() => undefined),
},
})
expect(fixture.descendants).toHaveBeenCalledTimes(1)
expect(fixture.tr.removeMark).toHaveBeenCalledWith(4, 12, 'link-mark')
expect(fixture.tr.setMeta).toHaveBeenCalledWith('tolaria-filename-autolink-guard', true)
expect(fixture.dispatch).toHaveBeenCalledWith(fixture.tr)
unmount()
expect(fixture.tiptap.off).toHaveBeenCalledWith('update', updateHandler)
})
it('skips guard runs that are already tagged or have no document changes', () => {
const fixture = createEditor({ nodes: [] })
render(<Harness editor={fixture.editor} />)
const updateHandler = fixture.getUpdateHandler()
updateHandler?.({
transaction: {
docChanged: false,
getMeta: vi.fn(() => undefined),
},
})
updateHandler?.({
transaction: {
docChanged: true,
getMeta: vi.fn(() => true),
},
})
expect(fixture.descendants).not.toHaveBeenCalled()
expect(fixture.dispatch).not.toHaveBeenCalled()
})
it('does not dispatch when the stripped ranges leave the document unchanged', () => {
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(true)
const fixture = createEditor({
docChanged: false,
nodes: [{
node: {
isText: true,
nodeSize: 8,
text: 'draft.md',
marks: [{
type: 'link-mark',
attrs: { href: 'draft.md' },
}],
},
pos: 10,
}],
})
render(<Harness editor={fixture.editor} />)
const updateHandler = fixture.getUpdateHandler()
updateHandler?.({
transaction: {
docChanged: true,
getMeta: vi.fn(() => undefined),
},
})
expect(fixture.tr.removeMark).toHaveBeenCalledWith(10, 18, 'link-mark')
expect(fixture.tr.setMeta).not.toHaveBeenCalled()
expect(fixture.dispatch).not.toHaveBeenCalled()
})
})