test: increase strategic coverage

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { defineMock } = vi.hoisted(() => ({
defineMock: vi.fn((factory: (view: unknown) => unknown) => factory),
}))
vi.mock('@codemirror/view', () => ({
EditorView: class EditorView {},
ViewPlugin: {
define: defineMock,
},
}))
import { getDocumentZoom, zoomCursorFix } from './zoomCursorFix'
function mockComputedZoom(value: string) {
const real = window.getComputedStyle.bind(window)
return vi.spyOn(window, 'getComputedStyle').mockImplementation((elt, pseudo) => {
const style = real(elt, pseudo)
if (elt === document.documentElement) {
return new Proxy(style, {
get(target, prop) {
if (prop === 'zoom') return value
const next = Reflect.get(target, prop)
return typeof next === 'function' ? next.bind(target) : next
},
})
}
return style
})
}
function mockInlineZoom(value: string) {
return vi.spyOn(document.documentElement.style, 'getPropertyValue').mockImplementation((name: string) => {
if (name === 'zoom') return value
return ''
})
}
function createView() {
const contentDOM = document.createElement('div')
const textNode = document.createTextNode('hello')
contentDOM.appendChild(textNode)
const origPosAtCoords = vi.fn(() => 11)
const origPosAndSideAtCoords = vi.fn(() => ({ pos: 13, assoc: -1 as const }))
const prototype = {
posAtCoords: origPosAtCoords,
posAndSideAtCoords: origPosAndSideAtCoords,
}
const view = Object.assign(Object.create(prototype), {
contentDOM,
posAtDOM: vi.fn(() => 17),
})
return {
view,
textNode,
origPosAtCoords,
origPosAndSideAtCoords,
}
}
describe('zoomCursorFix behavior', () => {
beforeEach(() => {
document.documentElement.style.removeProperty('zoom')
delete (document as Document & {
caretRangeFromPoint?: (x: number, y: number) => Range | null
}).caretRangeFromPoint
})
afterEach(() => {
vi.restoreAllMocks()
})
it('reads inline percentage zoom values when computed zoom is normal', () => {
const computedSpy = mockComputedZoom('normal')
const inlineSpy = mockInlineZoom('125%')
expect(getDocumentZoom()).toBe(1.25)
inlineSpy.mockRestore()
computedSpy.mockRestore()
})
it('falls back to 1 for invalid inline zoom values', () => {
const computedSpy = mockComputedZoom('normal')
const inlineSpy = mockInlineZoom('banana')
expect(getDocumentZoom()).toBe(1)
inlineSpy.mockRestore()
computedSpy.mockRestore()
})
it('uses caretRangeFromPoint when CSS zoom is active and restores prototype methods on destroy', () => {
const computedSpy = mockComputedZoom('normal')
const inlineSpy = mockInlineZoom('150%')
const { view, textNode } = createView()
Object.defineProperty(document, 'caretRangeFromPoint', {
configurable: true,
value: vi.fn(() => ({ startContainer: textNode, startOffset: 2 })),
})
const pluginFactory = zoomCursorFix() as unknown as (view: typeof view) => { destroy: () => void }
const plugin = pluginFactory(view)
expect(view.posAtCoords({ x: 30, y: 40 }, true)).toBe(17)
expect(view.posAndSideAtCoords({ x: 30, y: 40 }, false)).toEqual({ pos: 17, assoc: 1 })
expect(view.posAtDOM).toHaveBeenCalledWith(textNode, 2)
expect(Object.hasOwn(view, 'posAtCoords')).toBe(true)
plugin.destroy()
expect(Object.hasOwn(view, 'posAtCoords')).toBe(false)
expect(view.posAtCoords({ x: 5, y: 6 }, false)).toBe(11)
inlineSpy.mockRestore()
computedSpy.mockRestore()
})
it('falls back to adjusted coordinates when the browser API is unavailable or returns an unusable range', () => {
const computedSpy = mockComputedZoom('normal')
const inlineSpy = mockInlineZoom('200%')
const pluginFactory = zoomCursorFix() as unknown as (view: ReturnType<typeof createView>['view']) => { destroy: () => void }
const first = createView()
pluginFactory(first.view)
expect(first.view.posAtCoords({ x: 40, y: 60 }, true)).toBe(11)
expect(first.origPosAtCoords).toHaveBeenCalledWith({ x: 20, y: 30 }, true)
expect(first.view.posAndSideAtCoords({ x: 40, y: 60 }, false)).toEqual({ pos: 13, assoc: -1 })
expect(first.origPosAndSideAtCoords).toHaveBeenCalledWith({ x: 20, y: 30 }, false)
const second = createView()
Object.defineProperty(document, 'caretRangeFromPoint', {
configurable: true,
value: vi.fn(() => ({ startContainer: document.createTextNode('outside'), startOffset: 0 })),
})
pluginFactory(second.view)
expect(second.view.posAtCoords({ x: 50, y: 70 }, false)).toBe(11)
expect(second.origPosAtCoords).toHaveBeenCalledWith({ x: 25, y: 35 }, false)
const third = createView()
third.view.posAtDOM.mockImplementation(() => {
throw new Error('boom')
})
Object.defineProperty(document, 'caretRangeFromPoint', {
configurable: true,
value: vi.fn(() => ({ startContainer: third.textNode, startOffset: 1 })),
})
pluginFactory(third.view)
expect(third.view.posAtCoords({ x: 60, y: 80 }, false)).toBe(11)
expect(third.origPosAtCoords).toHaveBeenCalledWith({ x: 30, y: 40 }, false)
inlineSpy.mockRestore()
computedSpy.mockRestore()
})
})

View File

@@ -0,0 +1,113 @@
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDocumentZoom, zoomCursorFix } from './zoomCursorFix'
function mockComputedZoom(value: string) {
const realGetComputedStyle = window.getComputedStyle.bind(window)
return vi.spyOn(window, 'getComputedStyle').mockImplementation((element, pseudo) => {
const style = realGetComputedStyle(element, pseudo)
if (element === document.documentElement) {
return new Proxy(style, {
get(target, prop) {
if (prop === 'zoom') return value
const current = Reflect.get(target, prop)
return typeof current === 'function' ? current.bind(target) : current
},
})
}
return style
})
}
describe('zoomCursorFix extra coverage', () => {
let parent: HTMLDivElement
beforeEach(() => {
parent = document.createElement('div')
document.body.appendChild(parent)
document.documentElement.style.removeProperty('zoom')
})
afterEach(() => {
vi.restoreAllMocks()
document.documentElement.style.removeProperty('zoom')
document.body.innerHTML = ''
delete (document as Document & { caretRangeFromPoint?: unknown }).caretRangeFromPoint
})
function createView() {
return new EditorView({
parent,
state: EditorState.create({
doc: 'hello world',
extensions: [zoomCursorFix()],
}),
})
}
it('falls back to inline zoom values when computed zoom is invalid', () => {
mockComputedZoom('not-a-number')
const inlineZoomSpy = vi.spyOn(document.documentElement.style, 'getPropertyValue')
inlineZoomSpy.mockReturnValueOnce('125%')
expect(getDocumentZoom()).toBe(1.25)
inlineZoomSpy.mockReturnValueOnce('-20%')
expect(getDocumentZoom()).toBe(1)
})
it('uses caretRangeFromPoint for zoomed coordinates and restores prototype methods on destroy', () => {
const protoPosAtCoords = vi.spyOn(EditorView.prototype, 'posAtCoords').mockReturnValue(99)
const protoPosAndSideAtCoords = vi.spyOn(EditorView.prototype, 'posAndSideAtCoords').mockReturnValue({ pos: 99, assoc: -1 })
const view = createView()
mockComputedZoom('2')
const textNode = view.contentDOM.querySelector('.cm-line')?.firstChild
expect(textNode).toBeTruthy()
const range = document.createRange()
range.setStart(textNode as Node, 3)
range.setEnd(textNode as Node, 3)
Object.defineProperty(document, 'caretRangeFromPoint', {
configurable: true,
value: vi.fn(() => range),
})
expect(view.posAtCoords({ x: 20, y: 30 }, true)).toBe(3)
expect((view as unknown as { posAndSideAtCoords: (coords: { x: number; y: number }, precise?: boolean) => unknown })
.posAndSideAtCoords({ x: 20, y: 30 }, false)).toEqual({ pos: 3, assoc: 1 })
expect(protoPosAtCoords).not.toHaveBeenCalled()
expect(protoPosAndSideAtCoords).not.toHaveBeenCalled()
view.destroy()
expect(Object.prototype.hasOwnProperty.call(view, 'posAtCoords')).toBe(false)
expect(Object.prototype.hasOwnProperty.call(view, 'posAndSideAtCoords')).toBe(false)
})
it('falls back to original coordinate methods when zoom is 1 or caret lookup misses', () => {
const protoPosAtCoords = vi.spyOn(EditorView.prototype, 'posAtCoords').mockImplementation((coords) => (
coords.x === 10 && coords.y === 15 ? 11 : 7
))
const protoPosAndSideAtCoords = vi.spyOn(EditorView.prototype, 'posAndSideAtCoords').mockImplementation((coords) => (
coords.x === 10 && coords.y === 15 ? { pos: 11, assoc: -1 } : { pos: 7, assoc: -1 }
))
const view = createView()
expect(view.posAtCoords({ x: 8, y: 12 }, true)).toBe(7)
expect(protoPosAtCoords).toHaveBeenCalledWith({ x: 8, y: 12 }, true)
mockComputedZoom('2')
Object.defineProperty(document, 'caretRangeFromPoint', {
configurable: true,
value: vi.fn(() => null),
})
expect(view.posAtCoords({ x: 20, y: 30 }, false)).toBe(11)
expect(protoPosAtCoords).toHaveBeenCalledWith({ x: 10, y: 15 }, false)
expect((view as unknown as { posAndSideAtCoords: (coords: { x: number; y: number }, precise?: boolean) => unknown })
.posAndSideAtCoords({ x: 20, y: 30 }, true)).toEqual({ pos: 11, assoc: -1 })
expect(protoPosAndSideAtCoords).toHaveBeenCalledWith({ x: 10, y: 15 }, true)
})
})

View File

@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { focusEditorWithRetries } from './editorFocusUtils'
describe('editorFocusUtils extra coverage', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
it('uses window focus and selection fallback before logging successful focus timing', () => {
const editable = document.createElement('div')
editable.className = 'ProseMirror'
editable.contentEditable = 'true'
editable.setAttribute('contenteditable', 'true')
editable.tabIndex = -1
Object.defineProperty(editable, 'isContentEditable', { configurable: true, value: true })
document.body.appendChild(editable)
const realFocus = HTMLElement.prototype.focus.bind(editable)
let focusCalls = 0
vi.spyOn(editable, 'focus').mockImplementation(() => {
focusCalls += 1
if (focusCalls >= 2) realFocus()
})
const selection = {
removeAllRanges: vi.fn(),
addRange: vi.fn(),
} as unknown as Selection
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0')
const windowFocusSpy = vi.spyOn(window, 'focus').mockImplementation(() => {})
vi.spyOn(window, 'getSelection').mockReturnValue(selection)
vi.spyOn(performance, 'now').mockReturnValue(150)
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
focusEditorWithRetries({ focus: vi.fn() }, false, 100)
expect(windowFocusSpy).toHaveBeenCalled()
expect(selection.removeAllRanges).toHaveBeenCalled()
expect(selection.addRange).toHaveBeenCalledTimes(1)
expect(debugSpy).toHaveBeenCalledWith('[perf] createNote → focus: 50.0ms')
})
it('uses the fallback editable selector and treats mixed heading content as empty text', () => {
const wrapper = document.createElement('div')
wrapper.className = 'bn-editor'
const editable = document.createElement('div')
editable.contentEditable = 'true'
editable.setAttribute('contenteditable', 'true')
editable.tabIndex = -1
Object.defineProperty(editable, 'isContentEditable', { configurable: true, value: true })
wrapper.appendChild(editable)
document.body.appendChild(wrapper)
const realFocus = HTMLElement.prototype.focus.bind(editable)
vi.spyOn(editable, 'focus').mockImplementation(() => realFocus())
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0)
return 1
})
const setTextCursorPosition = vi.fn()
focusEditorWithRetries({
focus: vi.fn(),
document: [
{
id: 'title',
type: 'heading',
content: [{ kind: 'ignored' }, { type: 'text' }],
},
],
setTextCursorPosition,
}, true, undefined)
expect(rAF).toHaveBeenCalled()
expect(setTextCursorPosition).toHaveBeenCalledWith('title', 'start')
})
it('schedules another animation frame when nothing focusable is available yet', () => {
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
focusEditorWithRetries({ focus: vi.fn() }, false, undefined)
expect(rAF).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,201 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAutoSync } from './useAutoSync'
import type { GitPullResult, GitRemoteStatus } from '../types'
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
const LAST_COMMIT_INFO = {
shortHash: 'a1b2c3d',
commitUrl: 'https://github.com/owner/repo/commit/abc123',
}
const REMOTE_STATUS: GitRemoteStatus = {
branch: 'main',
ahead: 0,
behind: 0,
hasRemote: true,
}
function upToDate(): GitPullResult {
return {
status: 'up_to_date',
message: 'Already up to date',
updatedFiles: [],
conflictFiles: [],
}
}
function updated(files: string[]): GitPullResult {
return {
status: 'updated',
message: `${files.length} files updated`,
updatedFiles: files,
conflictFiles: [],
}
}
function conflict(files: string[]): GitPullResult {
return {
status: 'conflict',
message: 'Merge conflicts detected',
updatedFiles: [],
conflictFiles: files,
}
}
function defaultMockImplementation(command: string) {
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
if (command === 'get_conflict_files') return Promise.resolve([])
if (command === 'git_remote_status') return Promise.resolve(REMOTE_STATUS)
return Promise.resolve(upToDate())
}
function renderSync(overrides: Partial<Parameters<typeof useAutoSync>[0]> = {}) {
const onVaultUpdated = vi.fn()
const onSyncUpdated = vi.fn()
const onConflict = vi.fn()
const onToast = vi.fn()
const hook = renderHook(() =>
useAutoSync({
vaultPath: '/Users/luca/Laputa',
intervalMinutes: 5,
onVaultUpdated,
onSyncUpdated,
onConflict,
onToast,
...overrides,
}),
)
return {
...hook,
onVaultUpdated,
onSyncUpdated,
onConflict,
onToast,
}
}
async function waitForInitialIdle(
result: ReturnType<typeof renderSync>['result'],
) {
await waitFor(() => {
expect(result.current.syncStatus).toBe('idle')
expect(result.current.remoteStatus).toEqual(REMOTE_STATUS)
})
mockInvokeFn.mockClear()
}
describe('useAutoSync extra', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockImplementation(defaultMockImplementation)
})
it('pulls, pushes, and refreshes remote status after a recovery sync', async () => {
const hook = renderSync()
await waitForInitialIdle(hook.result)
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
if (command === 'get_conflict_files') return Promise.resolve([])
if (command === 'git_remote_status') return Promise.resolve({ ...REMOTE_STATUS, behind: 1 })
if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed successfully' })
return Promise.resolve(updated(['notes/today.md']))
})
await act(async () => {
await hook.result.current.pullAndPush()
})
await waitFor(() => {
expect(hook.onVaultUpdated).toHaveBeenCalledWith(['notes/today.md'])
expect(hook.onSyncUpdated).toHaveBeenCalledOnce()
expect(hook.onToast).toHaveBeenCalledWith('Pulled and pushed successfully')
expect(hook.result.current.syncStatus).toBe('idle')
expect(hook.result.current.remoteStatus).toEqual({ ...REMOTE_STATUS, behind: 1 })
})
})
it('surfaces conflicts from pullAndPush without attempting a push', async () => {
const hook = renderSync()
await waitForInitialIdle(hook.result)
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
if (command === 'get_conflict_files') return Promise.resolve([])
if (command === 'git_remote_status') return Promise.resolve(REMOTE_STATUS)
return Promise.resolve(conflict(['plans/weekly.md']))
})
await act(async () => {
await hook.result.current.pullAndPush()
})
await waitFor(() => {
expect(hook.onConflict).toHaveBeenCalledWith(['plans/weekly.md'])
expect(hook.result.current.syncStatus).toBe('conflict')
expect(hook.result.current.conflictFiles).toEqual(['plans/weekly.md'])
})
expect(
mockInvokeFn.mock.calls.some(([command]) => command === 'git_push'),
).toBe(false)
})
it.each([
{
name: 'marks pull_required when the follow-up push is rejected',
pushResult: { status: 'rejected', message: 'Remote advanced again' },
expectedStatus: 'pull_required',
expectedToast: 'Push still rejected after pull — try again',
},
{
name: 'surfaces follow-up push errors',
pushResult: { status: 'network_error', message: 'Push failed: offline' },
expectedStatus: 'error',
expectedToast: 'Push failed: offline',
},
])('$name', async ({ pushResult, expectedStatus, expectedToast }) => {
const hook = renderSync()
await waitForInitialIdle(hook.result)
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'get_last_commit_info') return Promise.resolve(LAST_COMMIT_INFO)
if (command === 'get_conflict_files') return Promise.resolve([])
if (command === 'git_remote_status') return Promise.resolve(REMOTE_STATUS)
if (command === 'git_push') return Promise.resolve(pushResult)
return Promise.resolve(upToDate())
})
await act(async () => {
await hook.result.current.pullAndPush()
})
await waitFor(() => {
expect(hook.result.current.syncStatus).toBe(expectedStatus)
expect(hook.onToast).toHaveBeenCalledWith(expectedToast)
})
})
it('exposes a manual pull-required state for rejected save flows', async () => {
const hook = renderSync()
await waitForInitialIdle(hook.result)
act(() => {
hook.result.current.handlePushRejected()
})
expect(hook.result.current.syncStatus).toBe('pull_required')
})
})

View File

@@ -359,4 +359,137 @@ describe('useAutoSync', () => {
expect(result.current.conflictFiles).toEqual(['conflict.md'])
})
})
it('pulls, pushes, and emits a success toast when recovery succeeds', async () => {
const onSyncUpdated = vi.fn()
let pullCount = 0
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_conflict_files') return Promise.resolve([])
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
if (cmd === 'git_remote_status') return Promise.resolve(null)
if (cmd === 'git_pull') {
pullCount += 1
return Promise.resolve(pullCount === 1 ? upToDate() : updated(['note.md']))
}
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed successfully' })
return Promise.resolve(upToDate())
})
const { result } = renderHook(() =>
useAutoSync({
vaultPath: '/Users/luca/Laputa',
intervalMinutes: 5,
onVaultUpdated,
onSyncUpdated,
onConflict,
onToast,
}),
)
await waitFor(() => {
expect(result.current.syncStatus).toBe('idle')
})
await act(async () => {
result.current.pullAndPush()
})
await waitFor(() => {
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md'])
expect(onSyncUpdated).toHaveBeenCalled()
expect(onToast).toHaveBeenCalledWith('Pulled and pushed successfully')
expect(result.current.syncStatus).toBe('idle')
})
})
it('marks pull_required when the recovery push is still rejected', async () => {
let pullCount = 0
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_conflict_files') return Promise.resolve([])
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
if (cmd === 'git_remote_status') return Promise.resolve(null)
if (cmd === 'git_pull') {
pullCount += 1
return Promise.resolve(pullCount === 1 ? upToDate() : upToDate())
}
if (cmd === 'git_push') {
return Promise.resolve({
status: 'rejected',
message: 'Push rejected: remote has new commits. Pull first, then push.',
})
}
return Promise.resolve(upToDate())
})
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('idle')
})
await act(async () => {
result.current.pullAndPush()
})
await waitFor(() => {
expect(result.current.syncStatus).toBe('pull_required')
expect(onToast).toHaveBeenCalledWith('Push still rejected after pull — try again')
})
})
it('surfaces pull conflicts and pull errors during recovery pushes', async () => {
let mode: 'conflict' | 'error' = 'conflict'
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_conflict_files') return Promise.resolve([])
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
if (cmd === 'git_remote_status') return Promise.resolve(null)
if (cmd === 'git_pull') {
return Promise.resolve(
mode === 'conflict'
? conflict(['note.md'])
: { status: 'error', message: 'fetch failed', updatedFiles: [], conflictFiles: [] },
)
}
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed successfully' })
return Promise.resolve(upToDate())
})
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('idle')
})
await act(async () => {
result.current.pullAndPush()
})
await waitFor(() => {
expect(result.current.syncStatus).toBe('conflict')
expect(result.current.conflictFiles).toEqual(['note.md'])
})
mode = 'error'
await act(async () => {
result.current.pullAndPush()
})
await waitFor(() => {
expect(result.current.syncStatus).toBe('error')
expect(onToast).toHaveBeenCalledWith('Pull failed: fetch failed')
})
})
it('exposes a direct push-rejected handler for external workflows', async () => {
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('idle')
})
act(() => {
result.current.handlePushRejected()
})
expect(result.current.syncStatus).toBe('pull_required')
})
})

View File

@@ -0,0 +1,191 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VaultEntry } from '../types'
import type { ParsedFrontmatter } from '../utils/frontmatter'
const propertyModeState = vi.hoisted(() => ({
overrides: {} as Record<string, string>,
}))
vi.mock('../components/DynamicPropertiesPanel', () => ({
containsWikilinks: (value: unknown) => typeof value === 'string' && value.includes('[['),
}))
vi.mock('../utils/propertyTypes', async () => {
const actual = await vi.importActual<typeof import('../utils/propertyTypes')>('../utils/propertyTypes')
return {
...actual,
loadDisplayModeOverrides: vi.fn(() => ({ ...propertyModeState.overrides })),
saveDisplayModeOverride: vi.fn((key: string, mode: string) => {
propertyModeState.overrides[key] = mode
}),
removeDisplayModeOverride: vi.fn((key: string) => {
delete propertyModeState.overrides[key]
}),
getEffectiveDisplayMode: vi.fn((key: string, value: unknown, overrides: Record<string, string>) => (
overrides[key] ?? actual.detectPropertyType(key, value as never)
)),
}
})
import {
loadDisplayModeOverrides,
removeDisplayModeOverride,
saveDisplayModeOverride,
} from '../utils/propertyTypes'
import { usePropertyPanelState } from './usePropertyPanelState'
function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/note.md',
filename: 'note.md',
title: 'Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1,
createdAt: 1,
fileSize: 1,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: true,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
...overrides,
}
}
function createFrontmatter(overrides: ParsedFrontmatter = {}): ParsedFrontmatter {
return {
Status: 'Active',
Alias: 'shown',
Icon: 'sparkle',
_icon: 'duplicate-hidden',
Tags: ['alpha', 'beta'],
Related_to: '[[Linked note]]',
Workspace: 'hidden',
...overrides,
}
}
describe('usePropertyPanelState extra coverage', () => {
beforeEach(() => {
propertyModeState.overrides = {}
vi.clearAllMocks()
})
it('derives type, status, tag, and visible property metadata from entries and frontmatter', () => {
const { result } = renderHook(() => usePropertyPanelState({
entries: [
entry({ title: 'Project', isA: 'Type', color: 'sky', icon: 'rocket' }),
entry({ title: 'Topic', isA: 'Type', color: 'mint', icon: 'hash' }),
entry({ title: 'Roadmap', status: 'Paused', properties: { Tags: ['alpha', 'gamma'], People: ['Brian'] } }),
],
entryIsA: 'Project',
frontmatter: createFrontmatter(),
}))
expect(result.current.availableTypes).toEqual(['Project', 'Topic'])
expect(result.current.customColorKey).toBe('sky')
expect(result.current.typeColorKeys).toEqual({ Project: 'sky', Topic: 'mint' })
expect(result.current.typeIconKeys).toEqual({ Project: 'rocket', Topic: 'hash' })
expect(result.current.vaultStatuses).toEqual(['Paused'])
expect(result.current.vaultTagsByKey).toEqual({
People: ['Brian'],
Tags: ['alpha', 'gamma'],
})
expect(result.current.propertyEntries).toEqual([
['Status', 'Active'],
['Alias', 'shown'],
['Icon', 'sparkle'],
['Tags', ['alpha', 'beta']],
])
})
it('saves scalar values using number-aware coercion and deletes empty numeric values', () => {
propertyModeState.overrides = { Estimate: 'number' }
const onUpdateProperty = vi.fn()
const onDeleteProperty = vi.fn()
const { result } = renderHook(() => usePropertyPanelState({
entries: [],
entryIsA: null,
frontmatter: { Estimate: 2, Done: false },
onUpdateProperty,
onDeleteProperty,
}))
act(() => {
result.current.setEditingKey('Estimate')
result.current.handleSaveValue('Estimate', ' 42 ')
result.current.handleSaveValue('Estimate', ' ')
result.current.handleSaveValue('Done', 'true')
})
expect(result.current.editingKey).toBeNull()
expect(onUpdateProperty).toHaveBeenCalledWith('Estimate', 42)
expect(onDeleteProperty).toHaveBeenCalledWith('Estimate')
expect(onUpdateProperty).toHaveBeenCalledWith('Done', true)
})
it('reconciles list values, persists added display modes, and supports clearing overrides', () => {
const onAddProperty = vi.fn()
const onDeleteProperty = vi.fn()
const onUpdateProperty = vi.fn()
const { result } = renderHook(() => usePropertyPanelState({
entries: [],
entryIsA: null,
frontmatter: {},
onAddProperty,
onDeleteProperty,
onUpdateProperty,
}))
act(() => {
result.current.setShowAddDialog(true)
result.current.handleSaveList('Tags', [])
result.current.handleSaveList('Tags', ['solo'])
result.current.handleSaveList('Tags', ['alpha', 'beta'])
result.current.handleAdd('Priority', ' 3 ', 'number')
result.current.handleAdd('People', ' Luca, Brian ', 'tags')
result.current.handleAdd('Enabled', 'TRUE', 'boolean')
result.current.handleAdd(' ', 'ignored', 'text')
result.current.handleDisplayModeChange('Priority', null)
})
expect(onDeleteProperty).toHaveBeenCalledWith('Tags')
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', 'solo')
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', ['alpha', 'beta'])
expect(onAddProperty).toHaveBeenCalledWith('Priority', 3)
expect(onAddProperty).toHaveBeenCalledWith('People', ['Luca', 'Brian'])
expect(onAddProperty).toHaveBeenCalledWith('Enabled', true)
expect(onAddProperty).toHaveBeenCalledTimes(3)
expect(saveDisplayModeOverride).toHaveBeenCalledWith('Priority', 'number')
expect(saveDisplayModeOverride).toHaveBeenCalledWith('People', 'tags')
expect(saveDisplayModeOverride).toHaveBeenCalledWith('Enabled', 'boolean')
expect(loadDisplayModeOverrides).toHaveBeenCalled()
expect(removeDisplayModeOverride).toHaveBeenCalledWith('Priority')
expect(result.current.showAddDialog).toBe(false)
})
})

View File

@@ -0,0 +1,202 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VaultEntry } from '../types'
vi.mock('../components/DynamicPropertiesPanel', () => ({
containsWikilinks: (value: unknown) => {
if (Array.isArray(value)) {
return value.some((item) => String(item).includes('[['))
}
return String(value).includes('[[')
},
}))
import { initDisplayModeOverrides } from '../utils/propertyTypes'
import { usePropertyPanelState } from './usePropertyPanelState'
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/note.md',
filename: 'note.md',
title: 'Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 1,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: true,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
...overrides,
}
}
describe('usePropertyPanelState', () => {
beforeEach(() => {
localStorage.clear()
initDisplayModeOverrides({})
})
it('derives visible properties, available types, statuses, and aggregated tags', () => {
const entries = [
makeEntry({ title: 'Topic', isA: 'Type', color: '#334455', icon: 'book' }),
makeEntry({ title: 'Project', isA: 'Type', color: '#112233', icon: 'rocket' }),
makeEntry({
title: 'Alpha',
status: 'Doing',
properties: {
Tags: ['alpha', 'beta'],
Areas: ['ops'],
},
}),
makeEntry({
title: 'Beta',
status: 'Review',
properties: {
Tags: ['beta', 'gamma'],
},
}),
]
const frontmatter = {
_icon: 'sparkles',
icon: 'duplicate',
title: 'Hidden',
Count: 3,
Custom: 'value',
'Related to': '[[Alpha]]',
}
const { result } = renderHook(() =>
usePropertyPanelState({
entries,
entryIsA: 'Project',
frontmatter,
}),
)
expect(result.current.availableTypes).toEqual(['Project', 'Topic'])
expect(result.current.customColorKey).toBe('#112233')
expect(result.current.typeIconKeys.Project).toBe('rocket')
expect(result.current.vaultStatuses).toEqual(['Doing', 'Review'])
expect(result.current.vaultTagsByKey).toEqual({
Areas: ['ops'],
Tags: ['alpha', 'beta', 'gamma'],
})
expect(result.current.propertyEntries).toEqual([
['_icon', 'sparkles'],
['Count', 3],
['Custom', 'value'],
])
})
it('saves scalar and list properties through the correct handlers', () => {
const onUpdateProperty = vi.fn()
const onDeleteProperty = vi.fn()
const { result } = renderHook(() =>
usePropertyPanelState({
entries: [],
entryIsA: null,
frontmatter: {
Count: 7,
Flag: false,
Title: 'kept',
},
onUpdateProperty,
onDeleteProperty,
}),
)
act(() => {
result.current.setEditingKey('Count')
result.current.handleSaveValue('Count', ' ')
})
expect(result.current.editingKey).toBeNull()
expect(onDeleteProperty).toHaveBeenCalledWith('Count')
act(() => {
result.current.handleSaveValue('Flag', 'true')
})
expect(onUpdateProperty).toHaveBeenCalledWith('Flag', true)
act(() => {
result.current.handleSaveList('Tags', [])
result.current.handleSaveList('Tags', ['solo'])
result.current.handleSaveList('Tags', ['solo', 'duo'])
})
expect(onDeleteProperty).toHaveBeenCalledWith('Tags')
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', 'solo')
expect(onUpdateProperty).toHaveBeenCalledWith('Tags', ['solo', 'duo'])
})
it('adds properties, persists non-text display modes, and supports clearing overrides', () => {
const onAddProperty = vi.fn()
const { result } = renderHook(() =>
usePropertyPanelState({
entries: [],
entryIsA: null,
frontmatter: {},
onAddProperty,
}),
)
act(() => {
result.current.setShowAddDialog(true)
result.current.handleAdd(' ', 'ignored', 'text')
})
expect(onAddProperty).not.toHaveBeenCalled()
act(() => {
result.current.handleAdd('Rating', '42', 'number')
})
expect(onAddProperty).toHaveBeenCalledWith('Rating', 42)
expect(result.current.displayOverrides.Rating).toBe('number')
expect(result.current.showAddDialog).toBe(false)
act(() => {
result.current.handleAdd('Labels', 'alpha, beta', 'tags')
})
expect(onAddProperty).toHaveBeenCalledWith('Labels', ['alpha', 'beta'])
expect(result.current.displayOverrides.Labels).toBe('tags')
act(() => {
result.current.handleDisplayModeChange('Rating', null)
})
expect(result.current.displayOverrides.Rating).toBeUndefined()
})
})

View File

@@ -0,0 +1,173 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitRemoteStatus } from '../types'
import { REQUEST_ADD_REMOTE_EVENT } from '../utils/addRemoteEvents'
import { useStatusBarAddRemote } from './useStatusBarAddRemote'
const invokeMock = vi.fn()
const mockInvokeMock = vi.fn()
const isTauriMock = vi.fn(() => false)
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => isTauriMock(),
mockInvoke: (...args: unknown[]) => mockInvokeMock(...args),
}))
function remoteStatus(hasRemote: boolean): GitRemoteStatus {
return {
branch: 'main',
ahead: 0,
behind: 0,
hasRemote,
}
}
describe('useStatusBarAddRemote', () => {
beforeEach(() => {
vi.clearAllMocks()
isTauriMock.mockReturnValue(false)
mockInvokeMock.mockResolvedValue(remoteStatus(false))
invokeMock.mockResolvedValue(remoteStatus(false))
})
it('delegates to onAddRemote when provided', async () => {
const onAddRemote = vi.fn()
const { result } = renderHook(() =>
useStatusBarAddRemote({
vaultPath: '/vault',
isGitVault: true,
remoteStatus: remoteStatus(false),
onAddRemote,
}),
)
await act(async () => {
await result.current.openAddRemote()
})
expect(onAddRemote).toHaveBeenCalledTimes(1)
expect(mockInvokeMock).not.toHaveBeenCalled()
expect(result.current.showAddRemote).toBe(false)
})
it('does nothing when the vault is not git-backed', async () => {
const { result } = renderHook(() =>
useStatusBarAddRemote({
vaultPath: '/vault',
isGitVault: false,
remoteStatus: remoteStatus(false),
}),
)
await act(async () => {
await result.current.openAddRemote()
})
expect(result.current.showAddRemote).toBe(false)
expect(mockInvokeMock).not.toHaveBeenCalled()
})
it('opens when the refreshed remote status has no remote and closes when it does', async () => {
const { result, rerender } = renderHook(
({ remote }) =>
useStatusBarAddRemote({
vaultPath: '/vault',
isGitVault: true,
remoteStatus: remote,
}),
{
initialProps: { remote: remoteStatus(false) },
},
)
await act(async () => {
await result.current.openAddRemote()
})
expect(mockInvokeMock).toHaveBeenCalledWith('git_remote_status', { vaultPath: '/vault' })
expect(result.current.showAddRemote).toBe(true)
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(false))
mockInvokeMock.mockResolvedValue(remoteStatus(true))
await act(async () => {
await result.current.handleRemoteConnected('connected')
})
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
await act(async () => {
result.current.closeAddRemote()
})
expect(result.current.showAddRemote).toBe(false)
rerender({ remote: remoteStatus(false) })
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
})
it('stays closed when the latest refresh already has a remote', async () => {
mockInvokeMock.mockResolvedValue(remoteStatus(true))
const { result } = renderHook(() =>
useStatusBarAddRemote({
vaultPath: '/vault',
isGitVault: true,
remoteStatus: remoteStatus(false),
}),
)
await act(async () => {
await result.current.openAddRemote()
})
expect(result.current.showAddRemote).toBe(false)
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
})
it('reacts to the global add-remote request event', async () => {
const { result } = renderHook(() =>
useStatusBarAddRemote({
vaultPath: '/vault',
isGitVault: true,
remoteStatus: remoteStatus(false),
}),
)
await act(async () => {
window.dispatchEvent(new Event(REQUEST_ADD_REMOTE_EVENT))
})
await waitFor(() => {
expect(result.current.showAddRemote).toBe(true)
})
})
it('uses the Tauri invoke path in native mode and tolerates refresh failures', async () => {
isTauriMock.mockReturnValue(true)
invokeMock
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce(remoteStatus(true))
const { result } = renderHook(() =>
useStatusBarAddRemote({
vaultPath: '/vault',
isGitVault: true,
remoteStatus: null,
}),
)
await act(async () => {
await result.current.openAddRemote()
})
expect(result.current.showAddRemote).toBe(true)
await act(async () => {
await result.current.handleRemoteConnected('connected')
})
expect(result.current.visibleRemoteStatus).toEqual(remoteStatus(true))
})
})

View File

@@ -0,0 +1,298 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useVaultLoader } from './useVaultLoader'
import type { ModifiedFile, VaultEntry, ViewFile } from '../types'
const clearPrefetchCache = vi.fn()
const backendInvokeFn = vi.fn()
let mockIsTauri = false
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => backendInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => mockIsTauri,
mockInvoke: (command: string, args?: Record<string, unknown>) => backendInvokeFn(command, args),
}))
vi.mock('./useTabManagement', () => ({
clearPrefetchCache: () => clearPrefetchCache(),
}))
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/note/hello.md',
filename: 'hello.md',
title: 'Hello',
isA: 'Note',
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 makeModifiedFile(overrides: Partial<ModifiedFile> = {}): ModifiedFile {
return {
path: '/vault/note/hello.md',
relativePath: 'note/hello.md',
status: 'modified',
...overrides,
}
}
function makeView(name: string): ViewFile {
return {
path: `/vault/.views/${name.toLowerCase()}.yml`,
filename: `${name.toLowerCase()}.yml`,
definition: {
name,
icon: 'Folder',
filters: [],
sort: null,
listPropertiesDisplay: [],
},
}
}
function configureBackend(overrides: Partial<Record<string, unknown | Error>> = {}) {
const defaults: Record<string, unknown> = {
list_vault: [makeEntry()],
reload_vault: [makeEntry()],
get_modified_files: [],
list_vault_folders: [],
list_views: [],
git_commit: 'committed',
git_push: { status: 'ok', message: 'pushed' },
}
backendInvokeFn.mockImplementation((command: string) => {
const value = command in overrides ? overrides[command] : defaults[command]
if (value instanceof Error) return Promise.reject(value)
return Promise.resolve(value ?? null)
})
}
async function waitForEntries(
result: ReturnType<typeof renderHook<ReturnType<typeof useVaultLoader>, string>>['result'],
) {
await waitFor(() => {
expect(result.current.entries.length).toBeGreaterThan(0)
})
}
describe('useVaultLoader extra', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsTauri = false
configureBackend()
})
it('uses native commit and push commands when Tauri mode is active', async () => {
mockIsTauri = true
configureBackend({
reload_vault: [makeEntry()],
get_modified_files: [],
})
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitForEntries(result)
let response = { status: '', message: '' }
await act(async () => {
response = await result.current.commitAndPush('save current note')
})
expect(response.status).toBe('ok')
expect(backendInvokeFn).toHaveBeenCalledWith('git_commit', {
vaultPath: '/vault',
message: 'save current note',
})
expect(backendInvokeFn).toHaveBeenCalledWith('git_push', { vaultPath: '/vault' })
})
it('tracks pending saves and replaces entries in place', async () => {
const initialEntry = makeEntry()
configureBackend({
list_vault: [initialEntry],
get_modified_files: [],
})
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitForEntries(result)
act(() => {
result.current.addPendingSave(initialEntry.path)
})
expect(result.current.getNoteStatus(initialEntry.path)).toBe('pendingSave')
act(() => {
result.current.removePendingSave(initialEntry.path)
result.current.replaceEntry(initialEntry.path, {
path: '/vault/note/renamed.md',
title: 'Renamed',
})
})
expect(result.current.getNoteStatus(initialEntry.path)).toBe('clean')
expect(result.current.entries[0]?.path).toBe('/vault/note/renamed.md')
expect(result.current.entries[0]?.title).toBe('Renamed')
})
it('surfaces modified-file refresh failures with an empty fallback list', async () => {
configureBackend({
list_vault: [makeEntry()],
get_modified_files: new Error('backend offline'),
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.modifiedFilesError).toBe('Failed to load changes')
expect(result.current.modifiedFiles).toEqual([])
})
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
it('reloads the vault, refreshes modified files, and clears the prefetch cache', async () => {
configureBackend({
list_vault: [makeEntry()],
get_modified_files: [],
reload_vault: [makeEntry({ path: '/vault/note/fresh.md', filename: 'fresh.md', title: 'Fresh' })],
})
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitForEntries(result)
backendInvokeFn.mockImplementation((command: string) => {
if (command === 'reload_vault') {
return Promise.resolve([
makeEntry({ path: '/vault/note/fresh.md', filename: 'fresh.md', title: 'Fresh' }),
])
}
if (command === 'get_modified_files') {
return Promise.resolve([
makeModifiedFile({ path: '/vault/note/fresh.md', relativePath: 'note/fresh.md' }),
])
}
if (command === 'list_vault_folders' || command === 'list_views') return Promise.resolve([])
return Promise.resolve([makeEntry()])
})
let reloaded: VaultEntry[] = []
await act(async () => {
reloaded = await result.current.reloadVault()
})
expect(clearPrefetchCache).toHaveBeenCalledOnce()
expect(reloaded.map((entry) => entry.title)).toEqual(['Fresh'])
await waitFor(() => {
expect(result.current.entries[0]?.title).toBe('Fresh')
expect(result.current.modifiedFiles[0]?.path).toBe('/vault/note/fresh.md')
})
})
it('returns an empty list when vault reload fails', async () => {
configureBackend({
list_vault: [makeEntry()],
get_modified_files: [],
reload_vault: new Error('reload failed'),
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitForEntries(result)
let reloaded: VaultEntry[] = [makeEntry({ title: 'sentinel' })]
await act(async () => {
reloaded = await result.current.reloadVault()
})
expect(reloaded).toEqual([])
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
it('reloads views when the backend succeeds', async () => {
const views = [makeView('Projects')]
configureBackend({
list_vault: [makeEntry()],
list_views: [],
})
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitForEntries(result)
backendInvokeFn.mockImplementation((command: string) => {
if (command === 'list_views') return Promise.resolve(views)
if (command === 'get_modified_files' || command === 'list_vault_folders') return Promise.resolve([])
return Promise.resolve([makeEntry()])
})
let reloaded: ViewFile[] = []
await act(async () => {
reloaded = await result.current.reloadViews()
})
expect(reloaded.map((view) => view.definition.name)).toEqual(['Projects'])
expect(result.current.views[0]?.definition.name).toBe('Projects')
})
it('returns empty arrays when folder or view reloads fail', async () => {
configureBackend({
list_vault: [makeEntry()],
list_vault_folders: [{ name: 'projects', path: 'projects', children: [] }],
list_views: [makeView('Inbox')],
})
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitForEntries(result)
backendInvokeFn.mockImplementation((command: string) => {
if (command === 'list_vault_folders' || command === 'list_views') {
return Promise.reject(new Error('unavailable'))
}
if (command === 'get_modified_files') return Promise.resolve([])
return Promise.resolve([makeEntry()])
})
let folders: unknown[] = []
let views: ViewFile[] = []
await act(async () => {
folders = await result.current.reloadFolders()
views = await result.current.reloadViews()
})
expect(folders).toEqual([])
expect(views).toEqual([])
})
})

View File

@@ -76,6 +76,17 @@ function buildVaultLoaderMock(options: {
}) as typeof defaultMockInvoke
}
function buildReloadVaultPathMock(loads: Record<string, Promise<VaultEntry[]>>) {
return ((cmd: string, args?: Record<string, unknown>) => {
const path = typeof args?.path === 'string' ? args.path : undefined
if (cmd === 'reload_vault' && path) return loads[path] ?? Promise.resolve([])
if (cmd === 'list_vault_folders') return Promise.resolve([])
if (cmd === 'list_views') return Promise.resolve([])
if (cmd === 'get_modified_files') return Promise.resolve([])
return Promise.resolve(null)
}) as typeof defaultMockInvoke
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
@@ -184,18 +195,10 @@ describe('useVaultLoader', () => {
const firstLoad = createDeferred<VaultEntry[]>()
const secondLoad = createDeferred<VaultEntry[]>()
backendInvokeFn.mockImplementation(((cmd: string, args?: Record<string, unknown>) => {
const path = typeof args?.path === 'string' ? args.path : undefined
if (cmd === 'reload_vault') {
if (path === '/vault-a') return firstLoad.promise
if (path === '/vault-b') return secondLoad.promise
}
if (cmd === 'list_vault_folders') return Promise.resolve([])
if (cmd === 'list_views') return Promise.resolve([])
if (cmd === 'get_modified_files') return Promise.resolve([])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
backendInvokeFn.mockImplementation(buildReloadVaultPathMock({
'/vault-a': firstLoad.promise,
'/vault-b': secondLoad.promise,
}))
const { result, rerender } = renderHook(
({ path }) => useVaultLoader(path),
@@ -423,6 +426,19 @@ describe('useVaultLoader', () => {
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('new')
})
it('tracks and clears pendingSave states separately from unsaved/new markers', async () => {
const { result } = await renderVaultLoader()
act(() => {
result.current.addPendingSave('/vault/note/hello.md')
})
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('pendingSave')
act(() => {
result.current.removePendingSave('/vault/note/hello.md')
})
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('modified')
})
})
describe('loadGitHistory', () => {
@@ -496,6 +512,27 @@ describe('useVaultLoader', () => {
expect(response.status).toBe('ok')
})
it('commits and pushes through the Tauri invoke path', async () => {
await enableTauriMode()
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitForEntries(result)
let response: { status: string; message: string } = { status: '', message: '' }
await act(async () => {
response = await result.current.commitAndPush('tauri commit')
})
expect(response.status).toBe('ok')
expect(backendInvokeFn).toHaveBeenCalledWith('git_commit', {
vaultPath: '/vault',
message: 'tauri commit',
})
expect(backendInvokeFn).toHaveBeenCalledWith('git_push', {
vaultPath: '/vault',
})
})
it.each([
{
name: 'returns rejected status when push is rejected',
@@ -551,6 +588,27 @@ describe('useVaultLoader', () => {
expect(result.current.folders).toEqual(updatedFolders)
})
it('returns an empty folder list when the refresh fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
backendInvokeFn.mockImplementation(((cmd: string) => {
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'list_vault_folders') return Promise.reject(new Error('no folders'))
if (cmd === 'list_views') return Promise.resolve([])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
let folders: Array<{ name: string; path: string; children: [] }> = []
await act(async () => {
folders = await result.current.reloadFolders()
})
expect(folders).toEqual([])
warnSpy.mockRestore()
})
})
describe('loadModifiedFiles', () => {
@@ -563,6 +621,158 @@ describe('useVaultLoader', () => {
expect(result.current.modifiedFiles).toHaveLength(1)
})
it('captures backend errors when modified files cannot be loaded', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
backendInvokeFn.mockImplementation(((cmd: string) => {
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
if (cmd === 'get_modified_files') return Promise.reject('git unavailable')
if (cmd === 'list_vault_folders') return Promise.resolve([])
if (cmd === 'list_views') return Promise.resolve([])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.modifiedFiles).toEqual([])
expect(result.current.modifiedFilesError).toBe('git unavailable')
})
expect(warnSpy).toHaveBeenCalledWith('Failed to load modified files:', 'git unavailable')
warnSpy.mockRestore()
})
})
describe('replaceEntry', () => {
it('replaces an entry path and metadata in place', async () => {
const { result } = await renderVaultLoader()
act(() => {
result.current.replaceEntry('/vault/note/hello.md', {
path: '/vault/note/renamed.md',
filename: 'renamed.md',
title: 'Renamed',
})
})
expect(result.current.entries[0]).toEqual(expect.objectContaining({
path: '/vault/note/renamed.md',
filename: 'renamed.md',
title: 'Renamed',
}))
})
})
describe('reloadVault', () => {
it('refreshes entries from reload_vault and reloads modified files', async () => {
const reloadedEntry = {
...mockEntries[0],
path: '/vault/note/reloaded.md',
filename: 'reloaded.md',
title: 'Reloaded',
}
backendInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'reload_vault') return Promise.resolve([reloadedEntry])
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'list_vault_folders') return Promise.resolve([])
if (cmd === 'list_views') return Promise.resolve([])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = await renderVaultLoader()
let entries: VaultEntry[] = []
await act(async () => {
entries = await result.current.reloadVault()
})
expect(entries.map((entry) => entry.title)).toEqual(['Reloaded'])
expect(result.current.entries.map((entry) => entry.title)).toEqual(['Reloaded'])
expect(result.current.modifiedFiles).toEqual([])
})
it('returns an empty list when reloading the vault fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
backendInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'reload_vault') return Promise.reject(new Error('reload failed'))
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'list_vault_folders') return Promise.resolve([])
if (cmd === 'list_views') return Promise.resolve([])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = await renderVaultLoader()
let entries: VaultEntry[] = []
await act(async () => {
entries = await result.current.reloadVault()
})
expect(entries).toEqual([])
expect(result.current.entries).toEqual(mockEntries)
warnSpy.mockRestore()
})
})
describe('reloadViews', () => {
it('refreshes views and falls back to an empty array when they are unavailable', async () => {
const initialViews = [{
filename: 'work.view',
definition: {
name: 'Work',
icon: null,
color: null,
sort: null,
filters: { all: [] },
},
}]
const updatedViews = [{
filename: 'projects.view',
definition: {
name: 'Projects',
icon: null,
color: null,
sort: null,
filters: { all: [] },
},
}]
backendInvokeFn.mockImplementation(((cmd: string) => {
if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries)
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'list_vault_folders') return Promise.resolve([])
if (cmd === 'list_views') return Promise.resolve(initialViews)
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = await renderVaultLoader()
expect(result.current.views).toEqual(initialViews)
backendInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_views') return Promise.resolve(updatedViews)
return defaultMockInvoke(cmd)
}) as typeof defaultMockInvoke)
await act(async () => {
const views = await result.current.reloadViews()
expect(views).toEqual(updatedViews)
})
expect(result.current.views).toEqual(updatedViews)
backendInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_views') return Promise.reject(new Error('views unavailable'))
return defaultMockInvoke(cmd)
}) as typeof defaultMockInvoke)
await act(async () => {
const views = await result.current.reloadViews()
expect(views).toEqual([])
})
})
})
})
@@ -593,6 +803,10 @@ describe('resolveNoteStatus', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('modified')
})
it('returns clean for unsupported git statuses', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'renamed')])).toBe('clean')
})
it('newPaths takes priority over git modified', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [mf('/vault/x.md', 'modified')])).toBe('new')
})

View File

@@ -0,0 +1,243 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
buildAgentSystemPromptMock,
formatMessageWithHistoryMock,
nextMessageIdMock,
trimHistoryMock,
} = vi.hoisted(() => ({
buildAgentSystemPromptMock: vi.fn(() => 'SYSTEM'),
formatMessageWithHistoryMock: vi.fn((_history: unknown, prompt: string) => `formatted:${prompt}`),
nextMessageIdMock: vi.fn(),
trimHistoryMock: vi.fn((history: unknown) => history),
}))
vi.mock('../utils/ai-agent', () => ({
buildAgentSystemPrompt: buildAgentSystemPromptMock,
}))
vi.mock('../utils/ai-chat', () => ({
MAX_HISTORY_TOKENS: 100_000,
formatMessageWithHistory: formatMessageWithHistoryMock,
nextMessageId: nextMessageIdMock,
trimHistory: trimHistoryMock,
}))
import {
appendLocalResponse,
appendStreamingMessage,
buildFormattedMessage,
createMissingAgentResponse,
type AiAgentMessage,
} from './aiAgentConversation'
import {
markReasoningDone,
updateMessage,
updateToolAction,
} from './aiAgentMessageState'
function createMessageStore(initial: AiAgentMessage[] = []) {
let messages = initial
return {
getMessages: () => messages,
setMessages: (next: AiAgentMessage[] | ((current: AiAgentMessage[]) => AiAgentMessage[])) => {
messages = typeof next === 'function' ? next(messages) : next
},
}
}
describe('aiAgentConversation', () => {
beforeEach(() => {
vi.clearAllMocks()
buildAgentSystemPromptMock.mockReturnValue('SYSTEM')
formatMessageWithHistoryMock.mockImplementation((_history: unknown, prompt: string) => `formatted:${prompt}`)
trimHistoryMock.mockImplementation((history: unknown) => history)
})
it('creates a missing-agent response using the agent label', () => {
expect(createMissingAgentResponse('codex')).toContain('Codex is not available on this machine')
})
it('appends local responses with the normalized message shape', () => {
nextMessageIdMock.mockReturnValue('msg-local')
const store = createMessageStore()
appendLocalResponse(
store.setMessages,
{ text: 'Explain this', references: [{ path: '/vault/note.md', title: 'Note' }] },
'Sure',
)
expect(store.getMessages()).toEqual([
{
userMessage: 'Explain this',
references: [{ path: '/vault/note.md', title: 'Note' }],
actions: [],
response: 'Sure',
id: 'msg-local',
},
])
})
it('appends streaming messages and returns the generated message id', () => {
nextMessageIdMock.mockReturnValue('msg-stream')
const store = createMessageStore()
const messageId = appendStreamingMessage(store.setMessages, { text: 'Draft reply' })
expect(messageId).toBe('msg-stream')
expect(store.getMessages()).toEqual([
{
userMessage: 'Draft reply',
references: undefined,
actions: [],
isStreaming: true,
id: 'msg-stream',
},
])
})
it('builds a formatted message from completed history only', () => {
const messages: AiAgentMessage[] = [
{
id: 'msg-1',
userMessage: 'First question',
actions: [],
response: 'First answer',
},
{
id: 'msg-2',
userMessage: 'Still streaming',
actions: [],
isStreaming: true,
},
]
const result = buildFormattedMessage(
{ agent: 'codex', ready: true, vaultPath: '/vault' },
messages,
{ text: 'Latest question' },
)
expect(buildAgentSystemPromptMock).toHaveBeenCalledTimes(1)
expect(trimHistoryMock).toHaveBeenCalledWith([
{ role: 'user', content: 'First question', id: 'msg-1' },
{ role: 'assistant', content: 'First answer', id: 'msg-1-resp' },
], 100_000)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([
{ role: 'user', content: 'First question', id: 'msg-1' },
{ role: 'assistant', content: 'First answer', id: 'msg-1-resp' },
], 'Latest question')
expect(result).toEqual({
formattedMessage: 'formatted:Latest question',
systemPrompt: 'SYSTEM',
})
})
it('prefers a system prompt override when provided', () => {
const result = buildFormattedMessage(
{ agent: 'codex', ready: true, vaultPath: '/vault', systemPromptOverride: 'OVERRIDE' },
[],
{ text: 'Prompt' },
)
expect(buildAgentSystemPromptMock).not.toHaveBeenCalled()
expect(result.systemPrompt).toBe('OVERRIDE')
})
})
describe('aiAgentMessageState', () => {
it('updates only the targeted message', () => {
const store = createMessageStore([
{ id: 'keep', userMessage: 'Keep', actions: [] },
{ id: 'edit', userMessage: 'Edit', actions: [] },
])
updateMessage(store.setMessages, 'edit', (message) => ({
...message,
response: 'Updated',
}))
expect(store.getMessages()).toEqual([
{ id: 'keep', userMessage: 'Keep', actions: [] },
{ id: 'edit', userMessage: 'Edit', actions: [], response: 'Updated' },
])
})
it('marks reasoning as done only once', () => {
const store = createMessageStore([
{ id: 'done', userMessage: 'Question', actions: [], reasoningDone: true },
{ id: 'pending', userMessage: 'Another', actions: [] },
])
markReasoningDone(store.setMessages, 'done')
markReasoningDone(store.setMessages, 'pending')
expect(store.getMessages()).toEqual([
{ id: 'done', userMessage: 'Question', actions: [], reasoningDone: true },
{ id: 'pending', userMessage: 'Another', actions: [], reasoningDone: true },
])
})
it('adds new tool actions with the expected labels', () => {
const baseMessage: AiAgentMessage = {
id: 'msg',
userMessage: 'Question',
actions: [],
}
expect(updateToolAction(baseMessage, 'Bash', 'tool-1', 'ls')).toMatchObject({
actions: [{
tool: 'Bash',
toolId: 'tool-1',
label: 'Ran shell command',
status: 'pending',
input: 'ls',
}],
})
expect(updateToolAction(baseMessage, 'Write', 'tool-2', '{"path":"/tmp/a.md"}')).toMatchObject({
actions: [{
tool: 'Write',
toolId: 'tool-2',
label: 'Wrote file',
status: 'pending',
}],
})
expect(updateToolAction(baseMessage, 'Edit', 'tool-3', '{"path":"/tmp/a.md"}')).toMatchObject({
actions: [{
tool: 'Edit',
toolId: 'tool-3',
label: 'Edited file',
status: 'pending',
}],
})
})
it('updates an existing tool action without dropping the prior input', () => {
const message: AiAgentMessage = {
id: 'msg',
userMessage: 'Question',
actions: [{
tool: 'Write',
toolId: 'tool-1',
label: 'Wrote file',
status: 'pending',
input: '{"path":"/tmp/original.md"}',
}],
}
expect(updateToolAction(message, 'Write', 'tool-1')).toEqual({
...message,
actions: [{
tool: 'Write',
toolId: 'tool-1',
label: 'Wrote file',
status: 'pending',
input: '{"path":"/tmp/original.md"}',
}],
})
})
})

View File

@@ -0,0 +1,242 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatus } from '../hooks/useAiAgent'
import type { AiAgentMessage } from './aiAgentConversation'
const {
buildAgentSystemPromptMock,
createStreamCallbacksMock,
formatMessageWithHistoryMock,
nextMessageIdMock,
streamAiAgentMock,
trimHistoryMock,
} = vi.hoisted(() => ({
buildAgentSystemPromptMock: vi.fn(() => 'SYSTEM'),
createStreamCallbacksMock: vi.fn(() => ({ stream: 'callbacks' })),
formatMessageWithHistoryMock: vi.fn((_history: unknown, prompt: string) => `formatted:${prompt}`),
nextMessageIdMock: vi.fn(),
streamAiAgentMock: vi.fn(async () => {}),
trimHistoryMock: vi.fn((history: unknown) => history),
}))
vi.mock('../utils/ai-agent', () => ({
buildAgentSystemPrompt: buildAgentSystemPromptMock,
}))
vi.mock('../utils/ai-chat', () => ({
MAX_HISTORY_TOKENS: 100_000,
formatMessageWithHistory: formatMessageWithHistoryMock,
nextMessageId: nextMessageIdMock,
trimHistory: trimHistoryMock,
}))
vi.mock('./aiAgentStreamCallbacks', () => ({
createStreamCallbacks: createStreamCallbacksMock,
}))
vi.mock('../utils/streamAiAgent', () => ({
streamAiAgent: streamAiAgentMock,
}))
import {
clearAgentConversation,
sendAgentMessage,
type AiAgentSessionRuntime,
} from './aiAgentSession'
function createRuntime(
initialMessages: AiAgentMessage[] = [],
initialStatus: AgentStatus = 'idle',
) {
let messages = initialMessages
let status = initialStatus
const messagesRef = { current: messages }
const statusRef = { current: status }
const setMessages = vi.fn((next: AiAgentMessage[] | ((current: AiAgentMessage[]) => AiAgentMessage[])) => {
messages = typeof next === 'function' ? next(messages) : next
messagesRef.current = messages
})
const setStatus = vi.fn((next: AgentStatus | ((current: AgentStatus) => AgentStatus)) => {
status = typeof next === 'function' ? next(status) : next
statusRef.current = status
})
const runtime: AiAgentSessionRuntime = {
setMessages,
setStatus,
abortRef: { current: { aborted: true } },
responseAccRef: { current: 'stale response' },
fileCallbacksRef: { current: { onVaultChanged: vi.fn() } },
toolInputMapRef: { current: new Map([['stale-tool', { tool: 'Write', input: '{"path":"/stale.md"}' }]]) },
messagesRef,
statusRef,
}
return {
runtime,
getMessages: () => messages,
getStatus: () => status,
}
}
describe('aiAgentSession', () => {
beforeEach(() => {
vi.clearAllMocks()
buildAgentSystemPromptMock.mockReturnValue('SYSTEM')
createStreamCallbacksMock.mockReturnValue({ stream: 'callbacks' })
formatMessageWithHistoryMock.mockImplementation((_history: unknown, prompt: string) => `formatted:${prompt}`)
trimHistoryMock.mockImplementation((history: unknown) => history)
streamAiAgentMock.mockResolvedValue(undefined)
})
async function expectLocalResponse(options: {
messageId: string
context: { agent: string; ready: boolean; vaultPath: string }
prompt: { text: string; references?: [] }
response: string
}) {
nextMessageIdMock.mockReturnValue(options.messageId)
const { runtime, getMessages } = createRuntime()
await sendAgentMessage({
runtime,
context: options.context,
prompt: options.prompt,
})
expect(getMessages()).toEqual([
{
userMessage: options.prompt.text,
references: undefined,
actions: [],
response: options.response,
id: options.messageId,
},
])
expect(streamAiAgentMock).not.toHaveBeenCalled()
}
it('ignores blank prompts and busy runtimes', async () => {
const idleRuntime = createRuntime()
await sendAgentMessage({
runtime: idleRuntime.runtime,
context: { agent: 'codex', ready: true, vaultPath: '/vault' },
prompt: { text: ' ' },
})
const busyRuntime = createRuntime([], 'thinking')
await sendAgentMessage({
runtime: busyRuntime.runtime,
context: { agent: 'codex', ready: true, vaultPath: '/vault' },
prompt: { text: 'Question' },
})
expect(idleRuntime.getMessages()).toEqual([])
expect(busyRuntime.getMessages()).toEqual([])
expect(streamAiAgentMock).not.toHaveBeenCalled()
})
it('appends local fallback responses when the session cannot stream', async () => {
const fallbackCases = [
{
messageId: 'msg-local',
context: { agent: 'codex', ready: true, vaultPath: '' },
prompt: { text: 'Open a note' },
response: 'No vault loaded. Open a vault first.',
},
{
messageId: 'msg-missing',
context: { agent: 'codex', ready: false, vaultPath: '/vault' },
prompt: { text: 'Open a note', references: [] },
response:
'Codex is not available on this machine. Install it or switch the default AI agent in Settings.',
},
] as const
for (const fallbackCase of fallbackCases) {
await expectLocalResponse(fallbackCase)
}
})
it('starts a streaming session with formatted history and fresh refs', async () => {
nextMessageIdMock.mockReturnValue('msg-stream')
const completedHistory: AiAgentMessage = {
id: 'msg-1',
userMessage: 'Previous question',
actions: [],
response: 'Previous answer',
}
const streamingHistory: AiAgentMessage = {
id: 'msg-2',
userMessage: 'Ignored streaming question',
actions: [],
isStreaming: true,
}
const { runtime, getMessages, getStatus } = createRuntime([
completedHistory,
streamingHistory,
])
await sendAgentMessage({
runtime,
context: {
agent: 'codex',
ready: true,
vaultPath: '/vault',
systemPromptOverride: 'OVERRIDE',
},
prompt: {
text: ' Latest question ',
references: [{ path: '/vault/ref.md', title: 'Ref' }],
},
})
expect(runtime.abortRef.current).toEqual({ aborted: false })
expect(runtime.responseAccRef.current).toBe('')
expect(runtime.toolInputMapRef.current.size).toBe(0)
expect(getStatus()).toBe('thinking')
expect(getMessages().at(-1)).toEqual({
userMessage: 'Latest question',
references: [{ path: '/vault/ref.md', title: 'Ref' }],
actions: [],
isStreaming: true,
id: 'msg-stream',
})
expect(trimHistoryMock).toHaveBeenCalledWith([
{ role: 'user', content: 'Previous question', id: 'msg-1' },
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
], 100_000)
expect(formatMessageWithHistoryMock).toHaveBeenCalledWith([
{ role: 'user', content: 'Previous question', id: 'msg-1' },
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
], 'Latest question')
expect(createStreamCallbacksMock).toHaveBeenCalledWith(expect.objectContaining({
messageId: 'msg-stream',
vaultPath: '/vault',
setMessages: runtime.setMessages,
setStatus: runtime.setStatus,
}))
expect(streamAiAgentMock).toHaveBeenCalledWith({
agent: 'codex',
message: 'formatted:Latest question',
systemPrompt: 'OVERRIDE',
vaultPath: '/vault',
callbacks: { stream: 'callbacks' },
})
})
it('clears the conversation and resets runtime refs', () => {
const { runtime } = createRuntime([
{ id: 'msg-1', userMessage: 'Question', actions: [] },
], 'done')
clearAgentConversation(runtime)
expect(runtime.abortRef.current.aborted).toBe(true)
expect(runtime.responseAccRef.current).toBe('')
expect(runtime.toolInputMapRef.current.size).toBe(0)
expect(runtime.setMessages).toHaveBeenCalledWith([])
expect(runtime.setStatus).toHaveBeenCalledWith('idle')
})
})

View File

@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatus } from '../hooks/useAiAgent'
import type { AiAgentMessage } from './aiAgentConversation'
const { detectFileOperationMock } = vi.hoisted(() => ({
detectFileOperationMock: vi.fn(),
}))
vi.mock('../hooks/useAiAgent', () => ({
detectFileOperation: detectFileOperationMock,
}))
import { createStreamCallbacks } from './aiAgentStreamCallbacks'
function createMessageStore(initialMessages: AiAgentMessage[]) {
let messages = initialMessages
return {
getMessages: () => messages,
setMessages: (next: AiAgentMessage[] | ((current: AiAgentMessage[]) => AiAgentMessage[])) => {
messages = typeof next === 'function' ? next(messages) : next
},
}
}
function createStatusStore(initialStatus: AgentStatus = 'idle') {
let status = initialStatus
return {
getStatus: () => status,
setStatus: (next: AgentStatus | ((current: AgentStatus) => AgentStatus)) => {
status = typeof next === 'function' ? next(status) : next
},
}
}
describe('aiAgentStreamCallbacks', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('handles the happy-path lifecycle and refreshes the vault at the end', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Question',
actions: [],
isStreaming: true,
},
])
const status = createStatusStore()
const fileCallbacks = { onVaultChanged: vi.fn() }
const responseAccRef = { current: '' }
const toolInputMapRef = { current: new Map<string, { tool: string; input?: string }>() }
const callbacks = createStreamCallbacks({
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: status.setStatus,
abortRef: { current: { aborted: false } },
responseAccRef,
toolInputMapRef,
fileCallbacksRef: { current: fileCallbacks },
})
callbacks.onThinking('step 1')
callbacks.onText('Hello')
callbacks.onToolStart('Write', 'tool-1', '{"path":"/vault/note.md"}')
callbacks.onToolStart('Write', 'tool-1')
callbacks.onToolDone('tool-1', 'saved')
callbacks.onDone()
expect(status.getStatus()).toBe('done')
expect(responseAccRef.current).toBe('Hello')
expect(toolInputMapRef.current.get('tool-1')).toEqual({
tool: 'Write',
input: '{"path":"/vault/note.md"}',
})
expect(detectFileOperationMock).toHaveBeenCalledWith(
'Write',
'{"path":"/vault/note.md"}',
'/vault',
fileCallbacks,
)
expect(fileCallbacks.onVaultChanged).toHaveBeenCalledTimes(1)
expect(messages.getMessages()).toEqual([
{
id: 'msg-1',
userMessage: 'Question',
actions: [{
tool: 'Write',
toolId: 'tool-1',
label: 'Wrote file',
status: 'done',
input: '{"path":"/vault/note.md"}',
output: 'saved',
}],
isStreaming: false,
reasoning: 'step 1',
reasoningDone: true,
response: 'Hello',
},
])
})
it('marks pending actions as failed when the stream errors', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Question',
actions: [{
tool: 'Bash',
toolId: 'tool-1',
label: 'Ran shell command',
status: 'pending',
}],
isStreaming: true,
},
])
const status = createStatusStore('thinking')
const responseAccRef = { current: 'Partial reply' }
const callbacks = createStreamCallbacks({
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: status.setStatus,
abortRef: { current: { aborted: false } },
responseAccRef,
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: undefined },
})
callbacks.onError('boom')
expect(status.getStatus()).toBe('error')
expect(messages.getMessages()).toEqual([
{
id: 'msg-1',
userMessage: 'Question',
actions: [{
tool: 'Bash',
toolId: 'tool-1',
label: 'Ran shell command',
status: 'error',
}],
isStreaming: false,
reasoningDone: true,
response: 'Partial reply\n\nError: boom',
},
])
})
it('ignores stream events after the request has been aborted', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: 'Question',
actions: [],
isStreaming: true,
},
])
const status = createStatusStore('thinking')
const fileCallbacks = { onVaultChanged: vi.fn() }
const callbacks = createStreamCallbacks({
messageId: 'msg-1',
vaultPath: '/vault',
setMessages: messages.setMessages,
setStatus: status.setStatus,
abortRef: { current: { aborted: true } },
responseAccRef: { current: '' },
toolInputMapRef: { current: new Map() },
fileCallbacksRef: { current: fileCallbacks },
})
callbacks.onThinking('ignored')
callbacks.onText('ignored')
callbacks.onToolStart('Write', 'tool-1', '{"path":"/vault/note.md"}')
callbacks.onToolDone('tool-1', 'saved')
callbacks.onError('boom')
callbacks.onDone()
expect(status.getStatus()).toBe('thinking')
expect(messages.getMessages()[0]).toEqual({
id: 'msg-1',
userMessage: 'Question',
actions: [],
isStreaming: true,
})
expect(fileCallbacks.onVaultChanged).not.toHaveBeenCalled()
expect(detectFileOperationMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
async function loadHandlers() {
vi.resetModules()
return import('./mock-handlers')
}
describe('mockHandlers coverage', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('renames a note, updates its frontmatter title, and rewrites backlinks', async () => {
const { mockHandlers } = await loadHandlers()
const vaultPath = '/Users/mock/Test Vault'
const oldPath = `${vaultPath}/old-note.md`
const referencePath = `${vaultPath}/reference.md`
mockHandlers.save_note_content({
path: oldPath,
content: '---\ntitle: Old Note\n---\n\n# Old Note',
})
mockHandlers.save_note_content({
path: referencePath,
content: 'See [[Old Note]] and [[old-note]].',
})
const result = mockHandlers.rename_note({
vault_path: vaultPath,
old_path: oldPath,
new_title: 'New Title',
old_title: 'Old Note',
})
const updatedContent = mockHandlers.get_all_content() as Record<string, string>
expect(result).toEqual({
new_path: `${vaultPath}/new-title.md`,
updated_files: 1,
})
expect(updatedContent[`${vaultPath}/new-title.md`]).toContain('title: New Title')
expect(updatedContent[referencePath]).toBe('See [[new-title]] and [[new-title]].')
})
it('treats an unchanged title as a no-op rename', async () => {
const { mockHandlers } = await loadHandlers()
const vaultPath = '/Users/mock/Test Vault'
const notePath = `${vaultPath}/same-title.md`
mockHandlers.save_note_content({
path: notePath,
content: '---\ntitle: Same Title\n---\n',
})
expect(mockHandlers.rename_note({
vault_path: vaultPath,
old_path: notePath,
new_title: 'Same Title',
old_title: 'Same Title',
})).toEqual({
new_path: notePath,
updated_files: 0,
})
})
it('validates filename-only renames and blocks collisions', async () => {
const { mockHandlers } = await loadHandlers()
const vaultPath = '/Users/mock/Test Vault'
const sourcePath = `${vaultPath}/draft.md`
mockHandlers.save_note_content({
path: sourcePath,
content: '# Draft',
})
mockHandlers.save_note_content({
path: `${vaultPath}/duplicate.md`,
content: '# Existing',
})
expect(() => mockHandlers.rename_note_filename({
vault_path: vaultPath,
old_path: sourcePath,
new_filename_stem: ' ',
})).toThrow('Invalid filename')
expect(() => mockHandlers.rename_note_filename({
vault_path: vaultPath,
old_path: sourcePath,
new_filename_stem: 'duplicate',
})).toThrow('A note with that name already exists')
})
it('tracks saved files, deduplicates modified-file listings, and clears them on commit', async () => {
const { mockHandlers } = await loadHandlers()
mockHandlers.save_note_content({
path: '/Users/luca/Laputa/26q1-laputa-app.md',
content: '# Updated project note',
})
mockHandlers.save_note_content({
path: '/Users/luca/Laputa/new-note.md',
content: '# New note',
})
const modifiedBeforeCommit = mockHandlers.get_modified_files()
const basePathCount = modifiedBeforeCommit.filter((entry) => entry.path === '/Users/luca/Laputa/26q1-laputa-app.md').length
expect(basePathCount).toBe(1)
expect(modifiedBeforeCommit.some((entry) => entry.path === '/Users/luca/Laputa/new-note.md')).toBe(true)
expect(mockHandlers.git_commit({ message: 'Save everything' })).toContain('6 files changed')
expect(mockHandlers.get_modified_files()).toEqual([])
})
it('searches mock content and slices pulse results to the requested limit', async () => {
const { mockHandlers } = await loadHandlers()
const projectPath = '/Users/luca/Laputa/26q1-laputa-app.md'
mockHandlers.save_note_content({
path: projectPath,
content: '# Project Plan\n\nStrategic coverage improvements',
})
const search = mockHandlers.search_vault({ query: 'strategic', mode: 'content' })
const pulse = mockHandlers.get_vault_pulse({ limit: 2 })
expect(search.query).toBe('strategic')
expect(search.results).toEqual([
expect.objectContaining({
path: projectPath,
title: 'Build Laputa App',
}),
])
expect(pulse).toHaveLength(2)
expect(pulse[0]?.shortHash).toBe('a1b2c3d')
})
it('applies setting defaults and keeps saved vault lists isolated from caller mutations', async () => {
const { mockHandlers } = await loadHandlers()
mockHandlers.save_settings({
settings: {
auto_pull_interval_minutes: undefined,
autogit_enabled: true,
autogit_idle_threshold_seconds: undefined,
autogit_inactive_threshold_seconds: undefined,
telemetry_consent: true,
crash_reporting_enabled: false,
analytics_enabled: true,
anonymous_id: 'anon-1',
release_channel: 'alpha',
default_ai_agent: 'codex',
},
})
expect(mockHandlers.get_settings()).toEqual({
auto_pull_interval_minutes: 5,
autogit_enabled: true,
autogit_idle_threshold_seconds: 90,
autogit_inactive_threshold_seconds: 30,
telemetry_consent: true,
crash_reporting_enabled: false,
analytics_enabled: true,
anonymous_id: 'anon-1',
release_channel: 'alpha',
default_ai_agent: 'codex',
})
const list = {
vaults: [{ label: 'Work', path: '/work' }],
active_vault: '/work',
}
mockHandlers.save_vault_list({ list })
const savedList = mockHandlers.load_vault_list()
savedList.vaults.push({ label: 'Leak', path: '/leak' })
expect(mockHandlers.load_vault_list()).toEqual({
vaults: [{ label: 'Work', path: '/work' }],
active_vault: '/work',
})
})
it('builds attachment paths for saved and copied images', async () => {
const { mockHandlers } = await loadHandlers()
vi.spyOn(Date, 'now').mockReturnValue(12345)
expect(mockHandlers.save_image({
vault_path: '/vault',
filename: 'diagram.png',
data: 'base64',
})).toBe('/vault/attachments/12345-diagram.png')
expect(mockHandlers.copy_image_to_vault({
vault_path: '/vault',
source_path: '/tmp/screenshot.jpg',
})).toBe('/vault/attachments/12345-screenshot.jpg')
})
})

View File

@@ -0,0 +1,183 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
async function loadHandlers() {
vi.resetModules()
return import('./mock-handlers')
}
describe('mockHandlers additional coverage', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns entry fallbacks, file history, diffs, and empty search results for empty queries', async () => {
const { mockHandlers } = await loadHandlers()
expect(mockHandlers.reload_vault_entry({ path: '/missing.md' })).toEqual(
expect.objectContaining({
path: '/missing.md',
title: 'Unknown',
filename: 'unknown.md',
}),
)
expect(mockHandlers.get_file_history({ path: '/vault/notes/strategy.md' })).toEqual(expect.arrayContaining([
expect.objectContaining({
shortHash: 'a1b2c3d',
message: 'Update strategy with latest changes',
}),
expect.objectContaining({
shortHash: 'm0n1o2p',
message: 'Create strategy',
}),
]))
expect(mockHandlers.get_file_diff({ path: '/vault/old-draft.md' })).toContain('deleted file mode 100644')
expect(mockHandlers.get_file_diff_at_commit({
path: '/vault/notes/strategy.md',
commitHash: 'abcdef1234567890',
})).toContain('Updated paragraph at commit abcdef1.')
expect(mockHandlers.search_vault({ query: '', mode: 'title' })).toEqual({
results: [],
elapsed_ms: 0,
query: '',
mode: 'title',
})
})
it('renames a filename successfully and rewrites wikilinks that target the old path stem', async () => {
const { mockHandlers } = await loadHandlers()
const vaultPath = '/Users/mock/Test Vault'
const sourcePath = `${vaultPath}/meeting-notes.md`
const backlinkPath = `${vaultPath}/backlinks.md`
mockHandlers.save_note_content({
path: sourcePath,
content: '# Meeting Notes',
})
mockHandlers.save_note_content({
path: backlinkPath,
content: 'Links: [[meeting-notes]] and [[Meeting Notes|alias]].',
})
expect(mockHandlers.rename_note_filename({
vault_path: vaultPath,
old_path: sourcePath,
new_filename_stem: 'weekly-notes',
})).toEqual({
new_path: `${vaultPath}/weekly-notes.md`,
updated_files: 1,
})
const content = mockHandlers.get_all_content() as Record<string, string>
expect(content[`${vaultPath}/weekly-notes.md`]).toBe('# Meeting Notes')
expect(content[backlinkPath]).toBe('Links: [[weekly-notes]] and [[Meeting Notes|alias]].')
})
it('tracks remote state through create, clone, and add-remote flows', async () => {
const { mockHandlers } = await loadHandlers()
const emptyVaultPath = '/Users/mock/Documents/Brand New Vault'
const clonedVaultPath = '/Users/mock/Documents/Cloned Vault'
expect(mockHandlers.git_remote_status({ vaultPath: emptyVaultPath })).toEqual({
branch: 'main',
ahead: 0,
behind: 0,
hasRemote: true,
})
expect(mockHandlers.create_empty_vault({ targetPath: emptyVaultPath })).toBe(emptyVaultPath)
expect(mockHandlers.git_remote_status({ vaultPath: emptyVaultPath })).toEqual({
branch: 'main',
ahead: 0,
behind: 0,
hasRemote: false,
})
expect(mockHandlers.git_add_remote({
request: { vault_path: emptyVaultPath, remoteUrl: 'https://example.test/repo.git' },
})).toEqual({
status: 'connected',
message: 'Remote connected. This vault now tracks origin/main.',
})
expect(mockHandlers.git_remote_status({ vault_path: emptyVaultPath })).toEqual({
branch: 'main',
ahead: 0,
behind: 0,
hasRemote: true,
})
expect(mockHandlers.create_getting_started_vault({ targetPath: clonedVaultPath })).toBe(clonedVaultPath)
expect(mockHandlers.git_remote_status({ vaultPath: clonedVaultPath })).toEqual({
branch: 'main',
ahead: 0,
behind: 0,
hasRemote: false,
})
expect(mockHandlers.clone_repo({
url: 'https://example.test/repo.git',
local_path: clonedVaultPath,
})).toBe(`Cloned to ${clonedVaultPath}`)
expect(mockHandlers.git_remote_status({ vaultPath: clonedVaultPath })).toEqual({
branch: 'main',
ahead: 0,
behind: 0,
hasRemote: true,
})
})
it('persists last-vault state, reports vault existence, and restores AI guidance state', async () => {
const { mockHandlers } = await loadHandlers()
expect(mockHandlers.get_last_vault_path()).toBe('/Users/mock/demo-vault-v2')
expect(mockHandlers.set_last_vault_path({ path: '/Users/mock/Documents/Work' })).toBeNull()
expect(mockHandlers.get_last_vault_path()).toBe('/Users/mock/Documents/Work')
expect(mockHandlers.check_vault_exists({ path: '/tmp/demo-vault-v2-copy' })).toBe(true)
expect(mockHandlers.check_vault_exists({ path: '/tmp/random-vault' })).toBe(false)
expect(mockHandlers.get_vault_ai_guidance_status()).toEqual({
agents_state: 'managed',
claude_state: 'managed',
can_restore: false,
})
expect(mockHandlers.restore_vault_ai_guidance()).toEqual({
agents_state: 'managed',
claude_state: 'managed',
can_restore: false,
})
expect(mockHandlers.repair_vault()).toBe('Vault repaired')
})
it('surfaces the simple command handlers for git, conflicts, trash, and telemetry', async () => {
const { mockHandlers } = await loadHandlers()
expect(mockHandlers.git_pull()).toEqual({
status: 'up_to_date',
message: 'Already up to date',
updatedFiles: [],
conflictFiles: [],
})
expect(mockHandlers.git_push()).toEqual({
status: 'ok',
message: 'Pushed to remote',
})
expect(mockHandlers.get_conflict_files()).toEqual([])
expect(mockHandlers.get_conflict_mode()).toBe('none')
expect(mockHandlers.purge_trash()).toEqual([])
expect(mockHandlers.empty_trash()).toEqual([])
expect(mockHandlers.delete_note({ path: '/vault/trash/me.md' })).toBe('/vault/trash/me.md')
expect(mockHandlers.batch_delete_notes({ paths: ['/a.md', '/b.md'] })).toEqual(['/a.md', '/b.md'])
expect(mockHandlers.batch_archive_notes({ paths: ['/a.md', '/b.md', '/c.md'] })).toBe(3)
expect(mockHandlers.batch_trash_notes({ paths: ['/a.md', '/b.md'] })).toBe(2)
expect(mockHandlers.migrate_is_a_to_type()).toBe(0)
expect(mockHandlers.register_mcp_tools()).toBe('registered')
expect(mockHandlers.check_mcp_status()).toBe('installed')
expect(mockHandlers.reinit_telemetry()).toBeNull()
expect(mockHandlers.stream_claude_chat()).toBe('mock-session')
expect(mockHandlers.stream_claude_agent()).toBeNull()
expect(mockHandlers.stream_ai_agent()).toBeNull()
})
})

View File

@@ -0,0 +1,243 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
import { allSelection, makeEntry } from '../test-utils/noteListTestUtils'
import {
clearListSortFromLocalStorage,
countInboxByPeriod,
extractSortableProperties,
filterEntries,
filterInboxEntries,
formatSearchSubtitle,
formatSubtitle,
getSortComparator,
getSortOptionLabel,
loadSortPreferences,
parseSortConfig,
relativeDate,
saveSortPreferences,
serializeSortConfig,
} from './noteListHelpers'
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', {
value: localStorageMock,
writable: true,
})
describe('noteListHelpers extra coverage', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-21T12:00:00Z'))
localStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
it('formats relative dates across future, recent, and older timestamps', () => {
const nowSeconds = Math.floor(Date.now() / 1000)
expect(relativeDate(nowSeconds + 86400)).toBe('Apr 22')
expect(relativeDate(nowSeconds - 30)).toBe('just now')
expect(relativeDate(nowSeconds - 5 * 60)).toBe('5m ago')
expect(relativeDate(nowSeconds - 2 * 3600)).toBe('2h ago')
expect(relativeDate(nowSeconds - 3 * 86400)).toBe('3d ago')
expect(relativeDate(nowSeconds - 10 * 86400)).toBe('Apr 11')
})
it('builds note subtitles for empty, linked, and edited notes', () => {
const modifiedEntry = makeEntry({
title: 'Project',
modifiedAt: Math.floor(Date.now() / 1000) - 3600,
createdAt: Math.floor(Date.now() / 1000) - 86400 * 2,
wordCount: 1200,
outgoingLinks: ['alpha', 'beta'],
})
const emptyEntry = makeEntry({
title: 'Empty',
modifiedAt: null,
createdAt: null,
wordCount: 0,
outgoingLinks: [],
})
expect(formatSubtitle(modifiedEntry)).toBe('1h ago · 1,200 words · 2 links')
expect(formatSubtitle(emptyEntry)).toBe('Empty')
expect(formatSearchSubtitle(modifiedEntry)).toBe('1h ago · Created 2d ago · 1,200 words · 2 links')
})
it('extracts sortable properties and labels custom property sort keys', () => {
const entries = [
makeEntry({ properties: { Priority: 'High', Owner: 'Luca' } }),
makeEntry({ properties: { Estimate: 3, Priority: 'Low' } }),
]
expect(extractSortableProperties(entries)).toEqual(['Estimate', 'Owner', 'Priority'])
expect(getSortOptionLabel('property:Priority')).toBe('Priority')
expect(getSortOptionLabel('title')).toBe('Title')
})
it('sorts entries by built-in and custom property comparators', () => {
const entries = [
makeEntry({
title: 'Gamma',
createdAt: 10,
modifiedAt: 30,
status: 'Done',
properties: { Score: 5, Start: '2026-04-18', Enabled: true },
}),
makeEntry({
title: 'Alpha',
createdAt: 20,
modifiedAt: 20,
status: 'Active',
properties: { Score: 2, Start: '2026-04-15', Enabled: false },
}),
makeEntry({
title: 'Beta',
createdAt: 15,
modifiedAt: 25,
status: null,
properties: { Score: 8, Start: 'not-a-date', Enabled: true },
}),
]
expect([...entries].sort(getSortComparator('title', 'asc')).map((entry) => entry.title)).toEqual(['Alpha', 'Beta', 'Gamma'])
expect([...entries].sort(getSortComparator('created', 'desc')).map((entry) => entry.title)).toEqual(['Alpha', 'Beta', 'Gamma'])
expect([...entries].sort(getSortComparator('status', 'asc')).map((entry) => entry.title)).toEqual(['Alpha', 'Gamma', 'Beta'])
expect([...entries].sort(getSortComparator('property:Score', 'asc')).map((entry) => entry.title)).toEqual(['Alpha', 'Gamma', 'Beta'])
expect([...entries].sort(getSortComparator('property:Start', 'asc')).map((entry) => entry.title)).toEqual(['Alpha', 'Gamma', 'Beta'])
expect([...entries].sort(getSortComparator('property:Enabled', 'asc')).map((entry) => entry.title)).toEqual(['Alpha', 'Gamma', 'Beta'])
})
it('serializes, parses, loads, and saves sort preferences with migration support', () => {
const serialized = serializeSortConfig({ option: 'property:Priority', direction: 'desc' })
expect(serialized).toBe('property:Priority:desc')
expect(parseSortConfig(serialized)).toEqual({ option: 'property:Priority', direction: 'desc' })
expect(parseSortConfig('broken')).toBeNull()
expect(parseSortConfig('title:sideways')).toBeNull()
localStorage.setItem(APP_STORAGE_KEYS.sortPreferences, JSON.stringify({
'__list__': 'title',
'type:Project': { option: 'created', direction: 'asc' },
}))
expect(loadSortPreferences()).toEqual({
'__list__': { option: 'title', direction: 'asc' },
'type:Project': { option: 'created', direction: 'asc' },
})
saveSortPreferences({
'__list__': { option: 'modified', direction: 'desc' },
})
expect(localStorage.getItem(APP_STORAGE_KEYS.sortPreferences)).toBe(JSON.stringify({
'__list__': { option: 'modified', direction: 'desc' },
}))
expect(localStorage.getItem(LEGACY_APP_STORAGE_KEYS.sortPreferences)).toBeNull()
clearListSortFromLocalStorage()
expect(localStorage.getItem(APP_STORAGE_KEYS.sortPreferences)).toBeNull()
expect(localStorage.getItem(LEGACY_APP_STORAGE_KEYS.sortPreferences)).toBeNull()
})
it('filters view, folder, favorites, and pulse selections', () => {
const entries = [
makeEntry({
path: '/vault/notes/alpha.md',
title: 'Alpha',
fileKind: 'markdown',
favorite: true,
}),
makeEntry({
path: '/vault/projects/beta.md',
title: 'Beta',
fileKind: 'markdown',
}),
makeEntry({
path: '/vault/attachments/diagram.png',
title: 'Diagram',
fileKind: 'binary',
}),
]
const views = [{
filename: 'work.view',
definition: {
name: 'Work',
icon: null,
color: null,
sort: null,
filters: {
all: [{ field: 'title', op: 'contains', value: 'Alpha' }],
},
},
}]
expect(filterEntries(entries, { kind: 'view', filename: 'work.view' }, undefined, views).map((entry) => entry.title)).toEqual(['Alpha'])
expect(filterEntries(entries, { kind: 'folder', path: 'projects' }).map((entry) => entry.title)).toEqual(['Beta'])
expect(filterEntries(entries, { kind: 'filter', filter: 'favorites' }).map((entry) => entry.title)).toEqual(['Alpha'])
expect(filterEntries(entries, { kind: 'filter', filter: 'pulse' })).toEqual([])
expect(filterEntries(entries, allSelection).map((entry) => entry.title)).toEqual(['Alpha', 'Beta'])
})
it('filters inbox entries by period and counts them', () => {
const nowSeconds = Math.floor(Date.now() / 1000)
const entries = [
makeEntry({
title: 'This Week',
organized: false,
archived: false,
isA: 'Note',
createdAt: nowSeconds - 2 * 86400,
}),
makeEntry({
title: 'This Month',
organized: false,
archived: false,
isA: 'Note',
createdAt: nowSeconds - 20 * 86400,
}),
makeEntry({
title: 'This Quarter',
organized: false,
archived: false,
isA: 'Note',
createdAt: nowSeconds - 80 * 86400,
}),
makeEntry({
title: 'Organized',
organized: true,
archived: false,
isA: 'Note',
createdAt: nowSeconds - 2 * 86400,
}),
makeEntry({
title: 'Type document',
organized: false,
archived: false,
isA: 'Type',
createdAt: nowSeconds - 2 * 86400,
}),
]
expect(filterInboxEntries(entries, 'week').map((entry) => entry.title)).toEqual(['This Week'])
expect(filterInboxEntries(entries, 'month').map((entry) => entry.title)).toEqual(['This Week', 'This Month'])
expect(filterInboxEntries(entries, 'quarter').map((entry) => entry.title)).toEqual(['This Week', 'This Month', 'This Quarter'])
expect(countInboxByPeriod(entries)).toEqual({
week: 1,
month: 2,
quarter: 3,
all: 3,
})
})
})

View File

@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
beginNoteOpenTrace,
failNoteOpenTrace,
finishNoteOpenTrace,
logKeyboardNavigationTrace,
markNoteOpenTrace,
} from './noteOpenPerformance'
const VITEST_WORKER_DESCRIPTOR = Object.getOwnPropertyDescriptor(globalThis, '__vitest_worker__')
describe('noteOpenPerformance additional coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
if (VITEST_WORKER_DESCRIPTOR) {
Reflect.deleteProperty(globalThis, '__vitest_worker__')
}
})
afterEach(() => {
if (VITEST_WORKER_DESCRIPTOR) {
Object.defineProperty(globalThis, '__vitest_worker__', VITEST_WORKER_DESCRIPTOR)
}
})
it('logs n/a durations and cache misses when optional marks are absent', () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
vi.spyOn(performance, 'now')
.mockReturnValueOnce(10)
.mockReturnValueOnce(35)
beginNoteOpenTrace('/vault/missing-stages.md', 'quick-open')
finishNoteOpenTrace('/vault/missing-stages.md')
expect(debugSpy).toHaveBeenCalledWith(
'[perf] noteOpen path=/vault/missing-stages.md source=quick-open total=25.0ms beforeNavigate=n/a contentLoad=n/a editorSwap=25.0ms cache=miss',
)
})
it('ignores trace updates when running under the vitest runtime flag', () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
Object.defineProperty(globalThis, '__vitest_worker__', {
configurable: true,
value: 'worker-1',
})
beginNoteOpenTrace('/vault/ignored.md', 'sidebar')
markNoteOpenTrace('/vault/ignored.md', 'cacheReady')
failNoteOpenTrace('/vault/ignored.md', 'ignored')
finishNoteOpenTrace('/vault/ignored.md')
logKeyboardNavigationTrace('down', 999, 12)
expect(debugSpy).not.toHaveBeenCalled()
})
it('does not log quiet keyboard traces when both thresholds stay below the cutoff', () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
logKeyboardNavigationTrace('down', 499, 3.9)
expect(debugSpy).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
beginNoteOpenTrace,
failNoteOpenTrace,
finishNoteOpenTrace,
logKeyboardNavigationTrace,
markNoteOpenTrace,
} from './noteOpenPerformance'
const VITEST_WORKER_DESCRIPTOR = Object.getOwnPropertyDescriptor(globalThis, '__vitest_worker__')
describe('noteOpenPerformance', () => {
beforeEach(() => {
vi.clearAllMocks()
if (VITEST_WORKER_DESCRIPTOR) {
Reflect.deleteProperty(globalThis, '__vitest_worker__')
}
})
afterEach(() => {
if (VITEST_WORKER_DESCRIPTOR) {
Object.defineProperty(globalThis, '__vitest_worker__', VITEST_WORKER_DESCRIPTOR)
}
})
it('logs a completed note-open trace with detailed stage timing', () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
const nowSpy = vi.spyOn(performance, 'now')
nowSpy
.mockReturnValueOnce(100)
.mockReturnValueOnce(120)
.mockReturnValueOnce(150)
.mockReturnValueOnce(170)
.mockReturnValueOnce(200)
.mockReturnValueOnce(245)
.mockReturnValueOnce(290)
beginNoteOpenTrace('/vault/note.md', 'sidebar')
markNoteOpenTrace('/vault/note.md', 'beforeNavigateStart')
markNoteOpenTrace('/vault/note.md', 'beforeNavigateEnd')
markNoteOpenTrace('/vault/note.md', 'cacheReady')
markNoteOpenTrace('/vault/note.md', 'contentLoadStart')
markNoteOpenTrace('/vault/note.md', 'contentLoadEnd')
finishNoteOpenTrace('/vault/note.md')
expect(debugSpy).toHaveBeenCalledWith(
'[perf] noteOpen path=/vault/note.md source=sidebar total=190.0ms beforeNavigate=30.0ms contentLoad=45.0ms editorSwap=45.0ms cache=hit',
)
})
it('logs when an in-flight note open is superseded by another one', () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
const nowSpy = vi.spyOn(performance, 'now')
nowSpy
.mockReturnValueOnce(10)
.mockReturnValueOnce(35)
.mockReturnValueOnce(55)
beginNoteOpenTrace('/vault/alpha.md', 'sidebar')
beginNoteOpenTrace('/vault/beta.md', 'search')
failNoteOpenTrace('/vault/beta.md', 'cleanup')
expect(debugSpy).toHaveBeenNthCalledWith(
1,
'[perf] noteOpen cancel path=/vault/alpha.md source=sidebar total=25.0ms reason=superseded',
)
expect(debugSpy).toHaveBeenNthCalledWith(
2,
'[perf] noteOpen cancel path=/vault/beta.md source=search total=20.0ms reason=cleanup',
)
})
it('only logs keyboard traces when the list is large or slow enough', () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
logKeyboardNavigationTrace('down', 100, 3)
logKeyboardNavigationTrace('up', 600, 5)
expect(debugSpy).toHaveBeenCalledTimes(1)
expect(debugSpy).toHaveBeenCalledWith(
'[perf] noteListKeyboard direction=up items=600 move=5.0ms',
)
})
})

View File

@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
getAiAgentDefinitionMock,
invokeMock,
isTauriState,
listenMock,
} = vi.hoisted(() => ({
getAiAgentDefinitionMock: vi.fn((agent: string) => ({
label: agent === 'codex' ? 'Codex' : 'Claude Code',
})),
invokeMock: vi.fn(),
isTauriState: { value: false },
listenMock: vi.fn(),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => isTauriState.value,
}))
vi.mock('../lib/aiAgents', () => ({
getAiAgentDefinition: getAiAgentDefinitionMock,
}))
vi.mock('@tauri-apps/api/core', () => ({
invoke: invokeMock,
}))
vi.mock('@tauri-apps/api/event', () => ({
listen: listenMock,
}))
import { streamAiAgent } from './streamAiAgent'
describe('streamAiAgent', () => {
beforeEach(() => {
vi.clearAllMocks()
isTauriState.value = false
})
afterEach(() => {
vi.useRealTimers()
})
it('uses the mock response when Tauri is unavailable', async () => {
vi.useFakeTimers()
const callbacks = {
onText: vi.fn(),
onThinking: vi.fn(),
onToolStart: vi.fn(),
onToolDone: vi.fn(),
onError: vi.fn(),
onDone: vi.fn(),
}
const promise = streamAiAgent({
agent: 'codex',
message: '<conversation_history>\n[user]: first\n\n[user]: latest\n</conversation_history>',
vaultPath: '/vault',
callbacks,
})
await vi.advanceTimersByTimeAsync(300)
await promise
expect(callbacks.onText).toHaveBeenCalledWith(
'[mock-codex turns=2] You asked: "latest" — This note is related to [[Build Laputa App]] and [[Matteo Cellini]].',
)
expect(callbacks.onDone).toHaveBeenCalledTimes(1)
expect(listenMock).not.toHaveBeenCalled()
expect(invokeMock).not.toHaveBeenCalled()
})
it('forwards streamed Tauri events and invokes the backend request', async () => {
isTauriState.value = true
const unlistenMock = vi.fn()
let eventHandler: ((event: { payload: unknown }) => void) | undefined
listenMock.mockImplementation(async (_eventName: string, handler: typeof eventHandler) => {
eventHandler = handler
return unlistenMock
})
invokeMock.mockImplementation(async () => {
eventHandler?.({ payload: { kind: 'Init', session_id: 'session-1' } })
eventHandler?.({ payload: { kind: 'ThinkingDelta', text: 'thinking...' } })
eventHandler?.({ payload: { kind: 'TextDelta', text: 'answer' } })
eventHandler?.({ payload: { kind: 'ToolStart', tool_name: 'Write', tool_id: 'tool-1', input: '{"path":"/vault/note.md"}' } })
eventHandler?.({ payload: { kind: 'ToolDone', tool_id: 'tool-1', output: 'saved' } })
eventHandler?.({ payload: { kind: 'Done' } })
return 'session-1'
})
const callbacks = {
onText: vi.fn(),
onThinking: vi.fn(),
onToolStart: vi.fn(),
onToolDone: vi.fn(),
onError: vi.fn(),
onDone: vi.fn(),
}
const promise = streamAiAgent({
agent: 'claude_code',
message: 'Explain this',
systemPrompt: 'SYSTEM',
vaultPath: '/vault',
callbacks,
})
await promise
expect(listenMock).toHaveBeenCalledWith('ai-agent-stream', expect.any(Function))
expect(invokeMock).toHaveBeenCalledWith('stream_ai_agent', {
request: {
agent: 'claude_code',
message: 'Explain this',
system_prompt: 'SYSTEM',
vault_path: '/vault',
},
})
expect(callbacks.onThinking).toHaveBeenCalledWith('thinking...')
expect(callbacks.onText).toHaveBeenCalledWith('answer')
expect(callbacks.onToolStart).toHaveBeenCalledWith('Write', 'tool-1', '{"path":"/vault/note.md"}')
expect(callbacks.onToolDone).toHaveBeenCalledWith('tool-1', 'saved')
expect(callbacks.onDone).toHaveBeenCalledTimes(1)
expect(unlistenMock).toHaveBeenCalledTimes(1)
})
it('surfaces backend invocation failures and still closes the stream', async () => {
isTauriState.value = true
const unlistenMock = vi.fn()
listenMock.mockResolvedValue(unlistenMock)
invokeMock.mockRejectedValue(new Error('backend boom'))
const callbacks = {
onText: vi.fn(),
onThinking: vi.fn(),
onToolStart: vi.fn(),
onToolDone: vi.fn(),
onError: vi.fn(),
onDone: vi.fn(),
}
await streamAiAgent({
agent: 'codex',
message: 'Explain this',
vaultPath: '/vault',
callbacks,
})
expect(callbacks.onError).toHaveBeenCalledWith('backend boom')
expect(callbacks.onDone).toHaveBeenCalledTimes(1)
expect(unlistenMock).toHaveBeenCalledTimes(1)
})
})