test: increase strategic coverage
This commit is contained in:
69
src/components/ConflictResolverModal.extra.test.tsx
Normal file
69
src/components/ConflictResolverModal.extra.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
119
src/components/ConflictResolverModal.keyboard.test.tsx
Normal file
119
src/components/ConflictResolverModal.keyboard.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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'))
|
||||
|
||||
325
src/components/PropertyValueCells.extra.test.tsx
Normal file
325
src/components/PropertyValueCells.extra.test.tsx
Normal 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'])
|
||||
})
|
||||
})
|
||||
390
src/components/PropertyValueCells.test.tsx
Normal file
390
src/components/PropertyValueCells.test.tsx
Normal 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'])
|
||||
})
|
||||
})
|
||||
315
src/components/RawEditorView.behavior.test.tsx
Normal file
315
src/components/RawEditorView.behavior.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
276
src/components/RawEditorView.coverage.test.tsx
Normal file
276
src/components/RawEditorView.coverage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
304
src/components/SingleEditorView.test.tsx
Normal file
304
src/components/SingleEditorView.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
59
src/components/StatusDropdown.extra.test.tsx
Normal file
59
src/components/StatusDropdown.extra.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
100
src/components/inlineWikilinkDom.test.ts
Normal file
100
src/components/inlineWikilinkDom.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
524
src/components/note-list/noteListHooks.extra.test.tsx
Normal file
524
src/components/note-list/noteListHooks.extra.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
161
src/components/status-bar/StatusBarBadges.extra.test.tsx
Normal file
161
src/components/status-bar/StatusBarBadges.extra.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
303
src/components/tolariaEditorFormatting.behavior.test.tsx
Normal file
303
src/components/tolariaEditorFormatting.behavior.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
163
src/components/useFilenameAutolinkGuard.extra.test.tsx
Normal file
163
src/components/useFilenameAutolinkGuard.extra.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
174
src/components/useFilenameAutolinkGuard.test.tsx
Normal file
174
src/components/useFilenameAutolinkGuard.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user