test: increase strategic coverage
This commit is contained in:
69
src/components/ConflictResolverModal.extra.test.tsx
Normal file
69
src/components/ConflictResolverModal.extra.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ConflictResolverModal } from './ConflictResolverModal'
|
||||
import type { ConflictFileState } from '../hooks/useConflictResolver'
|
||||
|
||||
function renderModal(fileStates: ConflictFileState[], overrides: Record<string, unknown> = {}) {
|
||||
const props = {
|
||||
open: true,
|
||||
fileStates,
|
||||
allResolved: true,
|
||||
committing: false,
|
||||
error: null,
|
||||
onResolveFile: vi.fn(),
|
||||
onOpenInEditor: vi.fn(),
|
||||
onCommit: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
render(<ConflictResolverModal {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
describe('ConflictResolverModal extra coverage', () => {
|
||||
it('supports keyboard navigation and actions across the focused file', () => {
|
||||
const props = renderModal([
|
||||
{ file: 'notes/project.md', resolution: null, resolving: false },
|
||||
{ file: 'notes/plan.md', resolution: null, resolving: false },
|
||||
])
|
||||
|
||||
const list = screen.getByTestId('conflict-file-list')
|
||||
|
||||
fireEvent.keyDown(list, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(list, { key: 'T' })
|
||||
expect(props.onResolveFile).toHaveBeenCalledWith('notes/plan.md', 'theirs')
|
||||
|
||||
fireEvent.keyDown(list, { key: 'ArrowUp' })
|
||||
fireEvent.keyDown(list, { key: 'k' })
|
||||
expect(props.onResolveFile).toHaveBeenCalledWith('notes/project.md', 'ours')
|
||||
|
||||
fireEvent.keyDown(list, { key: 'o' })
|
||||
expect(props.onOpenInEditor).toHaveBeenCalledWith('notes/project.md')
|
||||
|
||||
fireEvent.keyDown(list, { key: 'Enter' })
|
||||
expect(props.onCommit).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.keyDown(list, { key: 'Escape' })
|
||||
expect(props.onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores binary open shortcuts, resolving rows, and modifier-assisted actions', () => {
|
||||
const props = renderModal([
|
||||
{ file: 'images/photo.png', resolution: null, resolving: false },
|
||||
{ file: 'notes/plan.md', resolution: null, resolving: true },
|
||||
], { allResolved: false })
|
||||
|
||||
const list = screen.getByTestId('conflict-file-list')
|
||||
|
||||
fireEvent.keyDown(list, { key: 'o' })
|
||||
expect(props.onOpenInEditor).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.focus(screen.getByTestId('conflict-file-notes/plan.md'))
|
||||
fireEvent.keyDown(list, { key: 'K' })
|
||||
fireEvent.keyDown(list, { key: 'T', ctrlKey: true })
|
||||
|
||||
expect(props.onResolveFile).not.toHaveBeenCalled()
|
||||
expect(props.onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
119
src/components/ConflictResolverModal.keyboard.test.tsx
Normal file
119
src/components/ConflictResolverModal.keyboard.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ConflictFileState } from '../hooks/useConflictResolver'
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
open ? (
|
||||
<div data-testid="dialog-root">
|
||||
{children}
|
||||
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>close</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
DialogContent: ({
|
||||
children,
|
||||
onKeyDown,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
}) => (
|
||||
<div role="dialog" tabIndex={0} onKeyDown={onKeyDown}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
|
||||
}))
|
||||
|
||||
import { ConflictResolverModal } from './ConflictResolverModal'
|
||||
|
||||
function makeFileStates(overrides?: Partial<ConflictFileState>[]): ConflictFileState[] {
|
||||
const defaults: ConflictFileState[] = [
|
||||
{ file: 'notes/project.md', resolution: null, resolving: false },
|
||||
{ file: 'notes/plan.md', resolution: null, resolving: false },
|
||||
]
|
||||
if (!overrides) return defaults
|
||||
return defaults.map((state, index) => ({ ...state, ...(overrides[index] ?? {}) }))
|
||||
}
|
||||
|
||||
function renderModal(overrides: Partial<React.ComponentProps<typeof ConflictResolverModal>> = {}) {
|
||||
const props: React.ComponentProps<typeof ConflictResolverModal> = {
|
||||
open: true,
|
||||
fileStates: makeFileStates(),
|
||||
allResolved: false,
|
||||
committing: false,
|
||||
error: null,
|
||||
onResolveFile: vi.fn(),
|
||||
onOpenInEditor: vi.fn(),
|
||||
onCommit: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
render(<ConflictResolverModal {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
describe('ConflictResolverModal keyboard behavior', () => {
|
||||
it('navigates rows with the keyboard and routes shortcuts to the focused file', () => {
|
||||
const props = renderModal()
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(dialog, { key: 'k' })
|
||||
expect(props.onResolveFile).toHaveBeenCalledWith('notes/plan.md', 'ours')
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'ArrowUp' })
|
||||
fireEvent.keyDown(dialog, { key: 'T' })
|
||||
expect(props.onResolveFile).toHaveBeenCalledWith('notes/project.md', 'theirs')
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'o' })
|
||||
expect(props.onOpenInEditor).toHaveBeenCalledWith('notes/project.md')
|
||||
})
|
||||
|
||||
it('commits on Enter when all files are resolved and closes through Escape and open-change callbacks', () => {
|
||||
const props = renderModal({ allResolved: true })
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Enter' })
|
||||
expect(props.onCommit).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByTestId('dialog-close'))
|
||||
expect(props.onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores shortcut variants that should be blocked', () => {
|
||||
const props = renderModal({
|
||||
fileStates: [{ file: 'images/photo.png', resolution: null, resolving: true }],
|
||||
})
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'k' })
|
||||
fireEvent.keyDown(dialog, { key: 'o' })
|
||||
fireEvent.keyDown(dialog, { key: 't', metaKey: true })
|
||||
|
||||
expect(props.onResolveFile).not.toHaveBeenCalled()
|
||||
expect(props.onOpenInEditor).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render as rtlRender, screen, fireEvent } from '@testing-library/react'
|
||||
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
||||
import { TooltipProvider } from './ui/tooltip'
|
||||
import type { ReferencedByItem } from './InspectorPanels'
|
||||
@@ -343,6 +343,15 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
|
||||
})
|
||||
|
||||
it('clicks a search result to add the relationship and close the inline editor', () => {
|
||||
renderEditableRelationships()
|
||||
openInlineAdd('AI')
|
||||
fireEvent.click(screen.getByText('AI'))
|
||||
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
|
||||
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not add duplicate refs', () => {
|
||||
renderEditableRelationships({ frontmatter: { 'Belongs to': ['[[topic/ai]]'] } })
|
||||
const input = openInlineAdd('AI')
|
||||
@@ -350,6 +359,15 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
expect(onUpdateProperty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores empty inline-add Enter presses', () => {
|
||||
renderEditableRelationships()
|
||||
const input = openInlineAdd()
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onUpdateProperty).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes inline add on Escape', () => {
|
||||
renderEditableRelationships()
|
||||
const input = openInlineAdd()
|
||||
@@ -455,6 +473,40 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits from the relationship-name input on Enter and cancels the form from the note input on Escape', () => {
|
||||
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
|
||||
fireEvent.click(screen.getByText('+ Add relationship'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Relationship name'), { key: 'Enter' })
|
||||
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[topic/ai]]')
|
||||
|
||||
fireEvent.click(screen.getByText('+ Add relationship'))
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('Note title'), { key: 'Escape' })
|
||||
expect(screen.queryByPlaceholderText('Relationship name')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the target dropdown after blur once the grace timeout expires', () => {
|
||||
vi.useFakeTimers()
|
||||
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
|
||||
fireEvent.click(screen.getByText('+ Add relationship'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
|
||||
|
||||
const noteInput = screen.getByPlaceholderText('Note title')
|
||||
fireEvent.focus(noteInput)
|
||||
fireEvent.change(noteInput, { target: { value: 'New Person' } })
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
fireEvent.blur(noteInput)
|
||||
vi.advanceTimersByTime(150)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('creates note and adds relationship via form', async () => {
|
||||
renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote })
|
||||
fireEvent.click(screen.getByText('+ Add relationship'))
|
||||
|
||||
325
src/components/PropertyValueCells.extra.test.tsx
Normal file
325
src/components/PropertyValueCells.extra.test.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import { DisplayModeSelector, SmartPropertyValueCell } from './PropertyValueCells'
|
||||
|
||||
const { createValueButtonMock } = vi.hoisted(() => ({
|
||||
createValueButtonMock:
|
||||
(testId: string, nextValue: (value: string) => string) =>
|
||||
({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
|
||||
<button data-testid={testId} onClick={() => onSave(nextValue(value))}>
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./EditableValue', () => ({
|
||||
EditableValue: createValueButtonMock('editable-value', (value) => `${value}-saved`),
|
||||
TagPillList: ({
|
||||
items,
|
||||
label,
|
||||
onSave,
|
||||
}: {
|
||||
items: string[]
|
||||
label: string
|
||||
onSave: (items: string[]) => void
|
||||
}) => (
|
||||
<button data-testid="tag-pill-list" onClick={() => onSave([...items, 'omega'])}>
|
||||
{label}:{items.join(',')}
|
||||
</button>
|
||||
),
|
||||
UrlValue: createValueButtonMock('url-value', (value) => `${value}/updated`),
|
||||
}))
|
||||
|
||||
vi.mock('./StatusDropdown', () => ({
|
||||
StatusDropdown: ({
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
onSave: (value: string) => void
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<div>
|
||||
<button data-testid="status-save" onClick={() => onSave('Done')}>save</button>
|
||||
<button data-testid="status-cancel" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./TagsDropdown', () => ({
|
||||
TagsDropdown: ({
|
||||
onToggle,
|
||||
onClose,
|
||||
}: {
|
||||
onToggle: (tag: string) => void
|
||||
onClose: () => void
|
||||
}) => (
|
||||
<div data-testid="tags-dropdown">
|
||||
<button data-testid="tags-toggle-alpha" onClick={() => onToggle('alpha')}>alpha</button>
|
||||
<button data-testid="tags-toggle-beta" onClick={() => onToggle('beta')}>beta</button>
|
||||
<button data-testid="tags-close" onClick={onClose}>close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./ColorInput', () => ({
|
||||
ColorEditableValue: createValueButtonMock('color-value', (value) => value.toUpperCase()),
|
||||
}))
|
||||
|
||||
vi.mock('./IconEditableValue', () => ({
|
||||
IconEditableValue: createValueButtonMock('icon-value', (value) => `${value}-icon`),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/calendar', () => ({
|
||||
Calendar: ({ onSelect }: { onSelect: (value?: Date) => void }) => (
|
||||
<div>
|
||||
<button data-testid="date-picker-calendar" onClick={() => onSelect(new Date(2026, 3, 23))}>
|
||||
pick
|
||||
</button>
|
||||
<button data-testid="date-picker-empty" onClick={() => onSelect(undefined)}>
|
||||
empty
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({
|
||||
children,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
<button data-testid="popover-close" onClick={() => onOpenChange?.(false)}>close</button>
|
||||
</div>
|
||||
),
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function makeRect(right: number, bottom: number): DOMRect {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 24,
|
||||
height: 24,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right,
|
||||
bottom,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
describe('PropertyValueCells extra', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes relationship property keys and positions the display-mode menu within the viewport', () => {
|
||||
const rectSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue(makeRect(100, 20))
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<DisplayModeSelector
|
||||
propKey=" Belongs-To "
|
||||
currentMode="text"
|
||||
autoMode="text"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('display-mode-trigger'))
|
||||
|
||||
expect(screen.getByTestId('display-mode-icon-relationship')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('display-mode-menu').style.left).toBe('8px')
|
||||
expect(screen.getByTestId('display-mode-menu').style.top).toBe('24px')
|
||||
|
||||
const backdrop = Array.from(document.body.querySelectorAll('div')).find(
|
||||
(node) => node.className === 'fixed inset-0 z-[12000]',
|
||||
)
|
||||
fireEvent.click(backdrop as HTMLDivElement)
|
||||
expect(screen.queryByTestId('display-mode-menu')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('display-mode-trigger'))
|
||||
fireEvent.click(screen.getByTestId('display-mode-option-date'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(' Belongs-To ', 'date')
|
||||
rectSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('closes empty date pickers without saving and ignores undefined selections', () => {
|
||||
const onSave = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
|
||||
render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Due"
|
||||
value=""
|
||||
displayMode="date"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('date-picker-empty'))
|
||||
fireEvent.click(screen.getByTestId('popover-close'))
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(onStartEdit).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('renders number displays, restores invalid input on escape, and falls back to editable text', () => {
|
||||
const onSave = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Count"
|
||||
value="12"
|
||||
displayMode="number"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('number-display'))
|
||||
expect(onStartEdit).toHaveBeenCalledWith('Count')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Count"
|
||||
value="12"
|
||||
displayMode="number"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('number-input'), { target: { value: 'oops' } })
|
||||
fireEvent.keyDown(screen.getByTestId('number-input'), { key: 'Escape' })
|
||||
|
||||
expect(onStartEdit).toHaveBeenCalledWith(null)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Title"
|
||||
value="Plain text"
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('editable-value'))
|
||||
expect(onSave).toHaveBeenCalledWith('Title', 'Plain text-saved')
|
||||
})
|
||||
|
||||
it('handles string booleans, tag toggles, and tag removals', () => {
|
||||
const onSaveList = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Published"
|
||||
value="false"
|
||||
displayMode="boolean"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={vi.fn()}
|
||||
onSaveList={onSaveList}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
const checkbox = screen.getByRole('checkbox') as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(false)
|
||||
|
||||
fireEvent.click(checkbox)
|
||||
expect(onUpdate).toHaveBeenCalledWith('Published', true)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Archived"
|
||||
value={0}
|
||||
displayMode="boolean"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={vi.fn()}
|
||||
onSaveList={onSaveList}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Tags"
|
||||
value={['alpha']}
|
||||
displayMode="tags"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={['alpha', 'beta']}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={vi.fn()}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('tags-toggle-alpha'))
|
||||
fireEvent.click(screen.getByTitle('Remove alpha'))
|
||||
fireEvent.click(screen.getByTestId('tags-add-button'))
|
||||
fireEvent.click(screen.getByTestId('tags-toggle-beta'))
|
||||
|
||||
expect(onSaveList).toHaveBeenCalledWith('Tags', [])
|
||||
expect(onStartEdit).toHaveBeenCalledWith('Tags')
|
||||
expect(onSaveList).toHaveBeenCalledWith('Tags', ['alpha', 'beta'])
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Category"
|
||||
value="solo"
|
||||
displayMode="tags"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={['solo', 'beta']}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={vi.fn()}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('tags-toggle-beta'))
|
||||
|
||||
expect(onSaveList).toHaveBeenCalledWith('Category', ['solo', 'beta'])
|
||||
})
|
||||
})
|
||||
390
src/components/PropertyValueCells.test.tsx
Normal file
390
src/components/PropertyValueCells.test.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { createDropdownModule, createPrimarySecondaryActions } = vi.hoisted(() => {
|
||||
const renderMockActionMenu = (
|
||||
containerTestId: string,
|
||||
actions: Array<{ testId: string; label: string; onClick: () => void }>,
|
||||
) => (
|
||||
<div data-testid={containerTestId}>
|
||||
{actions.map(({ testId, label, onClick }) => (
|
||||
<button key={testId} data-testid={testId} onClick={onClick}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const createDropdownModule = (
|
||||
exportName: string,
|
||||
containerTestId: string,
|
||||
createActions: (callbacks: Record<string, (...args: never[]) => void>) => Array<{
|
||||
testId: string
|
||||
label: string
|
||||
onClick: () => void
|
||||
}>,
|
||||
) => ({
|
||||
[exportName]: (callbacks: Record<string, (...args: never[]) => void>) =>
|
||||
renderMockActionMenu(containerTestId, createActions(callbacks)),
|
||||
})
|
||||
|
||||
const createPrimarySecondaryActions = (options: {
|
||||
primary: { testId: string; label: string; onClick: () => void }
|
||||
secondary: { testId: string; label: string; onClick: () => void }
|
||||
}) => [options.primary, options.secondary]
|
||||
|
||||
return { createDropdownModule, createPrimarySecondaryActions }
|
||||
})
|
||||
|
||||
vi.mock('./EditableValue', () => ({
|
||||
EditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
|
||||
<button data-testid="editable-value" onClick={() => onSave(`${value}-saved`)}>
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
TagPillList: ({
|
||||
items,
|
||||
label,
|
||||
onSave,
|
||||
}: {
|
||||
items: string[]
|
||||
label: string
|
||||
onSave: (items: string[]) => void
|
||||
}) => (
|
||||
<button data-testid="tag-pill-list" onClick={() => onSave([...items, 'gamma'])}>
|
||||
{label}:{items.join(',')}
|
||||
</button>
|
||||
),
|
||||
UrlValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
|
||||
<button data-testid="url-value" onClick={() => onSave('https://saved.example')}>
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./StatusDropdown', () =>
|
||||
createDropdownModule('StatusDropdown', 'status-dropdown', ({ onSave, onCancel }) =>
|
||||
createPrimarySecondaryActions({
|
||||
primary: { testId: 'status-save', label: 'save', onClick: () => onSave('Done') },
|
||||
secondary: { testId: 'status-cancel', label: 'cancel', onClick: onCancel },
|
||||
})),
|
||||
)
|
||||
|
||||
vi.mock('./TagsDropdown', () =>
|
||||
createDropdownModule('TagsDropdown', 'tags-dropdown', ({ onToggle, onClose }) =>
|
||||
createPrimarySecondaryActions({
|
||||
primary: { testId: 'tags-toggle', label: 'toggle', onClick: () => onToggle('beta') },
|
||||
secondary: { testId: 'tags-close', label: 'close', onClick: onClose },
|
||||
})),
|
||||
)
|
||||
|
||||
vi.mock('./ColorInput', () => ({
|
||||
ColorEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
|
||||
<button data-testid="color-value" onClick={() => onSave('#00ff00')}>
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./IconEditableValue', () => ({
|
||||
IconEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => (
|
||||
<button data-testid="icon-value" onClick={() => onSave('sparkles')}>
|
||||
{value}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/calendar', () => ({
|
||||
Calendar: ({ onSelect }: { onSelect: (value: Date) => void }) => (
|
||||
<button
|
||||
data-testid="date-picker-calendar"
|
||||
onClick={() => onSelect(new Date(2026, 3, 22))}
|
||||
>
|
||||
pick
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
PopoverContent: ({
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
|
||||
}))
|
||||
|
||||
import { DisplayModeSelector, SmartPropertyValueCell } from './PropertyValueCells'
|
||||
|
||||
describe('PropertyValueCells', () => {
|
||||
it('shows the relationship icon and resets to auto mode when the auto option is selected', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<DisplayModeSelector
|
||||
propKey="Related to"
|
||||
currentMode="text"
|
||||
autoMode="number"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('display-mode-icon-relationship')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('display-mode-trigger'))
|
||||
fireEvent.click(screen.getByTestId('display-mode-option-number'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('Related to', null)
|
||||
})
|
||||
|
||||
it('selects explicit display modes from the menu', () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<DisplayModeSelector
|
||||
propKey="Status"
|
||||
currentMode="status"
|
||||
autoMode="text"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('display-mode-trigger'))
|
||||
fireEvent.click(screen.getByTestId('display-mode-option-date'))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('Status', 'date')
|
||||
})
|
||||
|
||||
it('handles status and tags editing interactions', () => {
|
||||
const onSave = vi.fn()
|
||||
const onSaveList = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Status"
|
||||
value="Doing"
|
||||
displayMode="status"
|
||||
isEditing={true}
|
||||
vaultStatuses={['Doing', 'Done']}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-save'))
|
||||
fireEvent.click(screen.getByTestId('status-cancel'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith('Status', 'Done')
|
||||
expect(onStartEdit).toHaveBeenCalledWith(null)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Tags"
|
||||
value={['alpha']}
|
||||
displayMode="tags"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={['alpha', 'beta']}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Remove alpha'))
|
||||
fireEvent.click(screen.getByTestId('tags-add-button'))
|
||||
fireEvent.click(screen.getByTestId('tags-toggle'))
|
||||
fireEvent.click(screen.getByTestId('tags-close'))
|
||||
|
||||
expect(onSaveList).toHaveBeenCalledWith('Tags', [])
|
||||
expect(onStartEdit).toHaveBeenCalledWith('Tags')
|
||||
expect(onSaveList).toHaveBeenCalledWith('Tags', ['alpha', 'beta'])
|
||||
expect(onStartEdit).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('covers number and date editing branches', () => {
|
||||
const onSave = vi.fn()
|
||||
const onSaveList = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Count"
|
||||
value="12"
|
||||
displayMode="number"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '' } })
|
||||
fireEvent.blur(screen.getByTestId('number-input'))
|
||||
expect(onSave).toHaveBeenCalledWith('Count', '')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Count"
|
||||
value="12"
|
||||
displayMode="number"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '19' } })
|
||||
fireEvent.keyDown(screen.getByTestId('number-input'), { key: 'Enter' })
|
||||
expect(onSave).toHaveBeenCalledWith('Count', '19')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Count"
|
||||
value="12"
|
||||
displayMode="number"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('number-input'), { target: { value: 'nope' } })
|
||||
fireEvent.blur(screen.getByTestId('number-input'))
|
||||
expect(onStartEdit).toHaveBeenCalledWith(null)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Due"
|
||||
value="2026-04-20"
|
||||
displayMode="date"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('date-picker-calendar'))
|
||||
expect(onSave).toHaveBeenCalledWith('Due', '2026-04-22')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Due"
|
||||
value="2026-04-20"
|
||||
displayMode="date"
|
||||
isEditing={true}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('date-picker-clear'))
|
||||
expect(onSave).toHaveBeenCalledWith('Due', '')
|
||||
})
|
||||
|
||||
it('auto-detects scalar display modes and delegates array values correctly', () => {
|
||||
const onSave = vi.fn()
|
||||
const onSaveList = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Flag"
|
||||
value={true}
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
expect(onUpdate).toHaveBeenCalledWith('Flag', false)
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Website"
|
||||
value="https://example.com"
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('url-value'))
|
||||
expect(onSave).toHaveBeenCalledWith('Website', 'https://saved.example')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Accent"
|
||||
value="#ff0000"
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('color-value'))
|
||||
expect(onSave).toHaveBeenCalledWith('Accent', '#00ff00')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="_icon"
|
||||
value="spark"
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('icon-value'))
|
||||
expect(onSave).toHaveBeenCalledWith('_icon', 'sparkles')
|
||||
|
||||
rerender(
|
||||
<SmartPropertyValueCell
|
||||
propKey="Labels"
|
||||
value={['alpha', 'beta']}
|
||||
displayMode="text"
|
||||
isEditing={false}
|
||||
vaultStatuses={[]}
|
||||
vaultTags={[]}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('tag-pill-list'))
|
||||
expect(onSaveList).toHaveBeenCalledWith('Labels', ['alpha', 'beta', 'gamma'])
|
||||
})
|
||||
})
|
||||
315
src/components/RawEditorView.behavior.test.tsx
Normal file
315
src/components/RawEditorView.behavior.test.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MutableRefObject } from 'react'
|
||||
|
||||
const {
|
||||
buildRawEditorAutocompleteStateMock,
|
||||
buildRawEditorBaseItemsMock,
|
||||
buildTypeEntryMapMock,
|
||||
detectYamlErrorMock,
|
||||
extractWikilinkQueryMock,
|
||||
getRawEditorDropdownPositionMock,
|
||||
noteSearchListState,
|
||||
replaceActiveWikilinkQueryMock,
|
||||
trackEventMock,
|
||||
useCodeMirrorMock,
|
||||
viewRefState,
|
||||
} = vi.hoisted(() => ({
|
||||
buildRawEditorAutocompleteStateMock: vi.fn(),
|
||||
buildRawEditorBaseItemsMock: vi.fn(),
|
||||
buildTypeEntryMapMock: vi.fn(),
|
||||
detectYamlErrorMock: vi.fn(),
|
||||
extractWikilinkQueryMock: vi.fn(),
|
||||
getRawEditorDropdownPositionMock: vi.fn(),
|
||||
noteSearchListState: { lastProps: null as null | Record<string, unknown> },
|
||||
replaceActiveWikilinkQueryMock: vi.fn(),
|
||||
trackEventMock: vi.fn(),
|
||||
useCodeMirrorMock: vi.fn(),
|
||||
viewRefState: { current: null as null | Record<string, unknown> },
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/useCodeMirror', () => ({
|
||||
useCodeMirror: useCodeMirrorMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/rawEditorUtils', () => ({
|
||||
buildRawEditorAutocompleteState: buildRawEditorAutocompleteStateMock,
|
||||
buildRawEditorBaseItems: buildRawEditorBaseItemsMock,
|
||||
detectYamlError: detectYamlErrorMock,
|
||||
extractWikilinkQuery: extractWikilinkQueryMock,
|
||||
getRawEditorDropdownPosition: getRawEditorDropdownPositionMock,
|
||||
replaceActiveWikilinkQuery: replaceActiveWikilinkQueryMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/typeColors', () => ({
|
||||
buildTypeEntryMap: buildTypeEntryMapMock,
|
||||
}))
|
||||
|
||||
vi.mock('../lib/telemetry', () => ({
|
||||
trackEvent: trackEventMock,
|
||||
}))
|
||||
|
||||
vi.mock('./NoteSearchList', () => ({
|
||||
NoteSearchList: (props: {
|
||||
items: Array<{ title: string; onItemClick: () => void }>
|
||||
onItemClick: (item: { title: string; onItemClick: () => void }) => void
|
||||
onItemHover: (index: number) => void
|
||||
selectedIndex: number
|
||||
}) => {
|
||||
noteSearchListState.lastProps = props
|
||||
return (
|
||||
<div data-testid="note-search-list">
|
||||
{props.items.map((item, index) => (
|
||||
<button
|
||||
key={item.title}
|
||||
data-testid={`note-search-item-${index}`}
|
||||
onMouseEnter={() => props.onItemHover(index)}
|
||||
onClick={() => props.onItemClick(item)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
<div data-testid="note-search-selected-index">{props.selectedIndex}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
|
||||
function entry(title: string, path = `/vault/note/${title}.md`) {
|
||||
return {
|
||||
path,
|
||||
filename: `${title}.md`,
|
||||
title,
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: null,
|
||||
createdAt: null,
|
||||
fileSize: 0,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
function createMockView(docText = '[[Target') {
|
||||
return {
|
||||
state: {
|
||||
doc: { toString: () => docText },
|
||||
selection: { main: { head: docText.length } },
|
||||
},
|
||||
dispatch: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('RawEditorView behavior coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
noteSearchListState.lastProps = null
|
||||
viewRefState.current = createMockView()
|
||||
useCodeMirrorMock.mockImplementation((_containerRef: unknown, _content: string, callbacks: unknown) => {
|
||||
useCodeMirrorMock.mock.calls[useCodeMirrorMock.mock.calls.length - 1]![2] = callbacks
|
||||
return viewRefState
|
||||
})
|
||||
buildRawEditorBaseItemsMock.mockReturnValue([{ title: 'Base item' }])
|
||||
buildTypeEntryMapMock.mockReturnValue({ Note: { title: 'Note' } })
|
||||
detectYamlErrorMock.mockImplementation((doc: string) => (
|
||||
doc.includes('broken') ? 'Broken YAML' : null
|
||||
))
|
||||
extractWikilinkQueryMock.mockReturnValue(null)
|
||||
getRawEditorDropdownPositionMock.mockReturnValue({ top: 12, left: 34 })
|
||||
replaceActiveWikilinkQueryMock.mockReturnValue({
|
||||
text: '[[Inserted]]',
|
||||
cursor: 12,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('debounces content changes, exposes the latest content ref, flushes saves, and cleans up on unmount', () => {
|
||||
const onContentChange = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
const latestContentRef = { current: null } as MutableRefObject<string | null>
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<RawEditorView
|
||||
content="---\ntitle: Start\n---"
|
||||
path="/vault/a.md"
|
||||
entries={[entry('Alpha')]}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
latestContentRef={latestContentRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
const callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
|
||||
onDocChange: (doc: string) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
act(() => {
|
||||
callbacks.onDocChange('broken content')
|
||||
})
|
||||
|
||||
expect(latestContentRef.current).toBe('broken content')
|
||||
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Broken YAML')
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/a.md', 'broken content')
|
||||
|
||||
rerender(
|
||||
<RawEditorView
|
||||
content="fixed"
|
||||
path="/vault/b.md"
|
||||
entries={[entry('Alpha')]}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
latestContentRef={latestContentRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
callbacks.onDocChange('pending change')
|
||||
callbacks.onSave()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenLastCalledWith('/vault/b.md', 'pending change')
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
callbacks.onDocChange('flush on unmount')
|
||||
})
|
||||
unmount()
|
||||
|
||||
expect(onContentChange).toHaveBeenLastCalledWith('/vault/b.md', 'flush on unmount')
|
||||
})
|
||||
|
||||
it('opens the autocomplete dropdown, updates selection, inserts wikilinks, and tracks the insert event', () => {
|
||||
extractWikilinkQueryMock.mockReturnValue('alp')
|
||||
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }: { onInsertTarget: (target: string) => void }) => ({
|
||||
items: [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
|
||||
{ title: 'Beta', path: '/vault/beta.md', onItemClick: () => onInsertTarget('Beta') },
|
||||
],
|
||||
selectedIndex: 0,
|
||||
}))
|
||||
const onContentChange = vi.fn()
|
||||
const mockView = createMockView('[[alp')
|
||||
viewRefState.current = mockView
|
||||
|
||||
render(
|
||||
<RawEditorView
|
||||
content="[[alp"
|
||||
path="/vault/a.md"
|
||||
entries={[entry('Alpha'), entry('Beta')]}
|
||||
onContentChange={onContentChange}
|
||||
onSave={vi.fn()}
|
||||
vaultPath="/vault"
|
||||
/>,
|
||||
)
|
||||
|
||||
const callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
|
||||
onCursorActivity: (view: unknown) => void
|
||||
onEscape: () => boolean
|
||||
}
|
||||
|
||||
act(() => {
|
||||
callbacks.onCursorActivity(mockView)
|
||||
})
|
||||
|
||||
expect(buildRawEditorAutocompleteStateMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
query: 'alp',
|
||||
vaultPath: '/vault',
|
||||
}))
|
||||
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('0')
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'ArrowDown' })
|
||||
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('1')
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'ArrowUp' })
|
||||
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('0')
|
||||
|
||||
fireEvent.mouseEnter(screen.getByTestId('note-search-item-1'))
|
||||
expect(screen.getByTestId('note-search-selected-index')).toHaveTextContent('1')
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('presentation'), { key: 'Enter' })
|
||||
|
||||
expect(replaceActiveWikilinkQueryMock).toHaveBeenCalledWith('[[alp', '[[alp'.length, 'Beta')
|
||||
expect(mockView.dispatch).toHaveBeenCalledWith({
|
||||
changes: { from: 0, to: 5, insert: '[[Inserted]]' },
|
||||
selection: { anchor: 12 },
|
||||
})
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/a.md', '[[Inserted]]')
|
||||
expect(trackEventMock).toHaveBeenCalledWith('wikilink_inserted')
|
||||
expect(mockView.focus).toHaveBeenCalledTimes(1)
|
||||
expect(callbacks.onEscape()).toBe(false)
|
||||
})
|
||||
|
||||
it('clears autocomplete when the query is too short and reports escape handling while open', () => {
|
||||
extractWikilinkQueryMock
|
||||
.mockReturnValueOnce('a')
|
||||
.mockReturnValueOnce('alpha')
|
||||
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }: { onInsertTarget: (target: string) => void }) => ({
|
||||
items: [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
|
||||
],
|
||||
selectedIndex: 0,
|
||||
}))
|
||||
const mockView = createMockView('[[alpha')
|
||||
viewRefState.current = mockView
|
||||
|
||||
render(
|
||||
<RawEditorView
|
||||
content="[[alpha"
|
||||
path="/vault/a.md"
|
||||
entries={[entry('Alpha')]}
|
||||
onContentChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
let callbacks = useCodeMirrorMock.mock.calls[0]![2] as {
|
||||
onCursorActivity: (view: unknown) => void
|
||||
onEscape: () => boolean
|
||||
}
|
||||
|
||||
act(() => {
|
||||
callbacks.onCursorActivity(mockView)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
|
||||
|
||||
callbacks = useCodeMirrorMock.mock.calls.at(-1)![2] as typeof callbacks
|
||||
|
||||
act(() => {
|
||||
callbacks.onCursorActivity(mockView)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
|
||||
callbacks = useCodeMirrorMock.mock.calls.at(-1)![2] as typeof callbacks
|
||||
expect(callbacks.onEscape()).toBe(true)
|
||||
})
|
||||
})
|
||||
276
src/components/RawEditorView.coverage.test.tsx
Normal file
276
src/components/RawEditorView.coverage.test.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
trackEventMock,
|
||||
buildTypeEntryMapMock,
|
||||
buildRawEditorBaseItemsMock,
|
||||
detectYamlErrorMock,
|
||||
extractWikilinkQueryMock,
|
||||
buildRawEditorAutocompleteStateMock,
|
||||
getRawEditorDropdownPositionMock,
|
||||
replaceActiveWikilinkQueryMock,
|
||||
useCodeMirrorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
trackEventMock: vi.fn(),
|
||||
buildTypeEntryMapMock: vi.fn(() => new Map()),
|
||||
buildRawEditorBaseItemsMock: vi.fn(() => []),
|
||||
detectYamlErrorMock: vi.fn(() => null),
|
||||
extractWikilinkQueryMock: vi.fn(() => null),
|
||||
buildRawEditorAutocompleteStateMock: vi.fn(),
|
||||
getRawEditorDropdownPositionMock: vi.fn(() => ({ top: 48, left: 96 })),
|
||||
replaceActiveWikilinkQueryMock: vi.fn(),
|
||||
useCodeMirrorMock: vi.fn(),
|
||||
}))
|
||||
|
||||
type CodeMirrorCallbacks = {
|
||||
onCursorActivity: (view: unknown) => void
|
||||
onDocChange: (doc: string) => void
|
||||
onEscape: () => boolean
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
let latestCallbacks: CodeMirrorCallbacks | null = null
|
||||
let latestViewRef: MutableRefObject<{
|
||||
dispatch: ReturnType<typeof vi.fn>
|
||||
focus: ReturnType<typeof vi.fn>
|
||||
state: {
|
||||
doc: { toString: () => string }
|
||||
selection: { main: { head: number } }
|
||||
}
|
||||
} | null>
|
||||
|
||||
vi.mock('../lib/telemetry', () => ({
|
||||
trackEvent: trackEventMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/typeColors', () => ({
|
||||
buildTypeEntryMap: buildTypeEntryMapMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/rawEditorUtils', () => ({
|
||||
buildRawEditorAutocompleteState: buildRawEditorAutocompleteStateMock,
|
||||
buildRawEditorBaseItems: buildRawEditorBaseItemsMock,
|
||||
detectYamlError: detectYamlErrorMock,
|
||||
extractWikilinkQuery: extractWikilinkQueryMock,
|
||||
getRawEditorDropdownPosition: getRawEditorDropdownPositionMock,
|
||||
replaceActiveWikilinkQuery: replaceActiveWikilinkQueryMock,
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/useCodeMirror', () => ({
|
||||
useCodeMirror: useCodeMirrorMock,
|
||||
}))
|
||||
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
|
||||
vi.mock('./NoteSearchList', () => ({
|
||||
NoteSearchList: ({
|
||||
items,
|
||||
selectedIndex,
|
||||
onItemClick,
|
||||
onItemHover,
|
||||
}: {
|
||||
items: Array<{ title: string }>
|
||||
selectedIndex: number
|
||||
onItemClick: (item: { title: string }) => void
|
||||
onItemHover: (index: number) => void
|
||||
}) => (
|
||||
<div data-testid="raw-editor-note-search-list">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.title}
|
||||
data-testid={`autocomplete-item-${index}`}
|
||||
data-selected={index === selectedIndex}
|
||||
onMouseEnter={() => onItemHover(index)}
|
||||
onClick={() => onItemClick(item)}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function createEntry(title: string) {
|
||||
return {
|
||||
path: `/vault/${title.toLowerCase().replace(/\s+/g, '-')}.md`,
|
||||
filename: `${title.toLowerCase().replace(/\s+/g, '-')}.md`,
|
||||
title,
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: null,
|
||||
createdAt: null,
|
||||
fileSize: 0,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
content: '# Raw note',
|
||||
path: '/vault/raw-note.md',
|
||||
entries: [createEntry('Alpha'), createEntry('Beta')],
|
||||
onContentChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
vaultPath: '/vault',
|
||||
}
|
||||
|
||||
function renderView(overrides: Partial<typeof defaultProps> = {}) {
|
||||
const props = { ...defaultProps, ...overrides }
|
||||
render(<RawEditorView {...props} />)
|
||||
return props
|
||||
}
|
||||
|
||||
describe('RawEditorView additional coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
|
||||
latestViewRef = {
|
||||
current: {
|
||||
dispatch: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
state: {
|
||||
doc: { toString: () => 'Before [[Al' },
|
||||
selection: { main: { head: 11 } },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
buildRawEditorAutocompleteStateMock.mockImplementation(({ onInsertTarget }) => ({
|
||||
selectedIndex: 0,
|
||||
items: [
|
||||
{ title: 'Alpha', path: '/vault/alpha.md', onItemClick: () => onInsertTarget('Alpha') },
|
||||
{ title: 'Beta', path: '/vault/beta.md', onItemClick: () => onInsertTarget('Beta') },
|
||||
],
|
||||
}))
|
||||
replaceActiveWikilinkQueryMock.mockReturnValue({
|
||||
text: 'Before [[Alpha]]',
|
||||
cursor: 15,
|
||||
})
|
||||
useCodeMirrorMock.mockImplementation((_container, _content, callbacks: CodeMirrorCallbacks) => {
|
||||
latestCallbacks = callbacks
|
||||
return latestViewRef
|
||||
})
|
||||
})
|
||||
|
||||
it('debounces content updates, exposes latest content, flushes on save, and flushes pending edits on unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
const latestContentRef = { current: null as string | null }
|
||||
const onContentChange = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
const { unmount } = render(
|
||||
<RawEditorView
|
||||
{...defaultProps}
|
||||
latestContentRef={latestContentRef}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(latestContentRef.current).toBe('# Raw note')
|
||||
|
||||
act(() => {
|
||||
latestCallbacks?.onDocChange('draft 1')
|
||||
latestCallbacks?.onDocChange('draft 2')
|
||||
})
|
||||
|
||||
expect(onContentChange).not.toHaveBeenCalled()
|
||||
expect(latestContentRef.current).toBe('draft 2')
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTimeAsync(500)
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 2')
|
||||
|
||||
act(() => {
|
||||
latestCallbacks?.onDocChange('draft 3')
|
||||
latestCallbacks?.onSave()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 3')
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
latestCallbacks?.onDocChange('draft 4')
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'draft 4')
|
||||
})
|
||||
|
||||
it('renders YAML errors from the parser result', () => {
|
||||
detectYamlErrorMock.mockReturnValue('Missing closing delimiter')
|
||||
|
||||
renderView()
|
||||
|
||||
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Missing closing delimiter')
|
||||
})
|
||||
|
||||
it('opens autocomplete from cursor activity, navigates items, inserts a wikilink, and closes on escape', async () => {
|
||||
extractWikilinkQueryMock.mockReturnValue('Al')
|
||||
const onContentChange = vi.fn()
|
||||
renderView({ onContentChange })
|
||||
|
||||
act(() => {
|
||||
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
|
||||
})
|
||||
|
||||
expect(buildRawEditorAutocompleteStateMock).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
|
||||
|
||||
const presentation = screen.getByRole('presentation')
|
||||
fireEvent.keyDown(presentation, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(presentation, { key: 'ArrowUp' })
|
||||
fireEvent.keyDown(presentation, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceActiveWikilinkQueryMock).toHaveBeenCalledWith('Before [[Al', 11, 'Alpha')
|
||||
})
|
||||
|
||||
expect(latestViewRef.current?.dispatch).toHaveBeenCalledWith({
|
||||
changes: { from: 0, to: 'Before [[Al'.length, insert: 'Before [[Alpha]]' },
|
||||
selection: { anchor: 15 },
|
||||
})
|
||||
expect(trackEventMock).toHaveBeenCalledWith('wikilink_inserted')
|
||||
expect(onContentChange).toHaveBeenCalledWith('/vault/raw-note.md', 'Before [[Alpha]]')
|
||||
expect(latestViewRef.current?.focus).toHaveBeenCalledTimes(1)
|
||||
|
||||
extractWikilinkQueryMock.mockReturnValue(null)
|
||||
act(() => {
|
||||
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
|
||||
})
|
||||
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
|
||||
|
||||
extractWikilinkQueryMock.mockReturnValue('Al')
|
||||
act(() => {
|
||||
latestCallbacks?.onCursorActivity(latestViewRef.current as never)
|
||||
})
|
||||
expect(screen.getByTestId('raw-editor-wikilink-dropdown')).toBeInTheDocument()
|
||||
|
||||
let escaped = false
|
||||
act(() => {
|
||||
escaped = latestCallbacks?.onEscape() ?? false
|
||||
})
|
||||
expect(escaped).toBe(true)
|
||||
expect(screen.queryByTestId('raw-editor-wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
304
src/components/SingleEditorView.test.tsx
Normal file
304
src/components/SingleEditorView.test.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
capturedToolbarProps: null as null | Record<string, unknown>,
|
||||
capturedSuggestionProps: {} as Record<string, Record<string, unknown>>,
|
||||
capturedImageDropArgs: null as null | Record<string, unknown>,
|
||||
hoverGuardMock: vi.fn(),
|
||||
imageDropState: { isDragOver: false },
|
||||
linkActivationMock: vi.fn(),
|
||||
wikilinkEntriesRef: { current: [] as VaultEntry[] },
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
ComponentsContext: {
|
||||
Provider: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
},
|
||||
BlockNoteViewRaw: (props: {
|
||||
children?: ReactNode
|
||||
editable?: boolean
|
||||
className?: string
|
||||
formattingToolbar?: boolean
|
||||
slashMenu?: boolean
|
||||
sideMenu?: boolean
|
||||
}) => {
|
||||
const {
|
||||
children,
|
||||
editable,
|
||||
className,
|
||||
formattingToolbar,
|
||||
slashMenu,
|
||||
sideMenu,
|
||||
...restProps
|
||||
} = props
|
||||
void formattingToolbar
|
||||
void slashMenu
|
||||
void sideMenu
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="blocknote-view"
|
||||
data-editable={editable !== false ? 'true' : 'false'}
|
||||
className={className}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
SideMenuController: () => <div data-testid="side-menu-controller" />,
|
||||
SuggestionMenuController: (props: Record<string, unknown>) => {
|
||||
state.capturedSuggestionProps[String(props.triggerCharacter)] = props
|
||||
return <div data-testid={`suggestion-${String(props.triggerCharacter)}`} />
|
||||
},
|
||||
useCreateBlockNote: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine', () => ({
|
||||
components: {},
|
||||
}))
|
||||
|
||||
vi.mock('@mantine/core', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
MantineContext: React.createContext(null),
|
||||
MantineProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hooks/useTheme', () => ({
|
||||
useEditorTheme: () => ({ cssVars: { '--editor-accent': '#abc' } }),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/useImageDrop', () => ({
|
||||
useImageDrop: (args: Record<string, unknown>) => {
|
||||
state.capturedImageDropArgs = args
|
||||
return state.imageDropState
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../utils/typeColors', () => ({
|
||||
buildTypeEntryMap: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/wikilinkSuggestions', () => ({
|
||||
MIN_QUERY_LENGTH: 2,
|
||||
deduplicateByPath: <T,>(items: T[]) => items,
|
||||
preFilterWikilinks: () => [],
|
||||
}))
|
||||
|
||||
vi.mock('../utils/personMentionSuggestions', () => ({
|
||||
PERSON_MENTION_MIN_QUERY: 1,
|
||||
filterPersonMentions: () => [],
|
||||
}))
|
||||
|
||||
vi.mock('../utils/suggestionEnrichment', () => ({
|
||||
attachClickHandlers: <T,>(items: T[]) => items,
|
||||
enrichSuggestionItems: <T,>(items: T[]) => items,
|
||||
}))
|
||||
|
||||
vi.mock('./WikilinkSuggestionMenu', () => ({
|
||||
WikilinkSuggestionMenu: () => <div data-testid="wikilink-suggestion-menu" />,
|
||||
}))
|
||||
|
||||
vi.mock('./editorSchema', () => ({
|
||||
_wikilinkEntriesRef: state.wikilinkEntriesRef,
|
||||
}))
|
||||
|
||||
vi.mock('./blockNoteSideMenuHoverGuard', () => ({
|
||||
useBlockNoteSideMenuHoverGuard: (containerRef: unknown) => state.hoverGuardMock(containerRef),
|
||||
}))
|
||||
|
||||
vi.mock('./tolariaEditorFormattingConfig', () => ({
|
||||
getTolariaSlashMenuItems: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
vi.mock('./tolariaEditorFormatting', () => ({
|
||||
TolariaFormattingToolbar: () => <div data-testid="tolaria-formatting-toolbar" />,
|
||||
TolariaFormattingToolbarController: (props: Record<string, unknown>) => {
|
||||
state.capturedToolbarProps = props
|
||||
return <div data-testid="tolaria-formatting-toolbar-controller" />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./tolariaBlockNoteSideMenu', () => ({
|
||||
TolariaSideMenu: () => <div data-testid="tolaria-side-menu" />,
|
||||
}))
|
||||
|
||||
vi.mock('./useEditorLinkActivation', () => ({
|
||||
useEditorLinkActivation: (containerRef: unknown, onNavigateWikilink: unknown) => (
|
||||
state.linkActivationMock(containerRef, onNavigateWikilink)
|
||||
),
|
||||
}))
|
||||
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/project/alpha.md',
|
||||
filename: 'alpha.md',
|
||||
title: 'Alpha',
|
||||
isA: 'Project',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
archived: false,
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
fileSize: 10,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createEditor() {
|
||||
const cursorBlock = { id: 'cursor-block', type: 'paragraph', content: [], children: [] }
|
||||
return {
|
||||
document: [
|
||||
{ id: 'heading-block', type: 'heading', content: [], children: [] },
|
||||
cursorBlock,
|
||||
],
|
||||
tryParseMarkdownToBlocks: vi.fn(async () => [
|
||||
{ type: 'table', content: { type: 'tableContent' } },
|
||||
]),
|
||||
blocksToHTMLLossy: vi.fn(() => '<table>seeded</table>'),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
focus: vi.fn(),
|
||||
getTextCursorPosition: vi.fn(() => ({ block: cursorBlock })),
|
||||
insertBlocks: vi.fn(),
|
||||
insertInlineContent: vi.fn(),
|
||||
setTextCursorPosition: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('SingleEditorView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.capturedToolbarProps = null
|
||||
state.capturedSuggestionProps = {}
|
||||
state.capturedImageDropArgs = null
|
||||
state.imageDropState.isDragOver = false
|
||||
state.wikilinkEntriesRef.current = []
|
||||
delete window.__laputaTest
|
||||
})
|
||||
|
||||
it('registers the seeded BlockNote test bridge, applies column widths, and cleans it up on unmount', async () => {
|
||||
const editor = createEditor()
|
||||
const entries = [makeEntry()]
|
||||
const { unmount } = render(
|
||||
<SingleEditorView
|
||||
editor={editor as never}
|
||||
entries={entries}
|
||||
onNavigateWikilink={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(state.wikilinkEntriesRef.current).toEqual(entries)
|
||||
expect(typeof window.__laputaTest?.seedBlockNoteTable).toBe('function')
|
||||
|
||||
await act(async () => {
|
||||
await window.__laputaTest?.seedBlockNoteTable?.([120, null, 80])
|
||||
})
|
||||
|
||||
expect(editor.blocksToHTMLLossy).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: 'table',
|
||||
content: expect.objectContaining({
|
||||
type: 'tableContent',
|
||||
columnWidths: [120, null, 80],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({ type: 'paragraph' }),
|
||||
])
|
||||
expect(editor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<table>seeded</table>')
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(window.__laputaTest?.seedBlockNoteTable).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows the drag overlay and inserts dropped images after the active cursor block', () => {
|
||||
state.imageDropState.isDragOver = true
|
||||
const editor = createEditor()
|
||||
|
||||
render(
|
||||
<SingleEditorView
|
||||
editor={editor as never}
|
||||
entries={[makeEntry()]}
|
||||
onNavigateWikilink={vi.fn()}
|
||||
vaultPath="/vault"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Drop image here')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
(state.capturedImageDropArgs?.onImageUrl as (url: string) => void)('https://example.com/image.png')
|
||||
})
|
||||
|
||||
expect(editor.insertBlocks).toHaveBeenCalledWith(
|
||||
[{ type: 'image', props: { url: 'https://example.com/image.png' } }],
|
||||
expect.objectContaining({ id: 'cursor-block' }),
|
||||
'after',
|
||||
)
|
||||
})
|
||||
|
||||
it('wires the toolbar mouse guard and suggestion item click handlers', () => {
|
||||
const editor = createEditor()
|
||||
render(
|
||||
<SingleEditorView
|
||||
editor={editor as never}
|
||||
entries={[makeEntry()]}
|
||||
onNavigateWikilink={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(state.hoverGuardMock).toHaveBeenCalledOnce()
|
||||
expect(state.linkActivationMock).toHaveBeenCalledOnce()
|
||||
|
||||
const onMouseDownCapture = (
|
||||
(state.capturedToolbarProps?.floatingUIOptions as { elementProps: { onMouseDownCapture: (event: { target: HTMLElement; preventDefault: () => void }) => void } })
|
||||
).elementProps.onMouseDownCapture
|
||||
const menuTrigger = document.createElement('button')
|
||||
menuTrigger.setAttribute('aria-haspopup', 'menu')
|
||||
const menuPreventDefault = vi.fn()
|
||||
onMouseDownCapture({ target: menuTrigger, preventDefault: menuPreventDefault })
|
||||
expect(menuPreventDefault).not.toHaveBeenCalled()
|
||||
|
||||
const normalTarget = document.createElement('div')
|
||||
const normalPreventDefault = vi.fn()
|
||||
onMouseDownCapture({ target: normalTarget, preventDefault: normalPreventDefault })
|
||||
expect(normalPreventDefault).toHaveBeenCalledOnce()
|
||||
|
||||
const onWikiItemClick = vi.fn()
|
||||
const onMentionItemClick = vi.fn()
|
||||
;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: onWikiItemClick })
|
||||
;(state.capturedSuggestionProps['@'].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: onMentionItemClick })
|
||||
|
||||
expect(onWikiItemClick).toHaveBeenCalledOnce()
|
||||
expect(onMentionItemClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
59
src/components/StatusDropdown.extra.test.tsx
Normal file
59
src/components/StatusDropdown.extra.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as statusStyles from '../utils/statusStyles'
|
||||
import { StatusDropdown } from './StatusDropdown'
|
||||
|
||||
describe('StatusDropdown extra coverage', () => {
|
||||
const onSave = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('wraps keyboard navigation to the create option and scrolls highlighted items into view', () => {
|
||||
const scrollSpy = vi
|
||||
.spyOn(Element.prototype, 'scrollIntoView')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
render(
|
||||
<StatusDropdown
|
||||
value="Active"
|
||||
vaultStatuses={['Doing']}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByTestId('status-search-input')
|
||||
fireEvent.change(input, { target: { value: 'Needs Review' } })
|
||||
fireEvent.keyDown(input, { key: 'ArrowUp' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalled()
|
||||
expect(onSave).toHaveBeenCalledWith('Needs Review')
|
||||
|
||||
scrollSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('opens color pickers and persists the selected accent color', () => {
|
||||
const setStatusColorSpy = vi.spyOn(statusStyles, 'setStatusColor')
|
||||
|
||||
render(
|
||||
<StatusDropdown
|
||||
value="Active"
|
||||
vaultStatuses={['Active']}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-color-swatch-Active'))
|
||||
expect(screen.getByTestId('color-picker-Active')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('color-option-green'))
|
||||
|
||||
expect(setStatusColorSpy).toHaveBeenCalledWith('Active', 'green')
|
||||
expect(screen.queryByTestId('color-picker-Active')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isWithinFormattingToolbarHoverBridge,
|
||||
shouldSuppressFormattingToolbarHoverUpdate,
|
||||
useBlockNoteFormattingToolbarHoverGuard,
|
||||
} from './blockNoteFormattingToolbarHoverGuard'
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number) {
|
||||
return DOMRect.fromRect({ x: left, y: top, width, height })
|
||||
}
|
||||
|
||||
function setRect(element: HTMLElement, nextRect: DOMRect) {
|
||||
element.getBoundingClientRect = () => nextRect
|
||||
}
|
||||
|
||||
function setupHoverDom() {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'bn-visual-media-wrapper'
|
||||
fileBlock.appendChild(wrapper)
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(wrapper, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
return { container, toolbar }
|
||||
}
|
||||
|
||||
function createEditor(options: {
|
||||
selectionType?: string | null
|
||||
selectionId?: string
|
||||
cursorType?: string
|
||||
cursorId?: string
|
||||
setTextCursorPosition?: ReturnType<typeof vi.fn>
|
||||
}) {
|
||||
const {
|
||||
selectionType = 'paragraph',
|
||||
selectionId = 'other-block',
|
||||
cursorType = 'paragraph',
|
||||
cursorId = 'cursor-block',
|
||||
setTextCursorPosition = vi.fn(),
|
||||
} = options
|
||||
|
||||
return {
|
||||
getSelection: () => (
|
||||
selectionType
|
||||
? { blocks: [{ type: selectionType, id: selectionId }] }
|
||||
: null
|
||||
),
|
||||
getTextCursorPosition: () => ({ block: { type: cursorType, id: cursorId } }),
|
||||
setTextCursorPosition,
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchBridgeMousemove(target: EventTarget = document.body) {
|
||||
const event = new MouseEvent('mousemove', {
|
||||
bubbles: true,
|
||||
clientX: 368,
|
||||
clientY: 104,
|
||||
})
|
||||
Object.defineProperty(event, 'target', { configurable: true, value: target })
|
||||
const stopPropagation = vi.fn()
|
||||
event.stopPropagation = stopPropagation
|
||||
act(() => {
|
||||
window.dispatchEvent(event)
|
||||
})
|
||||
return stopPropagation
|
||||
}
|
||||
|
||||
describe('blockNoteFormattingToolbarHoverGuard extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('treats invisible rects and missing bridge inputs as non-suppressing', () => {
|
||||
expect(
|
||||
isWithinFormattingToolbarHoverBridge(
|
||||
{ x: 12, y: 12 },
|
||||
rect(10, 10, 0, 20),
|
||||
rect(10, 10, 20, 20),
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 12, y: 12 },
|
||||
container: null,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('remembers the last selected file block while open and clears it when closed', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const { container } = setupHoverDom()
|
||||
const setTextCursorPosition = vi.fn()
|
||||
const editor = createEditor({ setTextCursorPosition })
|
||||
|
||||
const { rerender, unmount } = renderHook(
|
||||
({ selectedFileBlockId, isOpen }) => useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: editor as never,
|
||||
container,
|
||||
selectedFileBlockId,
|
||||
isOpen,
|
||||
}),
|
||||
{
|
||||
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
|
||||
},
|
||||
)
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith('mousemove', expect.any(Function), true)
|
||||
|
||||
rerender({ selectedFileBlockId: null, isOpen: true })
|
||||
const stopPropagation = dispatchBridgeMousemove()
|
||||
|
||||
expect(setTextCursorPosition).toHaveBeenCalledWith('image-block')
|
||||
expect(stopPropagation).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender({ selectedFileBlockId: null, isOpen: false })
|
||||
rerender({ selectedFileBlockId: null, isOpen: true })
|
||||
dispatchBridgeMousemove()
|
||||
|
||||
expect(setTextCursorPosition).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
expect(removeSpy).toHaveBeenCalledWith('mousemove', expect.any(Function), true)
|
||||
})
|
||||
|
||||
it('skips listener setup when the environment is invalid and safely ignores already-active or failing restores', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
const { container } = setupHoverDom()
|
||||
const alreadySelectedEditor = createEditor({
|
||||
selectionType: null,
|
||||
cursorType: 'image',
|
||||
cursorId: 'image-block',
|
||||
setTextCursorPosition: vi.fn(),
|
||||
})
|
||||
|
||||
renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: alreadySelectedEditor as never,
|
||||
container: null,
|
||||
selectedFileBlockId: 'image-block',
|
||||
isOpen: true,
|
||||
}))
|
||||
|
||||
expect(addSpy).not.toHaveBeenCalled()
|
||||
|
||||
const { unmount } = renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: alreadySelectedEditor as never,
|
||||
container,
|
||||
selectedFileBlockId: 'image-block',
|
||||
isOpen: true,
|
||||
}))
|
||||
|
||||
dispatchBridgeMousemove()
|
||||
expect(alreadySelectedEditor.setTextCursorPosition).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
|
||||
const throwingEditor = createEditor({
|
||||
selectionType: null,
|
||||
cursorType: 'paragraph',
|
||||
cursorId: 'other-block',
|
||||
setTextCursorPosition: vi.fn(() => {
|
||||
throw new Error('gone')
|
||||
}),
|
||||
})
|
||||
|
||||
renderHook(() => useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: throwingEditor as never,
|
||||
container,
|
||||
selectedFileBlockId: 'image-block',
|
||||
isOpen: true,
|
||||
}))
|
||||
|
||||
expect(() => dispatchBridgeMousemove()).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import {
|
||||
isWithinFormattingToolbarHoverBridge,
|
||||
shouldSuppressFormattingToolbarHoverUpdate,
|
||||
useBlockNoteFormattingToolbarHoverGuard,
|
||||
} from './blockNoteFormattingToolbarHoverGuard'
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number) {
|
||||
@@ -12,7 +14,69 @@ function setRect(element: HTMLElement, nextRect: DOMRect) {
|
||||
element.getBoundingClientRect = () => nextRect
|
||||
}
|
||||
|
||||
function appendFileBlock({
|
||||
container,
|
||||
blockId,
|
||||
innerClass,
|
||||
}: {
|
||||
container: HTMLElement
|
||||
blockId: string
|
||||
innerClass: string
|
||||
}) {
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = blockId
|
||||
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
|
||||
const inner = document.createElement('div')
|
||||
inner.className = innerClass
|
||||
fileBlock.appendChild(inner)
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
|
||||
return inner
|
||||
}
|
||||
|
||||
function createToolbarHoverScenario(withToolbarButton = false) {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
const toolbarButton = withToolbarButton ? document.createElement('button') : null
|
||||
if (toolbarButton) toolbar.appendChild(toolbarButton)
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
return { container, toolbarButton }
|
||||
}
|
||||
|
||||
function makeEditor(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getSelection: vi.fn(() => null),
|
||||
getTextCursorPosition: vi.fn(() => ({ block: { id: 'paragraph-block', type: 'paragraph' } })),
|
||||
setTextCursorPosition: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('blockNoteFormattingToolbarHoverGuard', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('treats the gap between the selected image block and toolbar as part of the hover bridge', () => {
|
||||
expect(
|
||||
isWithinFormattingToolbarHoverBridge(
|
||||
@@ -23,92 +87,210 @@ describe('blockNoteFormattingToolbarHoverGuard', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses hover updates when the pointer is already over the toolbar', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
const toolbarButton = document.createElement('button')
|
||||
toolbar.appendChild(toolbarButton)
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
it('ignores invisible rectangles and missing hover bridge context', () => {
|
||||
expect(
|
||||
isWithinFormattingToolbarHoverBridge(
|
||||
{ x: 10, y: 10 },
|
||||
rect(300, 130, 0, 90),
|
||||
rect(322, 78, 96, 24),
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: toolbarButton,
|
||||
eventTarget: document.body,
|
||||
point: { x: 350, y: 90 },
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses hover updates while the pointer crosses the image-toolbar bridge', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 368, y: 104 },
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves unrelated pointer movement alone', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 520, y: 220 },
|
||||
container,
|
||||
container: null,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 350, y: 90 },
|
||||
container: document.createElement('div'),
|
||||
doc: document,
|
||||
selectedFileBlockId: null,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'suppresses hover updates when the pointer is already over the toolbar',
|
||||
point: { x: 350, y: 90 },
|
||||
eventTarget: 'toolbarButton',
|
||||
expected: true,
|
||||
withToolbarButton: true,
|
||||
},
|
||||
{
|
||||
name: 'suppresses hover updates while the pointer crosses the image-toolbar bridge',
|
||||
point: { x: 368, y: 104 },
|
||||
eventTarget: 'body',
|
||||
expected: true,
|
||||
withToolbarButton: false,
|
||||
},
|
||||
{
|
||||
name: 'leaves unrelated pointer movement alone',
|
||||
point: { x: 520, y: 220 },
|
||||
eventTarget: 'body',
|
||||
expected: false,
|
||||
withToolbarButton: false,
|
||||
},
|
||||
])('$name', ({ point, eventTarget, expected, withToolbarButton }) => {
|
||||
const { container, toolbarButton } = createToolbarHoverScenario(withToolbarButton)
|
||||
const target = eventTarget === 'toolbarButton' ? toolbarButton : document.body
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: target as EventTarget,
|
||||
point,
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it('uses the filename bridge fallback and restores the last selected file block while the toolbar stays open', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const bridge = appendFileBlock({
|
||||
container,
|
||||
blockId: 'image-block',
|
||||
innerClass: 'bn-file-name-with-icon',
|
||||
})
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(bridge, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
const editor = makeEditor()
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ selectedFileBlockId, isOpen }) =>
|
||||
useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: editor as never,
|
||||
container,
|
||||
selectedFileBlockId,
|
||||
isOpen,
|
||||
}),
|
||||
{
|
||||
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
|
||||
},
|
||||
)
|
||||
|
||||
rerender({ selectedFileBlockId: null, isOpen: true })
|
||||
|
||||
window.dispatchEvent(new MouseEvent('mousemove', {
|
||||
clientX: 368,
|
||||
clientY: 104,
|
||||
bubbles: true,
|
||||
}))
|
||||
|
||||
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('image-block')
|
||||
|
||||
vi.mocked(editor.setTextCursorPosition).mockClear()
|
||||
rerender({ selectedFileBlockId: null, isOpen: false })
|
||||
rerender({ selectedFileBlockId: null, isOpen: true })
|
||||
|
||||
window.dispatchEvent(new MouseEvent('mousemove', {
|
||||
clientX: 368,
|
||||
clientY: 104,
|
||||
bubbles: true,
|
||||
}))
|
||||
|
||||
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the add-file-button fallback and swallows restore errors when the block disappears', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const bridge = appendFileBlock({
|
||||
container,
|
||||
blockId: 'image-block',
|
||||
innerClass: 'bn-add-file-button',
|
||||
})
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(bridge, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
const editor = makeEditor({
|
||||
setTextCursorPosition: vi.fn(() => {
|
||||
throw new Error('gone')
|
||||
}),
|
||||
})
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ selectedFileBlockId, isOpen }) =>
|
||||
useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: editor as never,
|
||||
container,
|
||||
selectedFileBlockId,
|
||||
isOpen,
|
||||
}),
|
||||
{
|
||||
initialProps: { selectedFileBlockId: 'image-block', isOpen: true },
|
||||
},
|
||||
)
|
||||
|
||||
rerender({ selectedFileBlockId: null, isOpen: true })
|
||||
|
||||
expect(() => {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', {
|
||||
clientX: 368,
|
||||
clientY: 104,
|
||||
bubbles: true,
|
||||
}))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('leaves the cursor alone when the selected file block is already active', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const bridge = appendFileBlock({
|
||||
container,
|
||||
blockId: 'image-block',
|
||||
innerClass: 'bn-file-name-with-icon',
|
||||
})
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(bridge, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
const editor = makeEditor({
|
||||
getSelection: vi.fn(() => ({
|
||||
blocks: [{ id: 'image-block', type: 'image' }],
|
||||
})),
|
||||
})
|
||||
|
||||
renderHook(() =>
|
||||
useBlockNoteFormattingToolbarHoverGuard({
|
||||
editor: editor as never,
|
||||
container,
|
||||
selectedFileBlockId: 'image-block',
|
||||
isOpen: true,
|
||||
}),
|
||||
)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('mousemove', {
|
||||
clientX: 368,
|
||||
clientY: 104,
|
||||
bubbles: true,
|
||||
}))
|
||||
|
||||
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
100
src/components/inlineWikilinkDom.test.ts
Normal file
100
src/components/inlineWikilinkDom.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applySelectionIndex,
|
||||
applySelectionRange,
|
||||
readSelectionIndex,
|
||||
readSelectionRange,
|
||||
serializeInlineNode,
|
||||
} from './inlineWikilinkDom'
|
||||
|
||||
function createChip(target: string): HTMLSpanElement {
|
||||
const chip = document.createElement('span')
|
||||
chip.dataset.chipTarget = target
|
||||
chip.textContent = target
|
||||
return chip
|
||||
}
|
||||
|
||||
function createMixedInlineRoot(): HTMLDivElement {
|
||||
const root = document.createElement('div')
|
||||
root.append('A')
|
||||
root.append(createChip('Project'))
|
||||
root.append('B')
|
||||
document.body.append(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function setSelectionRange(
|
||||
startContainer: Node,
|
||||
startOffset: number,
|
||||
endContainer: Node,
|
||||
endOffset: number,
|
||||
) {
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.setStart(startContainer, startOffset)
|
||||
range.setEnd(endContainer, endOffset)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
describe('inlineWikilinkDom', () => {
|
||||
it('serializes text, chips, breaks, and nested content into inline markdown', () => {
|
||||
const root = document.createElement('div')
|
||||
const nested = document.createElement('span')
|
||||
|
||||
root.append('A\u00A0B\u200B\n')
|
||||
root.append(createChip('Project'))
|
||||
root.append(document.createElement('br'))
|
||||
nested.textContent = 'Tail'
|
||||
root.append(nested)
|
||||
|
||||
expect(serializeInlineNode(root)).toBe('A B [[Project]]Tail')
|
||||
})
|
||||
|
||||
it('reads selections inside the editor and falls back to the editor end for outside selections', () => {
|
||||
const root = createMixedInlineRoot()
|
||||
const [startText, , endText] = Array.from(root.childNodes)
|
||||
const outside = document.createElement('div')
|
||||
|
||||
outside.textContent = 'Outside'
|
||||
document.body.append(outside)
|
||||
|
||||
setSelectionRange(startText as Node, 1, endText as Node, 1)
|
||||
|
||||
expect(readSelectionRange(root)).toEqual({ start: 1, end: 13 })
|
||||
expect(readSelectionIndex(root)).toBe(13)
|
||||
|
||||
setSelectionRange(outside.firstChild as Node, 0, outside.firstChild as Node, 7)
|
||||
|
||||
expect(readSelectionRange(root)).toEqual({ start: 13, end: 13 })
|
||||
})
|
||||
|
||||
it('applies selection ranges across chips and clamps to the editor end', () => {
|
||||
const root = createMixedInlineRoot()
|
||||
|
||||
applySelectionRange(root, { start: 2, end: 999 })
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
expect(selection?.anchorNode?.textContent).toBe('B')
|
||||
expect(selection?.anchorOffset).toBe(0)
|
||||
expect(selection?.focusNode?.textContent).toBe('B')
|
||||
expect(selection?.focusOffset).toBe(1)
|
||||
})
|
||||
|
||||
it('applies collapsed indices before leading chips without requiring text nodes', () => {
|
||||
const root = document.createElement('div')
|
||||
|
||||
root.append(createChip('Task'))
|
||||
document.body.append(root)
|
||||
|
||||
applySelectionIndex(root, 0)
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
expect(selection?.anchorNode).toBe(root)
|
||||
expect(selection?.anchorOffset).toBe(0)
|
||||
expect(selection?.focusNode).toBe(root)
|
||||
expect(selection?.focusOffset).toBe(0)
|
||||
})
|
||||
})
|
||||
524
src/components/note-list/noteListHooks.extra.test.tsx
Normal file
524
src/components/note-list/noteListHooks.extra.test.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ModifiedFile, VaultEntry, ViewFile } from '../../types'
|
||||
import { saveSortPreferences } from '../../utils/noteListHelpers'
|
||||
import {
|
||||
useChangeStatusResolver,
|
||||
useListPropertyPicker,
|
||||
useMultiSelectKeyboard,
|
||||
useNoteListInteractions,
|
||||
useNoteListSearch,
|
||||
useNoteListSort,
|
||||
} from './noteListHooks'
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key] }),
|
||||
clear: vi.fn(() => { store = {} }),
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const {
|
||||
multiSelectState,
|
||||
noteListKeyboardState,
|
||||
prefetchNoteContentMock,
|
||||
routeNoteClickMock,
|
||||
} = vi.hoisted(() => ({
|
||||
multiSelectState: {
|
||||
clear: vi.fn(),
|
||||
selectAll: vi.fn(),
|
||||
selectRange: vi.fn(),
|
||||
setAnchor: vi.fn(),
|
||||
isMultiSelecting: false,
|
||||
},
|
||||
noteListKeyboardState: {
|
||||
highlightedPath: null as string | null,
|
||||
handleKeyDown: vi.fn(),
|
||||
lastOptions: null as null | Record<string, unknown>,
|
||||
},
|
||||
prefetchNoteContentMock: vi.fn(),
|
||||
routeNoteClickMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useMultiSelect', () => ({
|
||||
useMultiSelect: () => multiSelectState,
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useNoteListKeyboard', () => ({
|
||||
useNoteListKeyboard: (options: Record<string, unknown>) => {
|
||||
noteListKeyboardState.lastOptions = options
|
||||
return {
|
||||
highlightedPath: noteListKeyboardState.highlightedPath,
|
||||
handleKeyDown: noteListKeyboardState.handleKeyDown,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useTabManagement', () => ({
|
||||
prefetchNoteContent: (path: string) => prefetchNoteContentMock(path),
|
||||
}))
|
||||
|
||||
vi.mock('./noteListUtils', async () => {
|
||||
const actual = await vi.importActual<typeof import('./noteListUtils')>('./noteListUtils')
|
||||
return {
|
||||
...actual,
|
||||
routeNoteClick: (...args: unknown[]) => routeNoteClickMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note/a.md',
|
||||
filename: 'a.md',
|
||||
title: 'Alpha',
|
||||
isA: 'Project',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
archived: false,
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeletedEntry(): VaultEntry {
|
||||
return makeEntry({
|
||||
path: '/vault/note/deleted.md',
|
||||
filename: 'deleted.md',
|
||||
title: 'Deleted',
|
||||
__deletedNotePreview: true,
|
||||
__deletedRelativePath: 'note/deleted.md',
|
||||
__changeAddedLines: 0,
|
||||
__changeDeletedLines: 4,
|
||||
__changeBinary: false,
|
||||
} as Partial<VaultEntry>)
|
||||
}
|
||||
|
||||
describe('noteListHooks extra', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.clear()
|
||||
multiSelectState.isMultiSelecting = false
|
||||
noteListKeyboardState.highlightedPath = null
|
||||
noteListKeyboardState.lastOptions = null
|
||||
routeNoteClickMock.mockImplementation((
|
||||
entry: VaultEntry,
|
||||
_event: unknown,
|
||||
actions: { onReplace: (value: VaultEntry) => void },
|
||||
) => {
|
||||
actions.onReplace(entry)
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles search visibility and clears the search when closing it', () => {
|
||||
const { result } = renderHook(() => useNoteListSearch())
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSearch()
|
||||
result.current.setSearch(' HELLO ')
|
||||
})
|
||||
|
||||
expect(result.current.searchVisible).toBe(true)
|
||||
expect(result.current.query).toBe('hello')
|
||||
|
||||
act(() => {
|
||||
result.current.toggleSearch()
|
||||
})
|
||||
|
||||
expect(result.current.searchVisible).toBe(false)
|
||||
expect(result.current.search).toBe('')
|
||||
})
|
||||
|
||||
it('migrates stored list sorting into type documents', async () => {
|
||||
const typeDocument = makeEntry({
|
||||
path: '/vault/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
sort: null,
|
||||
})
|
||||
const projectEntry = makeEntry()
|
||||
const onUpdateTypeSort = vi.fn()
|
||||
const updateEntry = vi.fn()
|
||||
|
||||
saveSortPreferences({
|
||||
__list__: { option: 'title', direction: 'asc' },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListSort({
|
||||
entries: [typeDocument, projectEntry],
|
||||
selection: { kind: 'sectionGroup', type: 'Project', label: 'Projects' },
|
||||
modifiedPathSet: new Set<string>(),
|
||||
modifiedSuffixes: [],
|
||||
onUpdateTypeSort,
|
||||
updateEntry,
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdateTypeSort).toHaveBeenCalledWith(typeDocument.path, 'sort', 'title:asc')
|
||||
expect(updateEntry).toHaveBeenCalledWith(typeDocument.path, { sort: 'title:asc' })
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('__list__', 'modified', 'desc')
|
||||
})
|
||||
|
||||
expect(onUpdateTypeSort).toHaveBeenCalledWith(typeDocument.path, 'sort', 'modified:desc')
|
||||
expect(updateEntry).toHaveBeenCalledWith(typeDocument.path, { sort: 'modified:desc' })
|
||||
})
|
||||
|
||||
it('stores list sorting locally when no persistence target is available', () => {
|
||||
const entry = makeEntry({ isA: 'Note' })
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListSort({
|
||||
entries: [entry],
|
||||
selection: { kind: 'filter', filter: 'all' },
|
||||
modifiedPathSet: new Set<string>(),
|
||||
modifiedSuffixes: [],
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('__list__', 'title', 'asc')
|
||||
})
|
||||
|
||||
expect(result.current.sortPrefs.__list__).toEqual({ option: 'title', direction: 'asc' })
|
||||
})
|
||||
|
||||
it('prefers selected view sort config and persists list sort changes back to the view definition', () => {
|
||||
const onUpdateViewDefinition = vi.fn()
|
||||
const view: ViewFile = {
|
||||
filename: 'work.view',
|
||||
definition: {
|
||||
name: 'Work',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: 'title:asc',
|
||||
filters: { all: [] },
|
||||
},
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListSort({
|
||||
entries: [makeEntry()],
|
||||
selection: { kind: 'view', filename: view.filename },
|
||||
modifiedPathSet: new Set<string>(),
|
||||
modifiedSuffixes: [],
|
||||
views: [view],
|
||||
onUpdateViewDefinition,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.listSort).toBe('title')
|
||||
expect(result.current.listDirection).toBe('asc')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('__list__', 'modified', 'desc')
|
||||
})
|
||||
|
||||
expect(onUpdateViewDefinition).toHaveBeenCalledWith(view.filename, { sort: 'modified:desc' })
|
||||
})
|
||||
|
||||
it('handles keyboard shortcuts for multi-select flows and ignores select-all in focused inputs', () => {
|
||||
const onArchive = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
|
||||
multiSelectState.isMultiSelecting = true
|
||||
renderHook(() => useMultiSelectKeyboard(multiSelectState as never, false, onArchive, onDelete))
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
})
|
||||
expect(multiSelectState.clear).toHaveBeenCalled()
|
||||
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'a',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
})
|
||||
expect(multiSelectState.selectAll).not.toHaveBeenCalled()
|
||||
|
||||
input.blur()
|
||||
input.remove()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'a',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'e',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Delete',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
})
|
||||
|
||||
expect(multiSelectState.selectAll).toHaveBeenCalledOnce()
|
||||
expect(onArchive).toHaveBeenCalledOnce()
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('matches change status by relative path suffix and returns undefined outside the changes view', () => {
|
||||
const modifiedFiles: ModifiedFile[] = [
|
||||
{ path: '/vault/changes/note/alpha.md', relativePath: 'note/alpha.md', status: 'deleted' },
|
||||
]
|
||||
|
||||
const enabled = renderHook(() => useChangeStatusResolver(true, modifiedFiles))
|
||||
expect(enabled.result.current('/mirror/worktree/note/alpha.md')).toBe('deleted')
|
||||
|
||||
const disabled = renderHook(() => useChangeStatusResolver(false, modifiedFiles))
|
||||
expect(disabled.result.current('/vault/changes/note/alpha.md')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for change-status lookups that do not match any modified file', () => {
|
||||
const modifiedFiles: ModifiedFile[] = [
|
||||
{ path: '/vault/note/a.md', relativePath: 'note/a.md', status: 'modified' },
|
||||
]
|
||||
const { result } = renderHook(() => useChangeStatusResolver(true, modifiedFiles))
|
||||
|
||||
expect(result.current('/vault/note/a.md')).toBe('modified')
|
||||
expect(result.current('/vault/note/missing.md')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds a type property picker that persists the chosen columns', () => {
|
||||
const typeDocument = makeEntry({
|
||||
path: '/vault/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
listPropertiesDisplay: ['status'],
|
||||
})
|
||||
const projectEntry = makeEntry({
|
||||
properties: { priority: 'High' },
|
||||
relationships: { related_to: ['Beta'] },
|
||||
})
|
||||
const onUpdateTypeSort = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListPropertyPicker({
|
||||
entries: [typeDocument, projectEntry],
|
||||
selection: { kind: 'sectionGroup', type: 'Project', label: 'Projects' },
|
||||
inboxPeriod: 'month',
|
||||
typeDocument,
|
||||
typeEntryMap: { Project: typeDocument },
|
||||
onUpdateTypeSort,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.propertyPicker?.scope).toBe('type')
|
||||
|
||||
act(() => {
|
||||
result.current.propertyPicker?.onSave(['status', 'priority'])
|
||||
})
|
||||
|
||||
expect(onUpdateTypeSort).toHaveBeenCalledWith(
|
||||
typeDocument.path,
|
||||
'_list_properties_display',
|
||||
['status', 'priority'],
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the view property picker and saves view-specific list property display overrides', () => {
|
||||
const onUpdateViewDefinition = vi.fn()
|
||||
const view: ViewFile = {
|
||||
filename: 'focus.view',
|
||||
definition: {
|
||||
name: 'Focus',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [] },
|
||||
listPropertiesDisplay: [],
|
||||
},
|
||||
}
|
||||
const focusTypeDocument = makeEntry({
|
||||
path: '/vault/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
listPropertiesDisplay: ['status'],
|
||||
})
|
||||
const projectEntry = makeEntry({
|
||||
properties: { priority: 'High' },
|
||||
relationships: { related_to: ['Beta'] },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListPropertyPicker({
|
||||
entries: [focusTypeDocument, projectEntry],
|
||||
selection: { kind: 'view', filename: view.filename },
|
||||
inboxPeriod: 'month',
|
||||
typeDocument: null,
|
||||
typeEntryMap: { Project: focusTypeDocument },
|
||||
views: [view],
|
||||
onUpdateViewDefinition,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.propertyPicker?.scope).toBe('view')
|
||||
|
||||
act(() => {
|
||||
result.current.propertyPicker?.onSave(null)
|
||||
})
|
||||
|
||||
expect(onUpdateViewDefinition).toHaveBeenCalledWith(view.filename, {
|
||||
listPropertiesDisplay: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('routes deleted-note interactions through the deleted preview handlers and auto-triggers diffs for live changes', () => {
|
||||
vi.useFakeTimers()
|
||||
const deletedEntry = makeDeletedEntry()
|
||||
const liveEntry = makeEntry({ path: '/vault/note/live.md', filename: 'live.md', title: 'Live' })
|
||||
const onReplaceActiveTab = vi.fn()
|
||||
const onOpenDeletedNote = vi.fn()
|
||||
const onAutoTriggerDiff = vi.fn()
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListInteractions({
|
||||
searched: [deletedEntry, liveEntry],
|
||||
searchedGroups: [],
|
||||
selectedNotePath: deletedEntry.path,
|
||||
selection: { kind: 'filter', filter: 'changes' },
|
||||
noteListFilter: 'open',
|
||||
isChangesView: true,
|
||||
entityEntry: null,
|
||||
searchVisible: false,
|
||||
toggleSearch: vi.fn(),
|
||||
onReplaceActiveTab,
|
||||
onOpenDeletedNote,
|
||||
onAutoTriggerDiff,
|
||||
openContextMenuForEntry: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
const keyboardOptions = noteListKeyboardState.lastOptions as {
|
||||
onOpen: (entry: VaultEntry) => void
|
||||
onPrefetch: (entry: VaultEntry) => void
|
||||
}
|
||||
|
||||
act(() => {
|
||||
keyboardOptions.onOpen(deletedEntry)
|
||||
keyboardOptions.onPrefetch(liveEntry)
|
||||
routeNoteClickMock.mockImplementationOnce((
|
||||
entry: VaultEntry,
|
||||
_event: unknown,
|
||||
actions: { onEnterNeighborhood?: (value: VaultEntry) => void },
|
||||
) => {
|
||||
actions.onEnterNeighborhood?.(entry)
|
||||
})
|
||||
result.current.handleClickNote(deletedEntry, {} as React.MouseEvent)
|
||||
result.current.handleClickNote(liveEntry, {} as React.MouseEvent)
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry)
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
|
||||
expect(onAutoTriggerDiff).toHaveBeenCalledOnce()
|
||||
expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry.path)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens live notes into Neighborhood mode and routes deleted clicks through the deleted preview action', async () => {
|
||||
const deletedEntry = makeDeletedEntry()
|
||||
const liveEntry = makeEntry({ path: '/vault/note/live.md', filename: 'live.md', title: 'Live' })
|
||||
const onReplaceActiveTab = vi.fn(async () => {})
|
||||
const onEnterNeighborhood = vi.fn()
|
||||
const onOpenDeletedNote = vi.fn()
|
||||
|
||||
routeNoteClickMock.mockImplementation((
|
||||
_entry: VaultEntry,
|
||||
_event: unknown,
|
||||
actions: { onEnterNeighborhood: () => void },
|
||||
) => {
|
||||
actions.onEnterNeighborhood()
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useNoteListInteractions({
|
||||
searched: [deletedEntry, liveEntry],
|
||||
searchedGroups: [],
|
||||
selectedNotePath: liveEntry.path,
|
||||
selection: { kind: 'filter', filter: 'changes' },
|
||||
noteListFilter: 'open',
|
||||
isChangesView: true,
|
||||
entityEntry: null,
|
||||
searchVisible: false,
|
||||
toggleSearch: vi.fn(),
|
||||
onReplaceActiveTab,
|
||||
onEnterNeighborhood,
|
||||
onOpenDeletedNote,
|
||||
openContextMenuForEntry: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
const keyboardOptions = noteListKeyboardState.lastOptions as {
|
||||
onEnterNeighborhood: (entry: VaultEntry) => Promise<void>
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await keyboardOptions.onEnterNeighborhood(deletedEntry)
|
||||
await keyboardOptions.onEnterNeighborhood(liveEntry)
|
||||
result.current.handleClickNote(deletedEntry, {} as React.MouseEvent)
|
||||
})
|
||||
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
|
||||
expect(onEnterNeighborhood).toHaveBeenCalledWith(liveEntry)
|
||||
expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry)
|
||||
})
|
||||
})
|
||||
161
src/components/status-bar/StatusBarBadges.extra.test.tsx
Normal file
161
src/components/status-bar/StatusBarBadges.extra.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const openExternalUrlMock = vi.fn()
|
||||
|
||||
vi.mock('@/components/ui/action-tooltip', () => ({
|
||||
ActionTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type="button" onClick={onClick} onKeyDown={onKeyDown} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => openExternalUrlMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('./useDismissibleLayer', () => ({
|
||||
useDismissibleLayer: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
CommitBadge,
|
||||
ConflictBadge,
|
||||
NoRemoteBadge,
|
||||
SyncBadge,
|
||||
} from './StatusBarBadges'
|
||||
|
||||
describe('StatusBarBadges extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens commit links externally and falls back to a plain hash badge without a URL', () => {
|
||||
const { rerender } = render(
|
||||
<CommitBadge info={{ shortHash: 'abc1234', commitUrl: 'https://example.com/commit/abc1234' }} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-commit-link'))
|
||||
expect(openExternalUrlMock).toHaveBeenCalledWith('https://example.com/commit/abc1234')
|
||||
|
||||
rerender(<CommitBadge info={{ shortHash: 'def5678', commitUrl: null }} />)
|
||||
expect(screen.getByTestId('status-commit-hash')).toHaveTextContent('def5678')
|
||||
})
|
||||
|
||||
it('renders actionable and passive no-remote badges', () => {
|
||||
const onAddRemote = vi.fn()
|
||||
const { rerender } = render(
|
||||
<NoRemoteBadge
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
|
||||
onAddRemote={onAddRemote}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-no-remote'))
|
||||
expect(onAddRemote).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<NoRemoteBadge
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('status-no-remote')).toHaveTextContent('No remote')
|
||||
expect(screen.getByTestId('status-no-remote')).toHaveAttribute(
|
||||
'title',
|
||||
'This git vault has no remote configured. Commits stay local until you add one.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows sync popup details, handles pull actions, and covers no-remote summaries', () => {
|
||||
const onTriggerSync = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SyncBadge
|
||||
status="idle"
|
||||
lastSyncTime={Date.now() - 120_000}
|
||||
remoteStatus={{ branch: 'main', ahead: 2, behind: 1, hasRemote: true }}
|
||||
onTriggerSync={onTriggerSync}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
|
||||
expect(screen.getByTestId('git-status-popup')).toHaveTextContent('main')
|
||||
expect(screen.getByText('↑ 2 ahead')).toBeInTheDocument()
|
||||
expect(screen.getByText('↓ 1 behind')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Status: Synced/)).toBeInTheDocument()
|
||||
|
||||
const pullButton = screen.getByTestId('git-status-pull-btn')
|
||||
fireEvent.mouseEnter(pullButton)
|
||||
expect(pullButton.style.background).toBe('var(--hover)')
|
||||
fireEvent.mouseLeave(pullButton)
|
||||
expect(pullButton.style.background).toBe('transparent')
|
||||
|
||||
fireEvent.click(pullButton)
|
||||
expect(onTriggerSync).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByTestId('git-status-popup')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<SyncBadge
|
||||
status="idle"
|
||||
lastSyncTime={null}
|
||||
remoteStatus={null}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(screen.getByTestId('git-status-popup')).toHaveTextContent('No remote configured')
|
||||
})
|
||||
|
||||
it('routes conflict and pull-required sync states to their dedicated actions', () => {
|
||||
const onOpenConflictResolver = vi.fn()
|
||||
const onPullAndPush = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SyncBadge
|
||||
status="conflict"
|
||||
lastSyncTime={null}
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: true }}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(onOpenConflictResolver).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByTestId('git-status-popup')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<SyncBadge
|
||||
status="pull_required"
|
||||
lastSyncTime={null}
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 2, hasRemote: true }}
|
||||
onPullAndPush={onPullAndPush}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(onPullAndPush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders clickable conflict badges with plural copy', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<ConflictBadge count={2} onClick={onClick} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-conflict-count'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('status-conflict-count')).toHaveTextContent('2 conflicts')
|
||||
})
|
||||
})
|
||||
303
src/components/tolariaEditorFormatting.behavior.test.tsx
Normal file
303
src/components/tolariaEditorFormatting.behavior.test.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const {
|
||||
blockHasTypeMock,
|
||||
editorHasBlockWithTypeMock,
|
||||
formattingToolbarStore,
|
||||
hoverGuardMock,
|
||||
positionPopoverState,
|
||||
showState,
|
||||
useBlockNoteEditorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
blockHasTypeMock: vi.fn(() => true),
|
||||
editorHasBlockWithTypeMock: vi.fn(() => true),
|
||||
formattingToolbarStore: { setState: vi.fn() },
|
||||
hoverGuardMock: vi.fn(),
|
||||
positionPopoverState: { lastProps: null as null | Record<string, unknown> },
|
||||
showState: { value: true },
|
||||
useBlockNoteEditorMock: vi.fn(),
|
||||
}))
|
||||
|
||||
function MockIcon() {
|
||||
return <svg data-testid="mock-icon" />
|
||||
}
|
||||
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
FormattingToolbar: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="mock-formatting-toolbar">{children}</div>
|
||||
),
|
||||
getFormattingToolbarItems: () => [
|
||||
<div key="blockTypeSelect" />,
|
||||
<div key="boldStyleButton" />,
|
||||
<div key="italicStyleButton" />,
|
||||
<div key="strikeStyleButton" />,
|
||||
<div key="createLinkButton" />,
|
||||
],
|
||||
PositionPopover: (props: Record<string, unknown> & { children?: ReactNode }) => {
|
||||
positionPopoverState.lastProps = props
|
||||
return <div data-testid="mock-position-popover">{props.children}</div>
|
||||
},
|
||||
useBlockNoteEditor: useBlockNoteEditorMock,
|
||||
useComponentsContext: () => ({
|
||||
FormattingToolbar: {
|
||||
Button: ({
|
||||
children,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<button onClick={onClick} type="button">
|
||||
{icon}
|
||||
{label}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
}),
|
||||
useEditorState: ({ editor, selector }: { editor: unknown; selector: (context: { editor: unknown }) => unknown }) => selector({ editor }),
|
||||
useExtension: () => ({ store: formattingToolbarStore }),
|
||||
useExtensionState: () => showState.value,
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
blockHasType: blockHasTypeMock,
|
||||
defaultProps: { textAlignment: 'left' },
|
||||
editorHasBlockWithType: editorHasBlockWithTypeMock,
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/core/extensions', () => ({
|
||||
FormattingToolbarExtension: Symbol('FormattingToolbarExtension'),
|
||||
}))
|
||||
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, ...props }: { children?: ReactNode }) => <button type="button" {...props}>{children}</button>,
|
||||
CheckIcon: () => <span data-testid="mantine-check">check</span>,
|
||||
Menu: Object.assign(
|
||||
({ children }: { children?: ReactNode }) => <div data-testid="mantine-menu">{children}</div>,
|
||||
{
|
||||
Target: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
Dropdown: ({ children, ...props }: { children?: ReactNode }) => <div {...props}>{children}</div>,
|
||||
Item: ({ children, ...props }: { children?: ReactNode }) => <button type="button" {...props}>{children}</button>,
|
||||
},
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Bold: MockIcon,
|
||||
ChevronDown: MockIcon,
|
||||
Code2: MockIcon,
|
||||
Italic: MockIcon,
|
||||
Strikethrough: MockIcon,
|
||||
}))
|
||||
|
||||
vi.mock('./tolariaEditorFormattingConfig', () => ({
|
||||
filterTolariaFormattingToolbarItems: (items: ReactNode[]) => items,
|
||||
getTolariaBlockTypeSelectItems: () => [
|
||||
{ name: 'Paragraph', type: 'paragraph', props: {}, icon: MockIcon },
|
||||
{ name: 'Heading 1', type: 'heading', props: { level: 1 }, icon: MockIcon },
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('./blockNoteFormattingToolbarHoverGuard', () => ({
|
||||
useBlockNoteFormattingToolbarHoverGuard: hoverGuardMock,
|
||||
}))
|
||||
|
||||
import {
|
||||
TolariaFormattingToolbar,
|
||||
TolariaFormattingToolbarController,
|
||||
} from './tolariaEditorFormatting'
|
||||
|
||||
function createMockEditor(blockType = 'image') {
|
||||
const selectedBlock = {
|
||||
id: 'file-block',
|
||||
type: blockType,
|
||||
props: { textAlignment: 'center', level: 1 },
|
||||
content: [{ type: 'text', text: 'Selected block' }],
|
||||
}
|
||||
|
||||
return {
|
||||
isEditable: true,
|
||||
schema: {
|
||||
styleSchema: {
|
||||
bold: { type: 'bold', propSchema: 'boolean' },
|
||||
italic: { type: 'italic', propSchema: 'boolean' },
|
||||
strike: { type: 'strike', propSchema: 'boolean' },
|
||||
code: { type: 'code', propSchema: 'boolean' },
|
||||
},
|
||||
},
|
||||
prosemirrorState: { selection: { from: 1, to: 5 } },
|
||||
domElement: document.createElement('div'),
|
||||
focus: vi.fn(),
|
||||
getActiveStyles: () => ({ bold: true }),
|
||||
getSelection: () => ({ blocks: [selectedBlock] }),
|
||||
getTextCursorPosition: () => ({ block: selectedBlock }),
|
||||
toggleStyles: vi.fn(),
|
||||
transact: vi.fn((callback: () => void) => callback()),
|
||||
updateBlock: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('tolariaEditorFormatting behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
positionPopoverState.lastProps = null
|
||||
showState.value = true
|
||||
useBlockNoteEditorMock.mockReturnValue(createMockEditor())
|
||||
})
|
||||
|
||||
it('renders toolbar controls, inserts the inline code button, and updates block types', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /bold/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /inline code/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(editor.toggleStyles).toHaveBeenCalledWith({ bold: true })
|
||||
expect(editor.toggleStyles).toHaveBeenCalledWith({ code: true })
|
||||
expect(editor.transact).toHaveBeenCalledTimes(1)
|
||||
expect(editor.updateBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'file-block' }),
|
||||
{ type: 'heading', props: { level: 1 } },
|
||||
)
|
||||
})
|
||||
|
||||
it('controls the floating toolbar placement, hover guard, and escape-key close behavior', () => {
|
||||
const editor = createMockEditor()
|
||||
const toolbarComponent = () => <div data-testid="custom-toolbar">Toolbar</div>
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(
|
||||
<TolariaFormattingToolbarController
|
||||
formattingToolbar={toolbarComponent}
|
||||
floatingUIOptions={{ useFloatingOptions: { placement: 'top-start' } }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-toolbar')).toBeInTheDocument()
|
||||
expect(hoverGuardMock).toHaveBeenCalledWith({
|
||||
editor,
|
||||
container: editor.domElement,
|
||||
selectedFileBlockId: 'file-block',
|
||||
isOpen: true,
|
||||
})
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({
|
||||
open: true,
|
||||
placement: 'top-start',
|
||||
}),
|
||||
}))
|
||||
|
||||
const onOpenChange = positionPopoverState.lastProps?.useFloatingOptions as {
|
||||
onOpenChange: (open: boolean, event: unknown, reason?: string) => void
|
||||
}
|
||||
|
||||
onOpenChange.onOpenChange(false, undefined, 'escape-key')
|
||||
|
||||
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
|
||||
expect(editor.focus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses block alignment when deciding the floating placement', () => {
|
||||
const editor = createMockEditor()
|
||||
editor.getTextCursorPosition = () => ({
|
||||
block: {
|
||||
id: 'paragraph-block',
|
||||
type: 'paragraph',
|
||||
props: { textAlignment: 'right' },
|
||||
content: [{ type: 'text', text: 'Paragraph' }],
|
||||
},
|
||||
})
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
useFloatingOptions: expect.objectContaining({
|
||||
placement: 'top-end',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('falls back to top-start and focuses the block type trigger on mouse down', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const focusSpy = vi.spyOn(HTMLButtonElement.prototype, 'focus').mockImplementation(() => {})
|
||||
|
||||
blockHasTypeMock.mockReturnValue(false)
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
fireEvent.mouseDown(screen.getAllByRole('button', { name: 'Paragraph' })[0] as HTMLButtonElement)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
useFloatingOptions: expect.objectContaining({
|
||||
placement: 'top-start',
|
||||
}),
|
||||
}))
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
|
||||
focusSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the toolbar open during close grace and clears the timeout on unmount', () => {
|
||||
vi.useFakeTimers()
|
||||
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||
const editor = createMockEditor('paragraph')
|
||||
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
const { rerender, unmount } = render(<TolariaFormattingToolbarController />)
|
||||
|
||||
showState.value = false
|
||||
rerender(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(screen.getByTestId('mock-position-popover')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
|
||||
clearTimeoutSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('ignores internal pointer and focus transitions before closing on external blur', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(
|
||||
<TolariaFormattingToolbarController
|
||||
formattingToolbar={() => <button data-testid="toolbar-action" type="button">Toolbar</button>}
|
||||
/>,
|
||||
)
|
||||
|
||||
const toolbarWrapper = screen.getByTestId('toolbar-action').parentElement as HTMLElement
|
||||
|
||||
fireEvent.pointerEnter(toolbarWrapper)
|
||||
fireEvent.pointerLeave(toolbarWrapper, { relatedTarget: screen.getByTestId('toolbar-action') })
|
||||
fireEvent.focus(toolbarWrapper)
|
||||
fireEvent.blur(toolbarWrapper, { relatedTarget: screen.getByTestId('toolbar-action') })
|
||||
|
||||
expect(screen.getByTestId('toolbar-action')).toBeInTheDocument()
|
||||
|
||||
fireEvent.pointerLeave(toolbarWrapper, { relatedTarget: document.body })
|
||||
fireEvent.blur(toolbarWrapper, { relatedTarget: document.body })
|
||||
|
||||
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
163
src/components/useFilenameAutolinkGuard.extra.test.tsx
Normal file
163
src/components/useFilenameAutolinkGuard.extra.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { shouldStripAutoLinkedLocalFileMarkMock } = vi.hoisted(() => ({
|
||||
shouldStripAutoLinkedLocalFileMarkMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/editorLinkAutolink', () => ({
|
||||
shouldStripAutoLinkedLocalFileMark: shouldStripAutoLinkedLocalFileMarkMock,
|
||||
}))
|
||||
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
|
||||
function Harness({ editor }: { editor: unknown }) {
|
||||
useFilenameAutolinkGuard(editor as never)
|
||||
return null
|
||||
}
|
||||
|
||||
function createEditor({
|
||||
nodes = [],
|
||||
docChanged = true,
|
||||
withEvents = true,
|
||||
withLinkMark = true,
|
||||
}: {
|
||||
nodes?: Array<{ node: unknown; pos: number }>
|
||||
docChanged?: boolean
|
||||
withEvents?: boolean
|
||||
withLinkMark?: boolean
|
||||
}) {
|
||||
let updateHandler:
|
||||
| ((payload: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => void)
|
||||
| undefined
|
||||
|
||||
const removeMark = vi.fn()
|
||||
const setMeta = vi.fn()
|
||||
const dispatch = vi.fn()
|
||||
const descendants = vi.fn((callback: (node: unknown, pos: number) => void) => {
|
||||
for (const entry of nodes) {
|
||||
callback(entry.node, entry.pos)
|
||||
}
|
||||
})
|
||||
|
||||
const tiptap = {
|
||||
schema: {
|
||||
marks: {
|
||||
...(withLinkMark ? { link: 'link-mark' } : {}),
|
||||
},
|
||||
},
|
||||
state: {
|
||||
doc: { descendants },
|
||||
tr: {
|
||||
docChanged,
|
||||
removeMark,
|
||||
setMeta,
|
||||
},
|
||||
},
|
||||
...(withEvents
|
||||
? {
|
||||
on: vi.fn((event: string, handler: typeof updateHandler) => {
|
||||
if (event === 'update') {
|
||||
updateHandler = handler
|
||||
}
|
||||
}),
|
||||
off: vi.fn(),
|
||||
}
|
||||
: {}),
|
||||
view: {
|
||||
dispatch,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
editor: {
|
||||
_tiptapEditor: tiptap,
|
||||
},
|
||||
tiptap,
|
||||
descendants,
|
||||
removeMark,
|
||||
setMeta,
|
||||
dispatch,
|
||||
getUpdateHandler: () => updateHandler,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFilenameAutolinkGuard extra coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not subscribe when the editor lacks an event API', () => {
|
||||
const fixture = createEditor({ withEvents: false })
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
|
||||
expect('on' in fixture.tiptap).toBe(false)
|
||||
expect(fixture.descendants).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips traversal when the editor has no link mark registered', () => {
|
||||
const fixture = createEditor({ withLinkMark: false })
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
fixture.getUpdateHandler()?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.descendants).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores text nodes whose marks are not accidental filename links', () => {
|
||||
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(false)
|
||||
const fixture = createEditor({
|
||||
nodes: [
|
||||
{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 8,
|
||||
text: 'draft.md',
|
||||
marks: [{ type: 'link-mark', attrs: { href: 'draft.md' } }],
|
||||
},
|
||||
pos: 5,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 6,
|
||||
text: 'notes',
|
||||
marks: [{ type: 'other-mark', attrs: { href: 'notes' } }],
|
||||
},
|
||||
pos: 20,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
isText: false,
|
||||
nodeSize: 3,
|
||||
text: null,
|
||||
marks: [],
|
||||
},
|
||||
pos: 40,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
fixture.getUpdateHandler()?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(shouldStripAutoLinkedLocalFileMarkMock).toHaveBeenCalledWith({
|
||||
href: { raw: 'draft.md' },
|
||||
text: { raw: 'draft.md' },
|
||||
})
|
||||
expect(fixture.removeMark).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
174
src/components/useFilenameAutolinkGuard.test.tsx
Normal file
174
src/components/useFilenameAutolinkGuard.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { shouldStripAutoLinkedLocalFileMarkMock } = vi.hoisted(() => ({
|
||||
shouldStripAutoLinkedLocalFileMarkMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/editorLinkAutolink', () => ({
|
||||
shouldStripAutoLinkedLocalFileMark: shouldStripAutoLinkedLocalFileMarkMock,
|
||||
}))
|
||||
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
|
||||
function Harness({ editor }: { editor: unknown }) {
|
||||
useFilenameAutolinkGuard(editor as never)
|
||||
return null
|
||||
}
|
||||
|
||||
function createEditor({
|
||||
nodes,
|
||||
docChanged = true,
|
||||
}: {
|
||||
nodes: Array<{ node: unknown; pos: number }>
|
||||
docChanged?: boolean
|
||||
}) {
|
||||
let updateHandler: ((payload: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => void) | undefined
|
||||
const removeMark = vi.fn()
|
||||
const setMeta = vi.fn()
|
||||
const dispatch = vi.fn()
|
||||
const descendants = vi.fn((callback: (node: unknown, pos: number) => void) => {
|
||||
for (const entry of nodes) {
|
||||
callback(entry.node, entry.pos)
|
||||
}
|
||||
})
|
||||
|
||||
const tr = {
|
||||
docChanged,
|
||||
removeMark,
|
||||
setMeta,
|
||||
}
|
||||
|
||||
const tiptap = {
|
||||
schema: {
|
||||
marks: {
|
||||
link: 'link-mark',
|
||||
},
|
||||
},
|
||||
state: {
|
||||
doc: { descendants },
|
||||
tr,
|
||||
},
|
||||
on: vi.fn((event: string, handler: typeof updateHandler) => {
|
||||
if (event === 'update') {
|
||||
updateHandler = handler
|
||||
}
|
||||
}),
|
||||
off: vi.fn(),
|
||||
view: {
|
||||
dispatch,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
editor: {
|
||||
_tiptapEditor: tiptap,
|
||||
},
|
||||
tiptap,
|
||||
tr,
|
||||
descendants,
|
||||
dispatch,
|
||||
getUpdateHandler: () => updateHandler,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFilenameAutolinkGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('removes accidental filename link marks and tags the transaction to avoid loops', () => {
|
||||
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(true)
|
||||
const fixture = createEditor({
|
||||
nodes: [{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 8,
|
||||
text: 'draft.md',
|
||||
marks: [{
|
||||
type: 'link-mark',
|
||||
attrs: { href: 'draft.md' },
|
||||
}],
|
||||
},
|
||||
pos: 4,
|
||||
}],
|
||||
})
|
||||
|
||||
const { unmount } = render(<Harness editor={fixture.editor} />)
|
||||
const updateHandler = fixture.getUpdateHandler()
|
||||
|
||||
expect(updateHandler).toBeTypeOf('function')
|
||||
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.descendants).toHaveBeenCalledTimes(1)
|
||||
expect(fixture.tr.removeMark).toHaveBeenCalledWith(4, 12, 'link-mark')
|
||||
expect(fixture.tr.setMeta).toHaveBeenCalledWith('tolaria-filename-autolink-guard', true)
|
||||
expect(fixture.dispatch).toHaveBeenCalledWith(fixture.tr)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(fixture.tiptap.off).toHaveBeenCalledWith('update', updateHandler)
|
||||
})
|
||||
|
||||
it('skips guard runs that are already tagged or have no document changes', () => {
|
||||
const fixture = createEditor({ nodes: [] })
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
const updateHandler = fixture.getUpdateHandler()
|
||||
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: false,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => true),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.descendants).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dispatch when the stripped ranges leave the document unchanged', () => {
|
||||
shouldStripAutoLinkedLocalFileMarkMock.mockReturnValue(true)
|
||||
const fixture = createEditor({
|
||||
docChanged: false,
|
||||
nodes: [{
|
||||
node: {
|
||||
isText: true,
|
||||
nodeSize: 8,
|
||||
text: 'draft.md',
|
||||
marks: [{
|
||||
type: 'link-mark',
|
||||
attrs: { href: 'draft.md' },
|
||||
}],
|
||||
},
|
||||
pos: 10,
|
||||
}],
|
||||
})
|
||||
|
||||
render(<Harness editor={fixture.editor} />)
|
||||
const updateHandler = fixture.getUpdateHandler()
|
||||
|
||||
updateHandler?.({
|
||||
transaction: {
|
||||
docChanged: true,
|
||||
getMeta: vi.fn(() => undefined),
|
||||
},
|
||||
})
|
||||
|
||||
expect(fixture.tr.removeMark).toHaveBeenCalledWith(10, 18, 'link-mark')
|
||||
expect(fixture.tr.setMeta).not.toHaveBeenCalled()
|
||||
expect(fixture.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
161
src/extensions/zoomCursorFix.behavior.test.ts
Normal file
161
src/extensions/zoomCursorFix.behavior.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
113
src/extensions/zoomCursorFix.extra.test.ts
Normal file
113
src/extensions/zoomCursorFix.extra.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
93
src/hooks/editorFocusUtils.extra.test.ts
Normal file
93
src/hooks/editorFocusUtils.extra.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
201
src/hooks/useAutoSync.extra.test.ts
Normal file
201
src/hooks/useAutoSync.extra.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
191
src/hooks/usePropertyPanelState.extra.test.ts
Normal file
191
src/hooks/usePropertyPanelState.extra.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
202
src/hooks/usePropertyPanelState.test.ts
Normal file
202
src/hooks/usePropertyPanelState.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
173
src/hooks/useStatusBarAddRemote.test.ts
Normal file
173
src/hooks/useStatusBarAddRemote.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
298
src/hooks/useVaultLoader.extra.test.ts
Normal file
298
src/hooks/useVaultLoader.extra.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
243
src/lib/aiAgentConversation.test.ts
Normal file
243
src/lib/aiAgentConversation.test.ts
Normal 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"}',
|
||||
}],
|
||||
})
|
||||
})
|
||||
})
|
||||
242
src/lib/aiAgentSession.test.ts
Normal file
242
src/lib/aiAgentSession.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
195
src/lib/aiAgentStreamCallbacks.test.ts
Normal file
195
src/lib/aiAgentStreamCallbacks.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
199
src/mock-tauri/mock-handlers.coverage.test.ts
Normal file
199
src/mock-tauri/mock-handlers.coverage.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
183
src/mock-tauri/mock-handlers.more.test.ts
Normal file
183
src/mock-tauri/mock-handlers.more.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
243
src/utils/noteListHelpers.extra.test.ts
Normal file
243
src/utils/noteListHelpers.extra.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
63
src/utils/noteOpenPerformance.extra.test.ts
Normal file
63
src/utils/noteOpenPerformance.extra.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
84
src/utils/noteOpenPerformance.test.ts
Normal file
84
src/utils/noteOpenPerformance.test.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
156
src/utils/streamAiAgent.test.ts
Normal file
156
src/utils/streamAiAgent.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user