2026-04-22 20:18:26 +02:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
|
import { renderHook, act } from '@testing-library/react'
|
2026-05-23 14:41:11 +02:00
|
|
|
import { invoke } from '@tauri-apps/api/core'
|
2026-04-22 20:18:26 +02:00
|
|
|
import { isTauri, mockInvoke } from '../mock-tauri'
|
|
|
|
|
import type { VaultEntry } from '../types'
|
|
|
|
|
import { useNoteActions, type NoteActionsConfig } from './useNoteActions'
|
|
|
|
|
|
2026-05-23 14:41:11 +02:00
|
|
|
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
2026-04-22 20:18:26 +02:00
|
|
|
vi.mock('../mock-tauri', () => ({
|
|
|
|
|
isTauri: vi.fn(() => false),
|
|
|
|
|
addMockEntry: vi.fn(),
|
|
|
|
|
updateMockContent: vi.fn(),
|
|
|
|
|
trackMockChange: vi.fn(),
|
|
|
|
|
mockInvoke: vi.fn(),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
|
|
|
|
path: '/test/vault/missing.md',
|
|
|
|
|
filename: 'missing.md',
|
|
|
|
|
title: 'Missing Note',
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: [],
|
|
|
|
|
status: null,
|
|
|
|
|
archived: false,
|
|
|
|
|
modifiedAt: 1700000000,
|
|
|
|
|
createdAt: 1700000000,
|
|
|
|
|
fileSize: 100,
|
|
|
|
|
snippet: '',
|
|
|
|
|
wordCount: 0,
|
|
|
|
|
relationships: {},
|
|
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
|
|
|
|
order: null,
|
|
|
|
|
outgoingLinks: [],
|
|
|
|
|
template: null,
|
|
|
|
|
sort: null,
|
|
|
|
|
sidebarLabel: null,
|
|
|
|
|
view: null,
|
|
|
|
|
visible: null,
|
|
|
|
|
properties: {},
|
|
|
|
|
organized: false,
|
|
|
|
|
favorite: false,
|
|
|
|
|
favoriteIndex: null,
|
|
|
|
|
listPropertiesDisplay: [],
|
|
|
|
|
hasH1: false,
|
|
|
|
|
...overrides,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function makeConfig(overrides: Partial<NoteActionsConfig> = {}): NoteActionsConfig {
|
|
|
|
|
return {
|
|
|
|
|
addEntry: vi.fn(),
|
|
|
|
|
removeEntry: vi.fn(),
|
|
|
|
|
entries: [makeEntry()],
|
|
|
|
|
reloadVault: vi.fn().mockResolvedValue([]),
|
|
|
|
|
setToastMessage: vi.fn(),
|
|
|
|
|
updateEntry: vi.fn(),
|
|
|
|
|
vaultPath: '/test/vault',
|
|
|
|
|
...overrides,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 14:41:11 +02:00
|
|
|
async function openNoteForFrontmatterRecovery(
|
|
|
|
|
result: { current: ReturnType<typeof useNoteActions> },
|
|
|
|
|
entry: VaultEntry,
|
|
|
|
|
) {
|
|
|
|
|
vi.mocked(mockInvoke).mockResolvedValue('# Missing Note\n')
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.handleSelectNote(entry)
|
|
|
|
|
})
|
|
|
|
|
vi.mocked(isTauri).mockReturnValue(true)
|
|
|
|
|
vi.mocked(invoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/missing.md'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function expectMissingFrontmatterRecovery(
|
|
|
|
|
result: { current: ReturnType<typeof useNoteActions> },
|
|
|
|
|
config: NoteActionsConfig,
|
|
|
|
|
) {
|
|
|
|
|
expect(result.current.tabs).toEqual([])
|
|
|
|
|
expect(result.current.activeTabPath).toBeNull()
|
|
|
|
|
expect(config.reloadVault).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(config.setToastMessage).toHaveBeenCalledWith(
|
|
|
|
|
'"Missing Note" could not be opened because its file is missing or moved.',
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 20:18:26 +02:00
|
|
|
describe('useNoteActions missing-path recovery', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks()
|
2026-05-23 14:41:11 +02:00
|
|
|
vi.mocked(invoke).mockReset()
|
2026-04-22 20:18:26 +02:00
|
|
|
vi.mocked(isTauri).mockReturnValue(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reloads vault state and shows a toast when the selected note path is gone', async () => {
|
|
|
|
|
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/missing.md'))
|
|
|
|
|
const config = makeConfig()
|
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
|
|
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.handleSelectNote(makeEntry())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(result.current.tabs).toEqual([])
|
|
|
|
|
expect(result.current.activeTabPath).toBeNull()
|
|
|
|
|
expect(config.reloadVault).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(config.setToastMessage).toHaveBeenCalledWith(
|
|
|
|
|
'"Missing Note" could not be opened because its file is missing or moved.',
|
|
|
|
|
)
|
|
|
|
|
warnSpy.mockRestore()
|
2026-05-03 19:40:50 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('recovers from missing stale sidebar entries with incomplete string metadata', async () => {
|
|
|
|
|
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/reloaded.md'))
|
|
|
|
|
const staleEntry = {
|
|
|
|
|
...makeEntry({ path: '/test/vault/reloaded.md' }),
|
|
|
|
|
filename: undefined,
|
|
|
|
|
title: undefined,
|
|
|
|
|
} as unknown as VaultEntry
|
|
|
|
|
const config = makeConfig()
|
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
|
|
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.handleSelectNote(staleEntry)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(result.current.tabs).toEqual([])
|
|
|
|
|
expect(result.current.activeTabPath).toBeNull()
|
|
|
|
|
expect(config.reloadVault).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(config.setToastMessage).toHaveBeenCalledWith(
|
|
|
|
|
'"Note" could not be opened because its file is missing or moved.',
|
|
|
|
|
)
|
|
|
|
|
warnSpy.mockRestore()
|
2026-04-22 20:18:26 +02:00
|
|
|
})
|
2026-04-24 03:02:03 +02:00
|
|
|
|
|
|
|
|
it('shows a toast without reloading the vault when note content is not valid UTF-8 text', async () => {
|
|
|
|
|
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File is not valid UTF-8 text: /test/vault/bad.csv'))
|
|
|
|
|
const config = makeConfig({ entries: [makeEntry({ path: '/test/vault/bad.csv', filename: 'bad.csv', title: 'bad.csv', fileKind: 'text' })] })
|
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
|
|
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.handleSelectNote(makeEntry({ path: '/test/vault/bad.csv', filename: 'bad.csv', title: 'bad.csv', fileKind: 'text' }))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(result.current.tabs).toEqual([])
|
|
|
|
|
expect(result.current.activeTabPath).toBeNull()
|
|
|
|
|
expect(config.reloadVault).not.toHaveBeenCalled()
|
|
|
|
|
expect(config.setToastMessage).toHaveBeenCalledWith(
|
|
|
|
|
'"bad.csv" could not be opened because it is not valid UTF-8 text.',
|
|
|
|
|
)
|
|
|
|
|
warnSpy.mockRestore()
|
|
|
|
|
})
|
2026-05-23 14:41:11 +02:00
|
|
|
|
|
|
|
|
it('reloads and clears the active tab when a frontmatter write targets a deleted note file', async () => {
|
|
|
|
|
const entry = makeEntry()
|
|
|
|
|
const config = makeConfig({ entries: [entry] })
|
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
|
|
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
|
|
|
|
|
|
await openNoteForFrontmatterRecovery(result, entry)
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await result.current.handleUpdateFrontmatter(entry.path, 'status', 'Done')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expectMissingFrontmatterRecovery(result, config)
|
|
|
|
|
expect(config.updateEntry).not.toHaveBeenCalled()
|
|
|
|
|
warnSpy.mockRestore()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('still rejects silent frontmatter writes after running missing-note recovery', async () => {
|
|
|
|
|
const entry = makeEntry()
|
|
|
|
|
const config = makeConfig({ entries: [entry] })
|
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
|
|
|
|
|
|
const { result } = renderHook(() => useNoteActions(config))
|
|
|
|
|
|
|
|
|
|
await openNoteForFrontmatterRecovery(result, entry)
|
|
|
|
|
let thrown: unknown
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
try {
|
|
|
|
|
await result.current.handleUpdateFrontmatter(entry.path, '_archived', true, { silent: true })
|
|
|
|
|
} catch (err) {
|
|
|
|
|
thrown = err
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(thrown).toBeInstanceOf(Error)
|
|
|
|
|
expectMissingFrontmatterRecovery(result, config)
|
|
|
|
|
warnSpy.mockRestore()
|
|
|
|
|
})
|
2026-04-22 20:18:26 +02:00
|
|
|
})
|