diff --git a/src/components/ConflictResolverModal.extra.test.tsx b/src/components/ConflictResolverModal.extra.test.tsx new file mode 100644 index 00000000..3f924d17 --- /dev/null +++ b/src/components/ConflictResolverModal.extra.test.tsx @@ -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 = {}) { + 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() + 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() + }) +}) diff --git a/src/components/ConflictResolverModal.keyboard.test.tsx b/src/components/ConflictResolverModal.keyboard.test.tsx new file mode 100644 index 00000000..8b75d66a --- /dev/null +++ b/src/components/ConflictResolverModal.keyboard.test.tsx @@ -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 ? ( +
+ {children} + +
+ ) : null + ), + DialogContent: ({ + children, + onKeyDown, + }: { + children: React.ReactNode + onKeyDown?: (event: React.KeyboardEvent) => void + }) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + ...props + }: React.ButtonHTMLAttributes) => , +})) + +import { ConflictResolverModal } from './ConflictResolverModal' + +function makeFileStates(overrides?: Partial[]): 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> = {}) { + const props: React.ComponentProps = { + open: true, + fileStates: makeFileStates(), + allResolved: false, + committing: false, + error: null, + onResolveFile: vi.fn(), + onOpenInEditor: vi.fn(), + onCommit: vi.fn(), + onClose: vi.fn(), + ...overrides, + } + + render() + 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() + }) +}) diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index 48b717f0..f2d0e788 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -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')) diff --git a/src/components/PropertyValueCells.extra.test.tsx b/src/components/PropertyValueCells.extra.test.tsx new file mode 100644 index 00000000..35141149 --- /dev/null +++ b/src/components/PropertyValueCells.extra.test.tsx @@ -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 }) => ( + + ), +})) + +vi.mock('./EditableValue', () => ({ + EditableValue: createValueButtonMock('editable-value', (value) => `${value}-saved`), + TagPillList: ({ + items, + label, + onSave, + }: { + items: string[] + label: string + onSave: (items: string[]) => void + }) => ( + + ), + UrlValue: createValueButtonMock('url-value', (value) => `${value}/updated`), +})) + +vi.mock('./StatusDropdown', () => ({ + StatusDropdown: ({ + onSave, + onCancel, + }: { + onSave: (value: string) => void + onCancel: () => void + }) => ( +
+ + +
+ ), +})) + +vi.mock('./TagsDropdown', () => ({ + TagsDropdown: ({ + onToggle, + onClose, + }: { + onToggle: (tag: string) => void + onClose: () => void + }) => ( +
+ + + +
+ ), +})) + +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 }) => ( +
+ + +
+ ), +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ + children, + onOpenChange, + }: { + children: ReactNode + onOpenChange?: (open: boolean) => void + }) => ( +
+ {children} + +
+ ), + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), +})) + +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( + , + ) + + 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( + , + ) + + 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( + , + ) + + fireEvent.click(screen.getByTestId('number-display')) + expect(onStartEdit).toHaveBeenCalledWith('Count') + + rerender( + , + ) + + fireEvent.change(screen.getByTestId('number-input'), { target: { value: 'oops' } }) + fireEvent.keyDown(screen.getByTestId('number-input'), { key: 'Escape' }) + + expect(onStartEdit).toHaveBeenCalledWith(null) + + rerender( + , + ) + + 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( + , + ) + + const checkbox = screen.getByRole('checkbox') as HTMLInputElement + expect(checkbox.checked).toBe(false) + + fireEvent.click(checkbox) + expect(onUpdate).toHaveBeenCalledWith('Published', true) + + rerender( + , + ) + + expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(false) + + rerender( + , + ) + + 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( + , + ) + + fireEvent.click(screen.getByTestId('tags-toggle-beta')) + + expect(onSaveList).toHaveBeenCalledWith('Category', ['solo', 'beta']) + }) +}) diff --git a/src/components/PropertyValueCells.test.tsx b/src/components/PropertyValueCells.test.tsx new file mode 100644 index 00000000..bd8b47ff --- /dev/null +++ b/src/components/PropertyValueCells.test.tsx @@ -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 }>, + ) => ( +
+ {actions.map(({ testId, label, onClick }) => ( + + ))} +
+ ) + + const createDropdownModule = ( + exportName: string, + containerTestId: string, + createActions: (callbacks: Record void>) => Array<{ + testId: string + label: string + onClick: () => void + }>, + ) => ({ + [exportName]: (callbacks: Record 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 }) => ( + + ), + TagPillList: ({ + items, + label, + onSave, + }: { + items: string[] + label: string + onSave: (items: string[]) => void + }) => ( + + ), + UrlValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => ( + + ), +})) + +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 }) => ( + + ), +})) + +vi.mock('./IconEditableValue', () => ({ + IconEditableValue: ({ value, onSave }: { value: string; onSave: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/components/ui/calendar', () => ({ + Calendar: ({ onSelect }: { onSelect: (value: Date) => void }) => ( + + ), +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverContent: ({ + children, + ...props + }: React.HTMLAttributes) =>
{children}
, +})) + +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( + , + ) + + 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( + , + ) + + 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( + , + ) + + fireEvent.click(screen.getByTestId('status-save')) + fireEvent.click(screen.getByTestId('status-cancel')) + + expect(onSave).toHaveBeenCalledWith('Status', 'Done') + expect(onStartEdit).toHaveBeenCalledWith(null) + + rerender( + , + ) + + 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( + , + ) + + fireEvent.change(screen.getByTestId('number-input'), { target: { value: '' } }) + fireEvent.blur(screen.getByTestId('number-input')) + expect(onSave).toHaveBeenCalledWith('Count', '') + + rerender( + , + ) + + fireEvent.change(screen.getByTestId('number-input'), { target: { value: '19' } }) + fireEvent.keyDown(screen.getByTestId('number-input'), { key: 'Enter' }) + expect(onSave).toHaveBeenCalledWith('Count', '19') + + rerender( + , + ) + + fireEvent.change(screen.getByTestId('number-input'), { target: { value: 'nope' } }) + fireEvent.blur(screen.getByTestId('number-input')) + expect(onStartEdit).toHaveBeenCalledWith(null) + + rerender( + , + ) + + fireEvent.click(screen.getByTestId('date-picker-calendar')) + expect(onSave).toHaveBeenCalledWith('Due', '2026-04-22') + + rerender( + , + ) + + 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( + , + ) + + fireEvent.click(screen.getByRole('checkbox')) + expect(onUpdate).toHaveBeenCalledWith('Flag', false) + + rerender( + , + ) + fireEvent.click(screen.getByTestId('url-value')) + expect(onSave).toHaveBeenCalledWith('Website', 'https://saved.example') + + rerender( + , + ) + fireEvent.click(screen.getByTestId('color-value')) + expect(onSave).toHaveBeenCalledWith('Accent', '#00ff00') + + rerender( + , + ) + fireEvent.click(screen.getByTestId('icon-value')) + expect(onSave).toHaveBeenCalledWith('_icon', 'sparkles') + + rerender( + , + ) + fireEvent.click(screen.getByTestId('tag-pill-list')) + expect(onSaveList).toHaveBeenCalledWith('Labels', ['alpha', 'beta', 'gamma']) + }) +}) diff --git a/src/components/RawEditorView.behavior.test.tsx b/src/components/RawEditorView.behavior.test.tsx new file mode 100644 index 00000000..6a9ea2ad --- /dev/null +++ b/src/components/RawEditorView.behavior.test.tsx @@ -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 }, + replaceActiveWikilinkQueryMock: vi.fn(), + trackEventMock: vi.fn(), + useCodeMirrorMock: vi.fn(), + viewRefState: { current: null as null | Record }, +})) + +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 ( +
+ {props.items.map((item, index) => ( + + ))} +
{props.selectedIndex}
+
+ ) + }, +})) + +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 + + const { rerender, unmount } = render( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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) + }) +}) diff --git a/src/components/RawEditorView.coverage.test.tsx b/src/components/RawEditorView.coverage.test.tsx new file mode 100644 index 00000000..a6ab03e7 --- /dev/null +++ b/src/components/RawEditorView.coverage.test.tsx @@ -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 + focus: ReturnType + 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 + }) => ( +
+ {items.map((item, index) => ( + + ))} +
+ ), +})) + +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 = {}) { + const props = { ...defaultProps, ...overrides } + render() + 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( + , + ) + + 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() + }) +}) diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx new file mode 100644 index 00000000..6438dd31 --- /dev/null +++ b/src/components/SingleEditorView.test.tsx @@ -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, + capturedSuggestionProps: {} as Record>, + capturedImageDropArgs: null as null | Record, + 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 ( +
+ {children} +
+ ) + }, + SideMenuController: () =>
, + SuggestionMenuController: (props: Record) => { + state.capturedSuggestionProps[String(props.triggerCharacter)] = props + return
+ }, + useCreateBlockNote: vi.fn(), +})) + +vi.mock('@blocknote/mantine', () => ({ + components: {}, +})) + +vi.mock('@mantine/core', async () => { + const React = await vi.importActual('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) => { + state.capturedImageDropArgs = args + return state.imageDropState + }, +})) + +vi.mock('../utils/typeColors', () => ({ + buildTypeEntryMap: () => ({}), +})) + +vi.mock('../utils/wikilinkSuggestions', () => ({ + MIN_QUERY_LENGTH: 2, + deduplicateByPath: (items: T[]) => items, + preFilterWikilinks: () => [], +})) + +vi.mock('../utils/personMentionSuggestions', () => ({ + PERSON_MENTION_MIN_QUERY: 1, + filterPersonMentions: () => [], +})) + +vi.mock('../utils/suggestionEnrichment', () => ({ + attachClickHandlers: (items: T[]) => items, + enrichSuggestionItems: (items: T[]) => items, +})) + +vi.mock('./WikilinkSuggestionMenu', () => ({ + WikilinkSuggestionMenu: () =>
, +})) + +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: () =>
, + TolariaFormattingToolbarController: (props: Record) => { + state.capturedToolbarProps = props + return
+ }, +})) + +vi.mock('./tolariaBlockNoteSideMenu', () => ({ + TolariaSideMenu: () =>
, +})) + +vi.mock('./useEditorLinkActivation', () => ({ + useEditorLinkActivation: (containerRef: unknown, onNavigateWikilink: unknown) => ( + state.linkActivationMock(containerRef, onNavigateWikilink) + ), +})) + +import { SingleEditorView } from './SingleEditorView' + +function makeEntry(overrides: Partial = {}): 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(() => 'seeded
'), + _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( + , + ) + + 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('seeded
') + 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( + , + ) + + 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( + , + ) + + 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() + }) +}) diff --git a/src/components/StatusDropdown.extra.test.tsx b/src/components/StatusDropdown.extra.test.tsx new file mode 100644 index 00000000..f650f2e4 --- /dev/null +++ b/src/components/StatusDropdown.extra.test.tsx @@ -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( + , + ) + + 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( + , + ) + + 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() + }) +}) diff --git a/src/components/blockNoteFormattingToolbarHoverGuard.extra.test.ts b/src/components/blockNoteFormattingToolbarHoverGuard.extra.test.ts new file mode 100644 index 00000000..d088986e --- /dev/null +++ b/src/components/blockNoteFormattingToolbarHoverGuard.extra.test.ts @@ -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 +}) { + 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() + }) +}) diff --git a/src/components/blockNoteFormattingToolbarHoverGuard.test.ts b/src/components/blockNoteFormattingToolbarHoverGuard.test.ts index f53e0382..aa2549ae 100644 --- a/src/components/blockNoteFormattingToolbarHoverGuard.test.ts +++ b/src/components/blockNoteFormattingToolbarHoverGuard.test.ts @@ -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 = {}) { + 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() }) }) diff --git a/src/components/inlineWikilinkDom.test.ts b/src/components/inlineWikilinkDom.test.ts new file mode 100644 index 00000000..d6ab20b7 --- /dev/null +++ b/src/components/inlineWikilinkDom.test.ts @@ -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) + }) +}) diff --git a/src/components/note-list/noteListHooks.extra.test.tsx b/src/components/note-list/noteListHooks.extra.test.tsx new file mode 100644 index 00000000..17a4f95c --- /dev/null +++ b/src/components/note-list/noteListHooks.extra.test.tsx @@ -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 = {} + 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, + }, + prefetchNoteContentMock: vi.fn(), + routeNoteClickMock: vi.fn(), +})) + +vi.mock('../../hooks/useMultiSelect', () => ({ + useMultiSelect: () => multiSelectState, +})) + +vi.mock('../../hooks/useNoteListKeyboard', () => ({ + useNoteListKeyboard: (options: Record) => { + 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('./noteListUtils') + return { + ...actual, + routeNoteClick: (...args: unknown[]) => routeNoteClickMock(...args), + } +}) + +function makeEntry(overrides: Partial = {}): 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) +} + +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(), + 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(), + 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(), + 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 + } + + 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) + }) +}) diff --git a/src/components/status-bar/StatusBarBadges.extra.test.tsx b/src/components/status-bar/StatusBarBadges.extra.test.tsx new file mode 100644 index 00000000..195bac1f --- /dev/null +++ b/src/components/status-bar/StatusBarBadges.extra.test.tsx @@ -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) => ( + + ), +})) + +vi.mock('@/lib/utils', () => ({ + cn: (...values: Array) => 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( + , + ) + + fireEvent.click(screen.getByTestId('status-commit-link')) + expect(openExternalUrlMock).toHaveBeenCalledWith('https://example.com/commit/abc1234') + + rerender() + expect(screen.getByTestId('status-commit-hash')).toHaveTextContent('def5678') + }) + + it('renders actionable and passive no-remote badges', () => { + const onAddRemote = vi.fn() + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByTestId('status-no-remote')) + expect(onAddRemote).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + fireEvent.click(screen.getByTestId('status-sync')) + expect(onOpenConflictResolver).toHaveBeenCalledTimes(1) + expect(screen.queryByTestId('git-status-popup')).not.toBeInTheDocument() + + rerender( + , + ) + + fireEvent.click(screen.getByTestId('status-sync')) + expect(onPullAndPush).toHaveBeenCalledTimes(1) + }) + + it('renders clickable conflict badges with plural copy', () => { + const onClick = vi.fn() + render() + + fireEvent.click(screen.getByTestId('status-conflict-count')) + expect(onClick).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('status-conflict-count')).toHaveTextContent('2 conflicts') + }) +}) diff --git a/src/components/tolariaEditorFormatting.behavior.test.tsx b/src/components/tolariaEditorFormatting.behavior.test.tsx new file mode 100644 index 00000000..cd57bd46 --- /dev/null +++ b/src/components/tolariaEditorFormatting.behavior.test.tsx @@ -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 }, + showState: { value: true }, + useBlockNoteEditorMock: vi.fn(), +})) + +function MockIcon() { + return +} + +vi.mock('@blocknote/react', () => ({ + FormattingToolbar: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), + getFormattingToolbarItems: () => [ +
, +
, +
, +
, +
, + ], + PositionPopover: (props: Record & { children?: ReactNode }) => { + positionPopoverState.lastProps = props + return
{props.children}
+ }, + useBlockNoteEditor: useBlockNoteEditorMock, + useComponentsContext: () => ({ + FormattingToolbar: { + Button: ({ + children, + icon, + label, + onClick, + }: { + children?: ReactNode + icon?: ReactNode + label: string + onClick: () => void + }) => ( + + ), + }, + }), + 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 }) => , + CheckIcon: () => check, + Menu: Object.assign( + ({ children }: { children?: ReactNode }) =>
{children}
, + { + Target: ({ children }: { children?: ReactNode }) => <>{children}, + Dropdown: ({ children, ...props }: { children?: ReactNode }) =>
{children}
, + Item: ({ children, ...props }: { children?: ReactNode }) => , + }, + ), +})) + +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() + + 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 = () =>
Toolbar
+ useBlockNoteEditorMock.mockReturnValue(editor) + + render( + , + ) + + 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() + + 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() + 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() + + showState.value = false + rerender() + + 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( + } + />, + ) + + 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) + }) +}) diff --git a/src/components/useFilenameAutolinkGuard.extra.test.tsx b/src/components/useFilenameAutolinkGuard.extra.test.tsx new file mode 100644 index 00000000..65644140 --- /dev/null +++ b/src/components/useFilenameAutolinkGuard.extra.test.tsx @@ -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() + + 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() + 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() + 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() + }) +}) diff --git a/src/components/useFilenameAutolinkGuard.test.tsx b/src/components/useFilenameAutolinkGuard.test.tsx new file mode 100644 index 00000000..376a5f6f --- /dev/null +++ b/src/components/useFilenameAutolinkGuard.test.tsx @@ -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() + 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() + 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() + 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() + }) +}) diff --git a/src/extensions/zoomCursorFix.behavior.test.ts b/src/extensions/zoomCursorFix.behavior.test.ts new file mode 100644 index 00000000..b512557b --- /dev/null +++ b/src/extensions/zoomCursorFix.behavior.test.ts @@ -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['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() + }) +}) diff --git a/src/extensions/zoomCursorFix.extra.test.ts b/src/extensions/zoomCursorFix.extra.test.ts new file mode 100644 index 00000000..9474e7a8 --- /dev/null +++ b/src/extensions/zoomCursorFix.extra.test.ts @@ -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) + }) +}) diff --git a/src/hooks/editorFocusUtils.extra.test.ts b/src/hooks/editorFocusUtils.extra.test.ts new file mode 100644 index 00000000..d907c685 --- /dev/null +++ b/src/hooks/editorFocusUtils.extra.test.ts @@ -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) + }) +}) diff --git a/src/hooks/useAutoSync.extra.test.ts b/src/hooks/useAutoSync.extra.test.ts new file mode 100644 index 00000000..4cf03956 --- /dev/null +++ b/src/hooks/useAutoSync.extra.test.ts @@ -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[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['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') + }) +}) diff --git a/src/hooks/useAutoSync.test.ts b/src/hooks/useAutoSync.test.ts index 029e0c76..da0707fd 100644 --- a/src/hooks/useAutoSync.test.ts +++ b/src/hooks/useAutoSync.test.ts @@ -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') + }) }) diff --git a/src/hooks/usePropertyPanelState.extra.test.ts b/src/hooks/usePropertyPanelState.extra.test.ts new file mode 100644 index 00000000..2cab94a0 --- /dev/null +++ b/src/hooks/usePropertyPanelState.extra.test.ts @@ -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, +})) + +vi.mock('../components/DynamicPropertiesPanel', () => ({ + containsWikilinks: (value: unknown) => typeof value === 'string' && value.includes('[['), +})) + +vi.mock('../utils/propertyTypes', async () => { + const actual = await vi.importActual('../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) => ( + overrides[key] ?? actual.detectPropertyType(key, value as never) + )), + } +}) + +import { + loadDisplayModeOverrides, + removeDisplayModeOverride, + saveDisplayModeOverride, +} from '../utils/propertyTypes' +import { usePropertyPanelState } from './usePropertyPanelState' + +function entry(overrides: Partial = {}): 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) + }) +}) diff --git a/src/hooks/usePropertyPanelState.test.ts b/src/hooks/usePropertyPanelState.test.ts new file mode 100644 index 00000000..ac7e062c --- /dev/null +++ b/src/hooks/usePropertyPanelState.test.ts @@ -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 = {} + 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 { + 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() + }) +}) diff --git a/src/hooks/useStatusBarAddRemote.test.ts b/src/hooks/useStatusBarAddRemote.test.ts new file mode 100644 index 00000000..c40bdf50 --- /dev/null +++ b/src/hooks/useStatusBarAddRemote.test.ts @@ -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)) + }) +}) diff --git a/src/hooks/useVaultLoader.extra.test.ts b/src/hooks/useVaultLoader.extra.test.ts new file mode 100644 index 00000000..e4cd5926 --- /dev/null +++ b/src/hooks/useVaultLoader.extra.test.ts @@ -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) => backendInvokeFn(command, args), +})) + +vi.mock('./useTabManagement', () => ({ + clearPrefetchCache: () => clearPrefetchCache(), +})) + +function makeEntry(overrides: Partial = {}): 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 { + 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> = {}) { + const defaults: Record = { + 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, 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([]) + }) +}) diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 2a40a163..87863322 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -76,6 +76,17 @@ function buildVaultLoaderMock(options: { }) as typeof defaultMockInvoke } +function buildReloadVaultPathMock(loads: Record>) { + return ((cmd: string, args?: Record) => { + 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() { let resolve!: (value: T | PromiseLike) => void let reject!: (reason?: unknown) => void @@ -184,18 +195,10 @@ describe('useVaultLoader', () => { const firstLoad = createDeferred() const secondLoad = createDeferred() - backendInvokeFn.mockImplementation(((cmd: string, args?: Record) => { - 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') }) diff --git a/src/lib/aiAgentConversation.test.ts b/src/lib/aiAgentConversation.test.ts new file mode 100644 index 00000000..6d8c421a --- /dev/null +++ b/src/lib/aiAgentConversation.test.ts @@ -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"}', + }], + }) + }) +}) diff --git a/src/lib/aiAgentSession.test.ts b/src/lib/aiAgentSession.test.ts new file mode 100644 index 00000000..2f474348 --- /dev/null +++ b/src/lib/aiAgentSession.test.ts @@ -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') + }) +}) diff --git a/src/lib/aiAgentStreamCallbacks.test.ts b/src/lib/aiAgentStreamCallbacks.test.ts new file mode 100644 index 00000000..8b10bbc9 --- /dev/null +++ b/src/lib/aiAgentStreamCallbacks.test.ts @@ -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() } + + 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() + }) +}) diff --git a/src/mock-tauri/mock-handlers.coverage.test.ts b/src/mock-tauri/mock-handlers.coverage.test.ts new file mode 100644 index 00000000..38f5c240 --- /dev/null +++ b/src/mock-tauri/mock-handlers.coverage.test.ts @@ -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 + + 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') + }) +}) diff --git a/src/mock-tauri/mock-handlers.more.test.ts b/src/mock-tauri/mock-handlers.more.test.ts new file mode 100644 index 00000000..06939e50 --- /dev/null +++ b/src/mock-tauri/mock-handlers.more.test.ts @@ -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 + 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() + }) +}) diff --git a/src/utils/noteListHelpers.extra.test.ts b/src/utils/noteListHelpers.extra.test.ts new file mode 100644 index 00000000..e6a05302 --- /dev/null +++ b/src/utils/noteListHelpers.extra.test.ts @@ -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 = {} + 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, + }) + }) +}) diff --git a/src/utils/noteOpenPerformance.extra.test.ts b/src/utils/noteOpenPerformance.extra.test.ts new file mode 100644 index 00000000..d4f3e09f --- /dev/null +++ b/src/utils/noteOpenPerformance.extra.test.ts @@ -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() + }) +}) diff --git a/src/utils/noteOpenPerformance.test.ts b/src/utils/noteOpenPerformance.test.ts new file mode 100644 index 00000000..a704e38c --- /dev/null +++ b/src/utils/noteOpenPerformance.test.ts @@ -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', + ) + }) +}) diff --git a/src/utils/streamAiAgent.test.ts b/src/utils/streamAiAgent.test.ts new file mode 100644 index 00000000..c9d28057 --- /dev/null +++ b/src/utils/streamAiAgent.test.ts @@ -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: '\n[user]: first\n\n[user]: latest\n', + 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) + }) +})