When editing type/status/color/icon in the raw editor (Cmd+\), changes now immediately flow into the reactive VaultEntry state, updating the Properties panel, note list, and sidebar without save/reload. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1136 lines
41 KiB
TypeScript
1136 lines
41 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { renderHook, act } from '@testing-library/react'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import { isTauri, mockInvoke } from '../mock-tauri'
|
|
import type { VaultEntry } from '../types'
|
|
import {
|
|
slugify,
|
|
buildNewEntry,
|
|
generateUntitledName,
|
|
entryMatchesTarget,
|
|
buildNoteContent,
|
|
resolveNewNote,
|
|
resolveNewType,
|
|
todayDateString,
|
|
buildDailyNoteContent,
|
|
resolveDailyNote,
|
|
findDailyNote,
|
|
DEFAULT_TEMPLATES,
|
|
resolveTemplate,
|
|
} from './useNoteCreation'
|
|
import { needsRenameOnSave } from './useNoteRename'
|
|
import { frontmatterToEntryPatch, applyRelationshipPatch, contentToEntryPatch } from './frontmatterOps'
|
|
import { useNoteActions } from './useNoteActions'
|
|
import type { NoteActionsConfig } from './useNoteActions'
|
|
|
|
// Mock dependencies
|
|
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
|
vi.mock('../mock-tauri', () => ({
|
|
isTauri: vi.fn(() => false),
|
|
addMockEntry: vi.fn(),
|
|
updateMockContent: vi.fn(),
|
|
trackMockChange: vi.fn(),
|
|
mockInvoke: vi.fn().mockResolvedValue(''),
|
|
}))
|
|
vi.mock('./mockFrontmatterHelpers', () => ({
|
|
updateMockFrontmatter: vi.fn().mockReturnValue('---\nupdated: true\n---\n'),
|
|
deleteMockFrontmatterProperty: vi.fn().mockReturnValue('---\n---\n'),
|
|
}))
|
|
|
|
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
|
path: '/Users/luca/Laputa/test.md',
|
|
filename: 'test.md',
|
|
title: 'Test Note',
|
|
isA: 'Note',
|
|
aliases: [],
|
|
belongsTo: [],
|
|
relatedTo: [],
|
|
status: 'Active',
|
|
archived: false,
|
|
trashed: false,
|
|
trashedAt: null,
|
|
modifiedAt: 1700000000,
|
|
createdAt: 1700000000,
|
|
fileSize: 100,
|
|
snippet: '',
|
|
wordCount: 0,
|
|
relationships: {},
|
|
icon: null,
|
|
color: null,
|
|
order: null,
|
|
outgoingLinks: [],
|
|
template: null, sort: null,
|
|
...overrides,
|
|
})
|
|
|
|
describe('slugify', () => {
|
|
it('converts text to lowercase kebab-case', () => {
|
|
expect(slugify('Hello World')).toBe('hello-world')
|
|
})
|
|
|
|
it('removes special characters', () => {
|
|
expect(slugify('My Project! @#$%')).toBe('my-project')
|
|
})
|
|
|
|
it('strips leading and trailing hyphens', () => {
|
|
expect(slugify('--hello--')).toBe('hello')
|
|
})
|
|
|
|
it('handles empty string with fallback', () => {
|
|
expect(slugify('')).toBe('untitled')
|
|
})
|
|
|
|
it('collapses multiple separators into one hyphen', () => {
|
|
expect(slugify('hello world---foo')).toBe('hello-world-foo')
|
|
})
|
|
|
|
it('returns fallback for strings with only special characters', () => {
|
|
// slugify('+++') should not return empty string — it causes invalid paths
|
|
expect(slugify('+++')).not.toBe('')
|
|
expect(slugify('!!!')).not.toBe('')
|
|
expect(slugify('---')).not.toBe('')
|
|
expect(slugify('@#$')).not.toBe('')
|
|
})
|
|
})
|
|
|
|
describe('needsRenameOnSave', () => {
|
|
it('returns true when filename does not match title slug', () => {
|
|
expect(needsRenameOnSave('My New Note', 'untitled-note.md')).toBe(true)
|
|
expect(needsRenameOnSave('Run good ads for newsletter', 'untitled-note-9.md')).toBe(true)
|
|
})
|
|
|
|
it('returns false when filename matches title slug', () => {
|
|
expect(needsRenameOnSave('My Note', 'my-note.md')).toBe(false)
|
|
expect(needsRenameOnSave('Hello World', 'hello-world.md')).toBe(false)
|
|
})
|
|
|
|
it('returns false for untitled note with matching slug', () => {
|
|
expect(needsRenameOnSave('Untitled note', 'untitled-note.md')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('buildNewEntry', () => {
|
|
it('creates a VaultEntry with correct fields', () => {
|
|
const entry = buildNewEntry({
|
|
path: '/vault/my-note.md',
|
|
slug: 'my-note',
|
|
title: 'My Note',
|
|
type: 'Note',
|
|
status: 'Active',
|
|
})
|
|
|
|
expect(entry.path).toBe('/vault/my-note.md')
|
|
expect(entry.filename).toBe('my-note.md')
|
|
expect(entry.title).toBe('My Note')
|
|
expect(entry.isA).toBe('Note')
|
|
expect(entry.status).toBe('Active')
|
|
expect(entry.archived).toBe(false)
|
|
expect(entry.trashed).toBe(false)
|
|
expect(entry.modifiedAt).toBeGreaterThan(0)
|
|
expect(entry.createdAt).toBe(entry.modifiedAt)
|
|
})
|
|
|
|
it('sets null status when provided', () => {
|
|
const entry = buildNewEntry({
|
|
path: '/vault/topic/ai.md',
|
|
slug: 'ai',
|
|
title: 'AI',
|
|
type: 'Topic',
|
|
status: null,
|
|
})
|
|
expect(entry.status).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('generateUntitledName', () => {
|
|
it('returns base name when no conflicts', () => {
|
|
expect(generateUntitledName([], 'Note')).toBe('Untitled note')
|
|
})
|
|
|
|
it('appends counter when base name exists', () => {
|
|
const entries = [makeEntry({ title: 'Untitled note' })]
|
|
expect(generateUntitledName(entries, 'Note')).toBe('Untitled note 2')
|
|
})
|
|
|
|
it('increments counter past existing numbered entries', () => {
|
|
const entries = [
|
|
makeEntry({ title: 'Untitled note' }),
|
|
makeEntry({ title: 'Untitled note 2' }),
|
|
makeEntry({ title: 'Untitled note 3' }),
|
|
]
|
|
expect(generateUntitledName(entries, 'Note')).toBe('Untitled note 4')
|
|
})
|
|
|
|
it('uses type name in lowercase', () => {
|
|
expect(generateUntitledName([], 'Project')).toBe('Untitled project')
|
|
})
|
|
|
|
it('avoids names in the pending set', () => {
|
|
const pending = new Set(['Untitled note'])
|
|
expect(generateUntitledName([], 'Note', pending)).toBe('Untitled note 2')
|
|
})
|
|
|
|
it('avoids both existing and pending names', () => {
|
|
const entries = [makeEntry({ title: 'Untitled note' })]
|
|
const pending = new Set(['Untitled note 2'])
|
|
expect(generateUntitledName(entries, 'Note', pending)).toBe('Untitled note 3')
|
|
})
|
|
})
|
|
|
|
describe('entryMatchesTarget', () => {
|
|
it('matches by exact title (case-insensitive)', () => {
|
|
const entry = makeEntry({ title: 'My Project' })
|
|
expect(entryMatchesTarget(entry, 'my project')).toBe(true)
|
|
})
|
|
|
|
it('matches by alias', () => {
|
|
const entry = makeEntry({ aliases: ['MP', 'TheProject'] })
|
|
expect(entryMatchesTarget(entry, 'mp')).toBe(true)
|
|
})
|
|
|
|
it('matches legacy path-style target via filename stem', () => {
|
|
const entry = makeEntry({ filename: 'my-project.md' })
|
|
expect(entryMatchesTarget(entry, 'project/my-project')).toBe(true)
|
|
})
|
|
|
|
it('matches by filename stem', () => {
|
|
const entry = makeEntry({ filename: 'my-project.md' })
|
|
expect(entryMatchesTarget(entry, 'my-project')).toBe(true)
|
|
})
|
|
|
|
it('matches when target as words matches title', () => {
|
|
const entry = makeEntry({ title: 'my project' })
|
|
expect(entryMatchesTarget(entry, 'my-project')).toBe(true)
|
|
})
|
|
|
|
it('returns false when nothing matches', () => {
|
|
const entry = makeEntry({ title: 'Something Else', aliases: [], filename: 'else.md' })
|
|
expect(entryMatchesTarget(entry, 'nonexistent')).toBe(false)
|
|
})
|
|
|
|
it('handles pipe syntax targets', () => {
|
|
const entry = makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha' })
|
|
expect(entryMatchesTarget(entry, 'project/alpha|Alpha Project')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('buildNoteContent', () => {
|
|
it('generates frontmatter with status for regular types', () => {
|
|
const content = buildNoteContent('My Note', 'Note', 'Active')
|
|
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n')
|
|
})
|
|
|
|
it('omits status when null', () => {
|
|
const content = buildNoteContent('AI', 'Topic', null)
|
|
expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n\n# AI\n\n')
|
|
})
|
|
|
|
it('includes template body when provided', () => {
|
|
const content = buildNoteContent('My Project', 'Project', 'Active', '## Objective\n\n## Notes\n\n')
|
|
expect(content).toContain('# My Project')
|
|
expect(content).toContain('## Objective')
|
|
expect(content).toContain('## Notes')
|
|
})
|
|
|
|
it('ignores null template', () => {
|
|
const content = buildNoteContent('My Note', 'Note', 'Active', null)
|
|
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n')
|
|
})
|
|
})
|
|
|
|
describe('resolveTemplate', () => {
|
|
it('returns template from type entry when set', () => {
|
|
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n## Steps\n\n' })
|
|
expect(resolveTemplate([typeEntry], 'Recipe')).toBe('## Ingredients\n\n## Steps\n\n')
|
|
})
|
|
|
|
it('falls back to DEFAULT_TEMPLATES for built-in types', () => {
|
|
expect(resolveTemplate([], 'Project')).toBe(DEFAULT_TEMPLATES.Project)
|
|
})
|
|
|
|
it('returns null when no template and no default', () => {
|
|
expect(resolveTemplate([], 'CustomType')).toBeNull()
|
|
})
|
|
|
|
it('type entry template overrides default', () => {
|
|
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom\n\n' })
|
|
expect(resolveTemplate([typeEntry], 'Project')).toBe('## Custom\n\n')
|
|
})
|
|
})
|
|
|
|
describe('DEFAULT_TEMPLATES', () => {
|
|
it('has templates for Project, Person, Responsibility, Experiment', () => {
|
|
expect(DEFAULT_TEMPLATES.Project).toBeDefined()
|
|
expect(DEFAULT_TEMPLATES.Person).toBeDefined()
|
|
expect(DEFAULT_TEMPLATES.Responsibility).toBeDefined()
|
|
expect(DEFAULT_TEMPLATES.Experiment).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('resolveNewNote', () => {
|
|
it('creates note at vault root', () => {
|
|
const { entry, content } = resolveNewNote('My Project', 'Project', '/my/vault')
|
|
expect(entry.path).toBe('/my/vault/my-project.md')
|
|
expect(entry.isA).toBe('Project')
|
|
expect(entry.status).toBe('Active')
|
|
expect(content).toContain('type: Project')
|
|
expect(content).toContain('status: Active')
|
|
})
|
|
|
|
it('creates custom type note at vault root', () => {
|
|
const { entry } = resolveNewNote('First Recipe', 'Recipe', '/my/vault')
|
|
expect(entry.path).toBe('/my/vault/first-recipe.md')
|
|
})
|
|
|
|
it('omits status for Topic type', () => {
|
|
const { entry, content } = resolveNewNote('Machine Learning', 'Topic', '/my/vault')
|
|
expect(entry.status).toBeNull()
|
|
expect(content).not.toContain('status:')
|
|
})
|
|
|
|
it('omits status for Person type', () => {
|
|
const { entry } = resolveNewNote('John Doe', 'Person', '/my/vault')
|
|
expect(entry.status).toBeNull()
|
|
})
|
|
|
|
it('uses provided vault path', () => {
|
|
const { entry } = resolveNewNote('Test', 'Note', '/other/vault')
|
|
expect(entry.path).toBe('/other/vault/test.md')
|
|
})
|
|
|
|
it('produces a valid path for custom types with special characters', () => {
|
|
const { entry } = resolveNewNote('My Note', 'Q&A', '/vault')
|
|
expect(entry.path).not.toContain('//')
|
|
expect(entry.path).toMatch(/\.md$/)
|
|
expect(entry.filename).not.toBe('.md')
|
|
})
|
|
|
|
it('produces a valid path when type is all special characters', () => {
|
|
const { entry } = resolveNewNote('My Note', '+++', '/vault')
|
|
expect(entry.path).not.toContain('//')
|
|
expect(entry.path).toMatch(/\.md$/)
|
|
})
|
|
})
|
|
|
|
describe('resolveNewType', () => {
|
|
it('creates a type entry at vault root', () => {
|
|
const { entry, content } = resolveNewType('Recipe', '/my/vault')
|
|
expect(entry.path).toBe('/my/vault/recipe.md')
|
|
expect(entry.isA).toBe('Type')
|
|
expect(entry.status).toBeNull()
|
|
expect(content).toContain('type: Type')
|
|
expect(content).toContain('# Recipe')
|
|
})
|
|
|
|
it('uses provided vault path instead of hardcoded path', () => {
|
|
const { entry } = resolveNewType('Responsibility', '/other/vault')
|
|
expect(entry.path).toBe('/other/vault/responsibility.md')
|
|
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
|
})
|
|
})
|
|
|
|
describe('frontmatterToEntryPatch', () => {
|
|
it.each([
|
|
['type', 'Project', { isA: 'Project' }],
|
|
['is_a', 'Project', { isA: 'Project' }],
|
|
['status', 'Done', { status: 'Done' }],
|
|
['color', 'red', { color: 'red' }],
|
|
['icon', 'star', { icon: 'star' }],
|
|
['archived', true, { archived: true }],
|
|
['trashed', true, { trashed: true }],
|
|
['order', 5, { order: 5 }],
|
|
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
|
|
['visible', false, { visible: false }],
|
|
['visible', true, { visible: null }],
|
|
] as [string, unknown, Partial<VaultEntry>][])(
|
|
'maps %s update to correct entry field',
|
|
(key, value, expected) => {
|
|
expect(frontmatterToEntryPatch('update', key, value as never).patch).toEqual(expected)
|
|
},
|
|
)
|
|
|
|
it('maps aliases update with array value', () => {
|
|
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B']).patch).toEqual({ aliases: ['A', 'B'] })
|
|
})
|
|
|
|
it('maps belongs_to update with array value', () => {
|
|
const result = frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])
|
|
expect(result.patch).toEqual({ belongsTo: ['[[parent]]'] })
|
|
// Also produces a relationship patch for the wikilink
|
|
expect(result.relationshipPatch).toEqual({ 'belongs_to': ['[[parent]]'] })
|
|
})
|
|
|
|
it('handles case-insensitive keys with spaces (e.g. "Is A", "Belongs to")', () => {
|
|
expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment').patch).toEqual({ isA: 'Experiment' })
|
|
const result = frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])
|
|
expect(result.patch).toEqual({ belongsTo: ['[[x]]'] })
|
|
expect(result.relationshipPatch).toEqual({ 'Belongs to': ['[[x]]'] })
|
|
})
|
|
|
|
it('returns empty patch for unknown non-wikilink keys', () => {
|
|
const result = frontmatterToEntryPatch('update', 'custom_field', 'value')
|
|
expect(result.patch).toEqual({})
|
|
// Non-wikilink value → no relationship change
|
|
expect(result.relationshipPatch).toBeNull()
|
|
})
|
|
|
|
it('produces relationship patch for wikilink values on unknown keys', () => {
|
|
const result = frontmatterToEntryPatch('update', 'Notes', ['[[note-a]]', '[[note-b|Note B]]'])
|
|
expect(result.patch).toEqual({})
|
|
expect(result.relationshipPatch).toEqual({ Notes: ['[[note-a]]', '[[note-b|Note B]]'] })
|
|
})
|
|
|
|
it('produces relationship patch for single wikilink string', () => {
|
|
const result = frontmatterToEntryPatch('update', 'Owner', '[[person/alice]]')
|
|
expect(result.patch).toEqual({})
|
|
expect(result.relationshipPatch).toEqual({ Owner: ['[[person/alice]]'] })
|
|
})
|
|
|
|
it.each([
|
|
['status', { status: null }],
|
|
['color', { color: null }],
|
|
['aliases', { aliases: [] }],
|
|
['archived', { archived: false }],
|
|
['order', { order: null }],
|
|
['template', { template: null }],
|
|
['visible', { visible: null }],
|
|
] as [string, Partial<VaultEntry>][])(
|
|
'maps delete of %s to null/default',
|
|
(key, expected) => {
|
|
expect(frontmatterToEntryPatch('delete', key).patch).toEqual(expected)
|
|
},
|
|
)
|
|
|
|
it('returns empty patch for unknown key on delete, with relationship removal', () => {
|
|
const result = frontmatterToEntryPatch('delete', 'unknown_key')
|
|
expect(result.patch).toEqual({})
|
|
expect(result.relationshipPatch).toEqual({ unknown_key: null })
|
|
})
|
|
|
|
it('delete of known key also produces relationship removal', () => {
|
|
const result = frontmatterToEntryPatch('delete', 'status')
|
|
expect(result.patch).toEqual({ status: null })
|
|
expect(result.relationshipPatch).toEqual({ status: null })
|
|
})
|
|
})
|
|
|
|
describe('applyRelationshipPatch', () => {
|
|
it('adds new relationship key', () => {
|
|
const existing = { 'Belongs to': ['[[eng]]'] }
|
|
const result = applyRelationshipPatch(existing, { Notes: ['[[note-a]]', '[[note-b]]'] })
|
|
expect(result).toEqual({ 'Belongs to': ['[[eng]]'], Notes: ['[[note-a]]', '[[note-b]]'] })
|
|
})
|
|
|
|
it('overwrites existing relationship key', () => {
|
|
const existing = { Notes: ['[[old]]'] }
|
|
const result = applyRelationshipPatch(existing, { Notes: ['[[new-a]]', '[[new-b]]'] })
|
|
expect(result).toEqual({ Notes: ['[[new-a]]', '[[new-b]]'] })
|
|
})
|
|
|
|
it('removes relationship key when value is null', () => {
|
|
const existing = { Notes: ['[[a]]'], Owner: ['[[alice]]'] }
|
|
const result = applyRelationshipPatch(existing, { Notes: null })
|
|
expect(result).toEqual({ Owner: ['[[alice]]'] })
|
|
})
|
|
|
|
it('does not mutate the original map', () => {
|
|
const existing = { Notes: ['[[a]]'] }
|
|
applyRelationshipPatch(existing, { Notes: ['[[b]]'] })
|
|
expect(existing).toEqual({ Notes: ['[[a]]'] })
|
|
})
|
|
})
|
|
|
|
describe('contentToEntryPatch', () => {
|
|
it('extracts type from frontmatter', () => {
|
|
const content = '---\ntype: Project\nstatus: Active\n---\nBody text'
|
|
expect(contentToEntryPatch(content)).toEqual({ isA: 'Project', status: 'Active' })
|
|
})
|
|
|
|
it('returns empty patch when no frontmatter', () => {
|
|
expect(contentToEntryPatch('Just a body')).toEqual({})
|
|
})
|
|
|
|
it('returns empty patch for empty content', () => {
|
|
expect(contentToEntryPatch('')).toEqual({})
|
|
})
|
|
|
|
it('extracts color, icon, and aliases', () => {
|
|
const content = '---\ncolor: red\nicon: star\naliases:\n - Foo\n - Bar\n---\n'
|
|
expect(contentToEntryPatch(content)).toEqual({ color: 'red', icon: 'star', aliases: ['Foo', 'Bar'] })
|
|
})
|
|
|
|
it('handles is_a as alias for type', () => {
|
|
const content = '---\nis_a: Essay\n---\n'
|
|
expect(contentToEntryPatch(content)).toEqual({ isA: 'Essay' })
|
|
})
|
|
|
|
it('ignores unknown frontmatter keys', () => {
|
|
const content = '---\ntype: Note\ncustom: value\n---\n'
|
|
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note' })
|
|
})
|
|
})
|
|
|
|
describe('todayDateString', () => {
|
|
it('returns date in YYYY-MM-DD format', () => {
|
|
const result = todayDateString()
|
|
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
|
})
|
|
})
|
|
|
|
describe('buildDailyNoteContent', () => {
|
|
it('generates frontmatter with Journal type and date', () => {
|
|
const content = buildDailyNoteContent('2026-03-02')
|
|
expect(content).toContain('type: Journal')
|
|
expect(content).toContain('date: 2026-03-02')
|
|
expect(content).toContain('title: 2026-03-02')
|
|
})
|
|
|
|
it('includes Intentions and Reflections sections', () => {
|
|
const content = buildDailyNoteContent('2026-03-02')
|
|
expect(content).toContain('## Intentions')
|
|
expect(content).toContain('## Reflections')
|
|
})
|
|
|
|
it('includes H1 heading with the date', () => {
|
|
const content = buildDailyNoteContent('2026-03-02')
|
|
expect(content).toContain('# 2026-03-02')
|
|
})
|
|
})
|
|
|
|
describe('resolveDailyNote', () => {
|
|
it('creates entry at vault root with date as filename', () => {
|
|
const { entry } = resolveDailyNote('2026-03-02', '/my/vault')
|
|
expect(entry.path).toBe('/my/vault/2026-03-02.md')
|
|
expect(entry.filename).toBe('2026-03-02.md')
|
|
expect(entry.title).toBe('2026-03-02')
|
|
expect(entry.isA).toBe('Journal')
|
|
expect(entry.status).toBeNull()
|
|
})
|
|
|
|
it('returns content with daily note template', () => {
|
|
const { content } = resolveDailyNote('2026-03-02', '/my/vault')
|
|
expect(content).toContain('type: Journal')
|
|
expect(content).toContain('## Intentions')
|
|
})
|
|
|
|
it('uses provided vault path', () => {
|
|
const { entry } = resolveDailyNote('2026-03-02', '/other/vault')
|
|
expect(entry.path).toBe('/other/vault/2026-03-02.md')
|
|
})
|
|
})
|
|
|
|
describe('findDailyNote', () => {
|
|
it('finds entry by filename and Journal type', () => {
|
|
const entries = [
|
|
makeEntry({ path: '/Users/luca/Laputa/2026-03-02.md', filename: '2026-03-02.md', isA: 'Journal' }),
|
|
makeEntry({ path: '/Users/luca/Laputa/other.md' }),
|
|
]
|
|
const found = findDailyNote(entries, '2026-03-02')
|
|
expect(found).toBeDefined()
|
|
expect(found!.path).toBe('/Users/luca/Laputa/2026-03-02.md')
|
|
})
|
|
|
|
it('returns undefined when no matching entry exists', () => {
|
|
const entries = [makeEntry({ path: '/Users/luca/Laputa/other.md' })]
|
|
expect(findDailyNote(entries, '2026-03-02')).toBeUndefined()
|
|
})
|
|
|
|
it('does not match non-Journal notes with date filename', () => {
|
|
const entries = [
|
|
makeEntry({ path: '/vault/2026-03-02.md', filename: '2026-03-02.md', isA: 'Note' }),
|
|
]
|
|
expect(findDailyNote(entries, '2026-03-02')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('useNoteActions hook', () => {
|
|
const addEntry = vi.fn()
|
|
const removeEntry = vi.fn()
|
|
const updateEntry = vi.fn()
|
|
const setToastMessage = vi.fn()
|
|
|
|
const makeConfig = (entries: VaultEntry[] = []): NoteActionsConfig => ({
|
|
addEntry, removeEntry, entries, setToastMessage, updateEntry, vaultPath: '/test/vault',
|
|
})
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.mocked(isTauri).mockReturnValue(false)
|
|
})
|
|
|
|
it('handleCreateNote calls addEntry and creates correct entry', () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
act(() => {
|
|
result.current.handleCreateNote('Test Note', 'Note')
|
|
})
|
|
|
|
expect(addEntry).toHaveBeenCalledTimes(1)
|
|
const [createdEntry] = addEntry.mock.calls[0]
|
|
expect(createdEntry.title).toBe('Test Note')
|
|
expect(createdEntry.isA).toBe('Note')
|
|
expect(createdEntry.path).toContain('test-note.md')
|
|
})
|
|
|
|
it('handleCreateNote opens tab immediately (before addEntry resolves)', () => {
|
|
const callOrder: string[] = []
|
|
const trackedAddEntry = vi.fn(() => { callOrder.push('addEntry') })
|
|
const config = makeConfig()
|
|
config.addEntry = trackedAddEntry
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
act(() => {
|
|
result.current.handleCreateNote('Fast Note', 'Note')
|
|
})
|
|
|
|
// Tab should be open with the new note
|
|
expect(result.current.tabs).toHaveLength(1)
|
|
expect(result.current.tabs[0].entry.title).toBe('Fast Note')
|
|
expect(result.current.activeTabPath).toContain('fast-note.md')
|
|
})
|
|
|
|
it('handleCreateType creates type entry', () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
act(() => {
|
|
result.current.handleCreateType('Recipe')
|
|
})
|
|
|
|
expect(addEntry).toHaveBeenCalledTimes(1)
|
|
const [createdEntry] = addEntry.mock.calls[0]
|
|
expect(createdEntry.isA).toBe('Type')
|
|
expect(createdEntry.title).toBe('Recipe')
|
|
})
|
|
|
|
it('handleNavigateWikilink finds entry by title', async () => {
|
|
const target = makeEntry({ title: 'Target Note', path: '/vault/target.md' })
|
|
|
|
const { result } = renderHook(() => useNoteActions(makeConfig([target])))
|
|
|
|
await act(async () => {
|
|
result.current.handleNavigateWikilink('Target Note')
|
|
})
|
|
|
|
expect(result.current.activeTabPath).toBe('/vault/target.md')
|
|
})
|
|
|
|
it('handleNavigateWikilink warns when target not found', () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
act(() => {
|
|
result.current.handleNavigateWikilink('Nonexistent')
|
|
})
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith('Navigation target not found: Nonexistent')
|
|
warnSpy.mockRestore()
|
|
})
|
|
|
|
it('handleUpdateFrontmatter calls updateEntry with mapped patch', async () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
await act(async () => {
|
|
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
|
|
})
|
|
|
|
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: 'Done' })
|
|
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
|
|
})
|
|
|
|
it('handleUpdateFrontmatter syncs is_a and color changes to entries', async () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
await act(async () => {
|
|
await result.current.handleUpdateFrontmatter('/vault/note.md', 'is_a', 'Project')
|
|
})
|
|
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { isA: 'Project' })
|
|
|
|
vi.clearAllMocks()
|
|
await act(async () => {
|
|
await result.current.handleUpdateFrontmatter('/vault/note.md', 'color', 'blue')
|
|
})
|
|
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { color: 'blue' })
|
|
})
|
|
|
|
it('handleDeleteProperty calls updateEntry with null/default values', async () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
await act(async () => {
|
|
await result.current.handleDeleteProperty('/vault/note.md', 'status')
|
|
})
|
|
|
|
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: null })
|
|
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
|
|
})
|
|
|
|
it('handleCreateNoteImmediate creates note with auto-generated title', () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
act(() => {
|
|
result.current.handleCreateNoteImmediate()
|
|
})
|
|
|
|
expect(addEntry).toHaveBeenCalledTimes(1)
|
|
const [createdEntry] = addEntry.mock.calls[0]
|
|
expect(createdEntry.title).toBe('Untitled note')
|
|
expect(createdEntry.isA).toBe('Note')
|
|
})
|
|
|
|
it('handleCreateNoteImmediate generates unique names on rapid calls', () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
act(() => {
|
|
result.current.handleCreateNoteImmediate()
|
|
result.current.handleCreateNoteImmediate()
|
|
result.current.handleCreateNoteImmediate()
|
|
})
|
|
|
|
expect(addEntry).toHaveBeenCalledTimes(3)
|
|
const titles = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.title)
|
|
expect(titles[0]).toBe('Untitled note')
|
|
expect(titles[1]).toBe('Untitled note 2')
|
|
expect(titles[2]).toBe('Untitled note 3')
|
|
})
|
|
|
|
it('handleCreateNoteImmediate accepts custom type', () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
act(() => {
|
|
result.current.handleCreateNoteImmediate('Project')
|
|
})
|
|
|
|
expect(addEntry).toHaveBeenCalledTimes(1)
|
|
const [createdEntry] = addEntry.mock.calls[0]
|
|
expect(createdEntry.title).toBe('Untitled project')
|
|
expect(createdEntry.isA).toBe('Project')
|
|
})
|
|
|
|
it('handleCreateNote uses default template for Project type', () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
act(() => {
|
|
result.current.handleCreateNote('My Project', 'Project')
|
|
})
|
|
|
|
const tabContent = result.current.tabs[0].content
|
|
expect(tabContent).toContain('## Objective')
|
|
expect(tabContent).toContain('## Key Results')
|
|
})
|
|
|
|
it('handleCreateNote uses custom template from type entry', () => {
|
|
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n## Steps\n\n' })
|
|
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
|
|
|
act(() => {
|
|
result.current.handleCreateNote('Pasta', 'Recipe')
|
|
})
|
|
|
|
const tabContent = result.current.tabs[0].content
|
|
expect(tabContent).toContain('## Ingredients')
|
|
expect(tabContent).toContain('## Steps')
|
|
})
|
|
|
|
it('handleCreateNoteImmediate does not throw for custom types with special characters', () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
expect(() => {
|
|
act(() => {
|
|
result.current.handleCreateNoteImmediate('Q&A')
|
|
})
|
|
}).not.toThrow()
|
|
|
|
const [entry] = addEntry.mock.calls[0]
|
|
expect(entry.isA).toBe('Q&A')
|
|
expect(entry.path).not.toContain('//')
|
|
})
|
|
|
|
it('handleCreateNoteImmediate does not throw for types that slugify to empty string', () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
expect(() => {
|
|
act(() => {
|
|
result.current.handleCreateNoteImmediate('+++')
|
|
})
|
|
}).not.toThrow()
|
|
|
|
const [entry] = addEntry.mock.calls[0]
|
|
expect(entry.path).not.toContain('//')
|
|
expect(entry.filename).not.toBe('.md')
|
|
})
|
|
|
|
it('handleCreateNoteImmediate uses template for typed notes', () => {
|
|
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
|
|
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
|
|
|
act(() => {
|
|
result.current.handleCreateNoteImmediate('Project')
|
|
})
|
|
|
|
const tabContent = result.current.tabs[0].content
|
|
expect(tabContent).toContain('## Custom Template')
|
|
})
|
|
|
|
it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
await act(async () => {
|
|
await result.current.handleUpdateFrontmatter('/vault/note.md', 'custom_field', 'value')
|
|
})
|
|
|
|
expect(updateEntry).not.toHaveBeenCalled()
|
|
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
|
|
})
|
|
|
|
it('handleOpenDailyNote creates a new daily note when none exists', () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
act(() => {
|
|
result.current.handleOpenDailyNote()
|
|
})
|
|
|
|
expect(addEntry).toHaveBeenCalledTimes(1)
|
|
const [createdEntry] = addEntry.mock.calls[0]
|
|
expect(createdEntry.isA).toBe('Journal')
|
|
expect(createdEntry.path).toMatch(/\/\d{4}-\d{2}-\d{2}\.md$/)
|
|
})
|
|
|
|
it('handleOpenDailyNote opens existing daily note instead of creating', async () => {
|
|
const today = todayDateString()
|
|
const existing = makeEntry({ path: `/Users/luca/Laputa/${today}.md`, filename: `${today}.md`, title: today, isA: 'Journal' })
|
|
const { result } = renderHook(() => useNoteActions(makeConfig([existing])))
|
|
|
|
await act(async () => {
|
|
result.current.handleOpenDailyNote()
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
})
|
|
|
|
// Should open existing note, not create a new one
|
|
expect(addEntry).not.toHaveBeenCalled()
|
|
expect(result.current.activeTabPath).toBe(`/Users/luca/Laputa/${today}.md`)
|
|
})
|
|
|
|
describe('pending save lifecycle', () => {
|
|
it('createAndPersist calls addPendingSave on start (non-Tauri)', async () => {
|
|
const addPendingSave = vi.fn()
|
|
const removePendingSave = vi.fn()
|
|
const config = makeConfig()
|
|
config.addPendingSave = addPendingSave
|
|
config.removePendingSave = removePendingSave
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
await act(async () => {
|
|
result.current.handleCreateNote('Pending Test', 'Note')
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
})
|
|
|
|
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('pending-test.md'))
|
|
})
|
|
|
|
it('createAndPersist calls removePendingSave when persist completes (non-Tauri)', async () => {
|
|
const addPendingSave = vi.fn()
|
|
const removePendingSave = vi.fn()
|
|
const config = makeConfig()
|
|
config.addPendingSave = addPendingSave
|
|
config.removePendingSave = removePendingSave
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
await act(async () => {
|
|
result.current.handleCreateNote('Persist OK', 'Note')
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
})
|
|
|
|
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('persist-ok.md'))
|
|
})
|
|
|
|
it('createAndPersist calls removePendingSave AND reverts when persist fails (Tauri)', async () => {
|
|
vi.mocked(isTauri).mockReturnValue(true)
|
|
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
|
|
const addPendingSave = vi.fn()
|
|
const removePendingSave = vi.fn()
|
|
const config = makeConfig()
|
|
config.addPendingSave = addPendingSave
|
|
config.removePendingSave = removePendingSave
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
await act(async () => {
|
|
result.current.handleCreateNote('Fail Save', 'Note')
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
})
|
|
|
|
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('fail-save.md'))
|
|
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('fail-save.md'))
|
|
expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining('fail-save.md'))
|
|
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
|
|
})
|
|
|
|
it('handleCreateNoteImmediate calls trackUnsaved and markContentPending (no disk write)', async () => {
|
|
const trackUnsaved = vi.fn()
|
|
const markContentPending = vi.fn()
|
|
const config = makeConfig()
|
|
config.trackUnsaved = trackUnsaved
|
|
config.markContentPending = markContentPending
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
await act(async () => {
|
|
result.current.handleCreateNoteImmediate()
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
})
|
|
|
|
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md'))
|
|
expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md'), expect.stringContaining('Untitled note'))
|
|
})
|
|
|
|
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {
|
|
const onNewNotePersisted = vi.fn()
|
|
const config = makeConfig()
|
|
config.onNewNotePersisted = onNewNotePersisted
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
await act(async () => {
|
|
result.current.handleCreateNote('Persist Callback', 'Note')
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
})
|
|
|
|
expect(onNewNotePersisted).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not call onNewNotePersisted when disk write fails (Tauri)', async () => {
|
|
vi.mocked(isTauri).mockReturnValue(true)
|
|
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
|
|
const onNewNotePersisted = vi.fn()
|
|
const config = makeConfig()
|
|
config.onNewNotePersisted = onNewNotePersisted
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
await act(async () => {
|
|
result.current.handleCreateNote('Fail Persist', 'Note')
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
})
|
|
|
|
expect(onNewNotePersisted).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('optimistic error recovery (Tauri mode)', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(isTauri).mockReturnValue(true)
|
|
})
|
|
|
|
it.each([
|
|
['handleCreateNote', 'Failing Note', 'Note', 'failing-note.md'],
|
|
['handleCreateType', 'Recipe', 'Type', 'recipe.md'],
|
|
])('reverts optimistic creation via %s when disk write fails', async (method, title, type, pathFragment) => {
|
|
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
await act(async () => {
|
|
if (method === 'handleCreateNote') result.current.handleCreateNote(title, type)
|
|
else result.current.handleCreateType(title)
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
})
|
|
|
|
expect(addEntry).toHaveBeenCalledTimes(1)
|
|
expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining(pathFragment))
|
|
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
|
|
})
|
|
|
|
it('does not revert when disk write succeeds', async () => {
|
|
vi.mocked(invoke).mockResolvedValueOnce(undefined)
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
await act(async () => {
|
|
result.current.handleCreateNote('Good Note', 'Note')
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
})
|
|
|
|
expect(removeEntry).not.toHaveBeenCalled()
|
|
expect(setToastMessage).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('handleCreateNoteImmediate does not call invoke (no disk write)', async () => {
|
|
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
|
|
|
await act(async () => {
|
|
result.current.handleCreateNoteImmediate()
|
|
result.current.handleCreateNoteImmediate()
|
|
result.current.handleCreateNoteImmediate()
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
})
|
|
|
|
expect(addEntry).toHaveBeenCalledTimes(3)
|
|
// No disk writes for immediate creation — notes are unsaved/in-memory
|
|
expect(invoke).not.toHaveBeenCalled()
|
|
expect(removeEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
})
|
|
|
|
describe('type change does not move file', () => {
|
|
it('changing type only updates frontmatter, does not move file', async () => {
|
|
const entry = makeEntry({ path: '/test/vault/my-note.md', filename: 'my-note.md', title: 'My Note', isA: 'Note' })
|
|
const config = makeConfig([entry])
|
|
vi.mocked(mockInvoke).mockResolvedValue('')
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
await act(async () => {
|
|
await result.current.handleUpdateFrontmatter('/test/vault/my-note.md', 'type', 'Quarter')
|
|
})
|
|
|
|
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
|
|
})
|
|
})
|
|
|
|
describe('title sync on reopen', () => {
|
|
it('calls sync_note_title when reopening an already-open tab', async () => {
|
|
vi.mocked(isTauri).mockReturnValue(true)
|
|
const entry = makeEntry({ path: '/test/vault/qa-test.md', filename: 'qa-test.md', title: 'Qa Test' })
|
|
|
|
// First open: handleSelectNoteWithSync calls sync, then handleSelectNote calls sync again + loads
|
|
vi.mocked(invoke)
|
|
.mockResolvedValueOnce(false) // sync_note_title (from handleSelectNoteWithSync)
|
|
.mockResolvedValueOnce(false) // sync_note_title (from handleSelectNote)
|
|
.mockResolvedValueOnce('# Qa Test\n') // get_note_content
|
|
.mockResolvedValueOnce({ ...entry }) // reload_vault_entry (first open)
|
|
|
|
// Second open (reopen, tab already exists): handleSelectNoteWithSync calls sync + reload
|
|
vi.mocked(invoke)
|
|
.mockResolvedValueOnce(true) // sync_note_title (reopen — file was modified)
|
|
.mockResolvedValueOnce('---\ntitle: Qa Test\n---\n# Qa Test\n') // get_note_content (reload)
|
|
.mockResolvedValueOnce({ ...entry, title: 'Qa Test' }) // reload_vault_entry (reopen)
|
|
|
|
const { result } = renderHook(() => useNoteActions(makeConfig([entry])))
|
|
|
|
// Open the note (creates tab)
|
|
await act(async () => { await result.current.handleSelectNote(entry) })
|
|
|
|
// Reopen the same note (tab already open — this is the Cmd+P reopen scenario)
|
|
const desyncedEntry = { ...entry, title: 'Wrong Title Desynced' }
|
|
await act(async () => { await result.current.handleSelectNote(desyncedEntry) })
|
|
|
|
// sync_note_title should have been called for both opens (3 total: 2 for first open, 1 for reopen)
|
|
const syncCalls = vi.mocked(invoke).mock.calls.filter(c => c[0] === 'sync_note_title')
|
|
expect(syncCalls.length).toBeGreaterThanOrEqual(2)
|
|
// Critical: sync was called on reopen (the bug fix)
|
|
expect(syncCalls.at(-1)).toEqual(['sync_note_title', { path: '/test/vault/qa-test.md' }])
|
|
})
|
|
})
|
|
|
|
describe('rename note updates wikilinks', () => {
|
|
it('handleRenameNote passes entry title as old_title to rename_note', async () => {
|
|
const entry = makeEntry({
|
|
path: '/test/vault/weekly-review.md',
|
|
filename: 'weekly-review.md',
|
|
title: 'Weekly Review',
|
|
})
|
|
const replaceEntry = vi.fn()
|
|
const config = makeConfig([entry])
|
|
config.replaceEntry = replaceEntry
|
|
|
|
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'rename_note') return { new_path: '/test/vault/sprint-retro.md', updated_files: 2 }
|
|
if (cmd === 'get_note_content') return '---\nIs A: Note\n---\n# Sprint Retro\n'
|
|
return ''
|
|
})
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
await act(async () => {
|
|
await result.current.handleRenameNote(
|
|
'/test/vault/weekly-review.md',
|
|
'Sprint Retro',
|
|
'/test/vault',
|
|
replaceEntry,
|
|
)
|
|
})
|
|
|
|
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
|
|
vault_path: '/test/vault',
|
|
old_path: '/test/vault/weekly-review.md',
|
|
new_title: 'Sprint Retro',
|
|
old_title: 'Weekly Review',
|
|
}))
|
|
expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links')
|
|
})
|
|
|
|
it('handleRenameNote passes null old_title when entry not found', async () => {
|
|
const config = makeConfig([])
|
|
|
|
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'rename_note') return { new_path: '/test/vault/new.md', updated_files: 0 }
|
|
if (cmd === 'get_note_content') return '# New\n'
|
|
return ''
|
|
})
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
await act(async () => {
|
|
await result.current.handleRenameNote(
|
|
'/test/vault/old.md', 'New', '/test/vault', vi.fn(),
|
|
)
|
|
})
|
|
|
|
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
|
|
old_title: null,
|
|
}))
|
|
})
|
|
|
|
it('handleUpdateFrontmatter triggers rename when title key is changed', async () => {
|
|
const entry = makeEntry({
|
|
path: '/test/vault/old-name.md',
|
|
filename: 'old-name.md',
|
|
title: 'Old Name',
|
|
})
|
|
const replaceEntry = vi.fn()
|
|
const config = makeConfig([entry])
|
|
config.replaceEntry = replaceEntry
|
|
|
|
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
|
if (cmd === 'rename_note') return { new_path: '/test/vault/new-name.md', updated_files: 1 }
|
|
if (cmd === 'get_note_content') return '---\ntitle: New Name\n---\n# New Name\n'
|
|
return ''
|
|
})
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
// Open a tab for the entry so the rename can find it via tabsRef
|
|
await act(async () => { result.current.handleSelectNote(entry) })
|
|
|
|
await act(async () => {
|
|
await result.current.handleUpdateFrontmatter('/test/vault/old-name.md', 'title', 'New Name')
|
|
})
|
|
|
|
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
|
|
old_path: '/test/vault/old-name.md',
|
|
new_title: 'New Name',
|
|
old_title: 'Old Name',
|
|
}))
|
|
expect(replaceEntry).toHaveBeenCalledWith(
|
|
'/test/vault/old-name.md',
|
|
expect.objectContaining({ path: '/test/vault/new-name.md', title: 'New Name' }),
|
|
)
|
|
})
|
|
|
|
it('handleUpdateFrontmatter does not trigger rename for non-title keys', async () => {
|
|
const config = makeConfig()
|
|
vi.mocked(mockInvoke).mockResolvedValue('')
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
await act(async () => {
|
|
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
|
|
})
|
|
|
|
expect(mockInvoke).not.toHaveBeenCalledWith('rename_note', expect.anything())
|
|
})
|
|
})
|
|
})
|