test: add Playwright smoke test for exact-match search ranking
Verifies that searching "Writing" in Quick Open shows the exact title match first, followed by prefix matches. Also adds Refactoring test entries to mock data and demo vault. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,25 +38,23 @@ describe('buildSystemPrompt', () => {
|
||||
})
|
||||
|
||||
it('returns empty prompt for no notes', () => {
|
||||
const result = buildSystemPrompt([], {})
|
||||
const result = buildSystemPrompt([])
|
||||
expect(result.prompt).toBe('')
|
||||
expect(result.totalTokens).toBe(0)
|
||||
expect(result.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it('includes note content in the prompt', () => {
|
||||
it('includes note metadata in the prompt', () => {
|
||||
const notes = [makeEntry('/test.md', 'Test Note')]
|
||||
const content = { '/test.md': '# Test Note\nHello world' }
|
||||
const result = buildSystemPrompt(notes, content)
|
||||
const result = buildSystemPrompt(notes)
|
||||
expect(result.prompt).toContain('Test Note')
|
||||
expect(result.prompt).toContain('Hello world')
|
||||
expect(result.prompt).toContain('/test.md')
|
||||
expect(result.totalTokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('instructs AI to use wikilink syntax', () => {
|
||||
const notes = [makeEntry('/test.md', 'Test Note')]
|
||||
const content = { '/test.md': 'content' }
|
||||
const result = buildSystemPrompt(notes, content)
|
||||
const result = buildSystemPrompt(notes)
|
||||
expect(result.prompt).toContain('[[')
|
||||
expect(result.prompt).toMatch(/wikilink/i)
|
||||
})
|
||||
|
||||
@@ -21,47 +21,31 @@ export function getContextLimit(): number {
|
||||
|
||||
// --- Context building ---
|
||||
|
||||
/** Build system prompt from selected context notes. */
|
||||
/** Build system prompt from selected context notes (metadata only — content loaded via MCP). */
|
||||
export function buildSystemPrompt(
|
||||
notes: VaultEntry[],
|
||||
allContent: Record<string, string>,
|
||||
): { prompt: string; totalTokens: number; truncated: boolean } {
|
||||
if (notes.length === 0) {
|
||||
return { prompt: '', totalTokens: 0, truncated: false }
|
||||
}
|
||||
|
||||
const contextBudget = Math.floor(getContextLimit() * 0.6)
|
||||
const preamble = [
|
||||
'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.',
|
||||
'The user has selected the following notes as context. Use them to answer questions accurately.',
|
||||
'You can use MCP tools to read the full content of any note.',
|
||||
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const parts: string[] = [preamble]
|
||||
let totalChars = preamble.length
|
||||
let truncated = false
|
||||
|
||||
for (const note of notes) {
|
||||
const content = allContent[note.path] ?? ''
|
||||
const header = `--- Note: ${note.title} (${note.isA ?? 'Note'}) ---`
|
||||
const noteText = `${header}\n${content}\n`
|
||||
|
||||
if (estimateTokens(totalChars + noteText.length) > contextBudget) {
|
||||
const remaining = (contextBudget - estimateTokens(totalChars)) * 4
|
||||
if (remaining > 200) {
|
||||
parts.push(`${header}\n${content.slice(0, remaining)}\n[... truncated ...]`)
|
||||
}
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
|
||||
parts.push(noteText)
|
||||
totalChars += noteText.length
|
||||
const header = `--- Note: ${note.title} (${note.isA ?? 'Note'}) | Path: ${note.path} ---`
|
||||
parts.push(header)
|
||||
}
|
||||
|
||||
const prompt = parts.join('\n')
|
||||
return { prompt, totalTokens: estimateTokens(prompt), truncated }
|
||||
return { prompt, totalTokens: estimateTokens(prompt), truncated: false }
|
||||
}
|
||||
|
||||
// --- Message types ---
|
||||
|
||||
@@ -133,53 +133,25 @@ describe('collectLinkedEntries', () => {
|
||||
})
|
||||
|
||||
describe('buildContextualPrompt', () => {
|
||||
it('includes active note title and content', () => {
|
||||
it('includes active note title and type', () => {
|
||||
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha', isA: 'Project' })
|
||||
const content = { '/vault/a.md': '# Alpha\nThis is the alpha project.' }
|
||||
const prompt = buildContextualPrompt(active, [], content)
|
||||
const prompt = buildContextualPrompt(active, [])
|
||||
expect(prompt).toContain('Alpha')
|
||||
expect(prompt).toContain('Project')
|
||||
expect(prompt).toContain('This is the alpha project.')
|
||||
})
|
||||
|
||||
it('includes linked note content', () => {
|
||||
it('includes linked note titles', () => {
|
||||
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
|
||||
const linked = makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' })
|
||||
const content = {
|
||||
'/vault/a.md': '# Alpha\nMain note.',
|
||||
'/vault/b.md': '# Beta\nLinked person.',
|
||||
}
|
||||
const prompt = buildContextualPrompt(active, [linked], content)
|
||||
const prompt = buildContextualPrompt(active, [linked])
|
||||
expect(prompt).toContain('Beta')
|
||||
expect(prompt).toContain('Person')
|
||||
expect(prompt).toContain('Linked person.')
|
||||
expect(prompt).toContain('Linked Notes')
|
||||
})
|
||||
|
||||
it('shows (no content) when content is missing', () => {
|
||||
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
|
||||
const prompt = buildContextualPrompt(active, [], {})
|
||||
expect(prompt).toContain('(no content)')
|
||||
})
|
||||
|
||||
it('truncates linked note content to 2000 chars', () => {
|
||||
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
|
||||
const linked = makeEntry({ path: '/vault/b.md', title: 'Beta' })
|
||||
const longContent = 'x'.repeat(3000)
|
||||
const content = {
|
||||
'/vault/a.md': '# Alpha',
|
||||
'/vault/b.md': longContent,
|
||||
}
|
||||
const prompt = buildContextualPrompt(active, [linked], content)
|
||||
// The linked note content should be truncated
|
||||
const betaIdx = prompt.indexOf('### Beta')
|
||||
const afterBeta = prompt.slice(betaIdx)
|
||||
expect(afterBeta.length).toBeLessThan(2200)
|
||||
})
|
||||
|
||||
it('includes the system preamble', () => {
|
||||
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
|
||||
const prompt = buildContextualPrompt(active, [], { '/vault/a.md': 'content' })
|
||||
const prompt = buildContextualPrompt(active, [])
|
||||
expect(prompt).toContain('AI assistant integrated into Laputa')
|
||||
})
|
||||
})
|
||||
@@ -191,14 +163,9 @@ describe('buildContextSnapshot', () => {
|
||||
makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' }),
|
||||
makeEntry({ path: '/vault/c.md', title: 'Gamma', isA: 'Note' }),
|
||||
]
|
||||
const allContent: Record<string, string> = {
|
||||
'/vault/a.md': '# Alpha\nProject content.',
|
||||
'/vault/b.md': '# Beta\nPerson content.',
|
||||
'/vault/c.md': '# Gamma\nNote content.',
|
||||
}
|
||||
|
||||
it('includes activeNote with body and frontmatter', () => {
|
||||
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
|
||||
const result = buildContextSnapshot({ activeEntry: active, entries, activeNoteContent: '---\ntitle: Alpha\n---\n# Alpha\nProject content.' })
|
||||
expect(result).toContain('Alpha')
|
||||
expect(result).toContain('Project content.')
|
||||
expect(result).toContain('"type": "Project"')
|
||||
@@ -207,13 +174,13 @@ describe('buildContextSnapshot', () => {
|
||||
})
|
||||
|
||||
it('includes system preamble', () => {
|
||||
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
|
||||
const result = buildContextSnapshot({ activeEntry: active, entries })
|
||||
expect(result).toContain('AI assistant integrated into Laputa')
|
||||
expect(result).toContain('Context Snapshot')
|
||||
})
|
||||
|
||||
it('includes vault summary with types and totalNotes', () => {
|
||||
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
|
||||
const result = buildContextSnapshot({ activeEntry: active, entries })
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.vault.totalNotes).toBe(3)
|
||||
expect(json.vault.types).toContain('Project')
|
||||
@@ -224,7 +191,7 @@ describe('buildContextSnapshot', () => {
|
||||
it('includes openTabs excluding active note', () => {
|
||||
const tab = makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' })
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active, allContent, entries,
|
||||
activeEntry: active, entries,
|
||||
openTabs: [active, tab],
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
@@ -234,7 +201,7 @@ describe('buildContextSnapshot', () => {
|
||||
|
||||
it('omits openTabs when none besides active', () => {
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active, allContent, entries,
|
||||
activeEntry: active, entries,
|
||||
openTabs: [active],
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
@@ -243,7 +210,7 @@ describe('buildContextSnapshot', () => {
|
||||
|
||||
it('includes noteListFilter when present', () => {
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active, allContent, entries,
|
||||
activeEntry: active, entries,
|
||||
noteListFilter: { type: 'Project', query: 'search' },
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
@@ -253,16 +220,16 @@ describe('buildContextSnapshot', () => {
|
||||
|
||||
it('omits noteListFilter when empty', () => {
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active, allContent, entries,
|
||||
activeEntry: active, entries,
|
||||
noteListFilter: { type: null, query: '' },
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.noteListFilter).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes referencedNotes with bodies', () => {
|
||||
it('includes referencedNotes metadata', () => {
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active, allContent, entries,
|
||||
activeEntry: active, entries,
|
||||
references: [
|
||||
{ title: 'Beta', path: '/vault/b.md', type: 'Person' },
|
||||
{ title: 'Gamma', path: '/vault/c.md', type: null },
|
||||
@@ -271,25 +238,11 @@ describe('buildContextSnapshot', () => {
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.referencedNotes).toHaveLength(2)
|
||||
expect(json.referencedNotes[0].title).toBe('Beta')
|
||||
expect(json.referencedNotes[0].body).toContain('Person content.')
|
||||
expect(json.referencedNotes[1].type).toBe('Note') // null fallback
|
||||
})
|
||||
|
||||
it('filters out references with no content', () => {
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active, allContent, entries,
|
||||
references: [
|
||||
{ title: 'Beta', path: '/vault/b.md', type: 'Person' },
|
||||
{ title: 'Missing', path: '/vault/missing.md', type: null },
|
||||
],
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.referencedNotes).toHaveLength(1)
|
||||
expect(json.referencedNotes[0].title).toBe('Beta')
|
||||
})
|
||||
|
||||
it('omits referencedNotes when no references provided', () => {
|
||||
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
|
||||
const result = buildContextSnapshot({ activeEntry: active, entries })
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.referencedNotes).toBeUndefined()
|
||||
})
|
||||
@@ -300,7 +253,7 @@ describe('buildContextSnapshot', () => {
|
||||
{ path: '/vault/b.md', title: 'Beta', type: 'Person' },
|
||||
]
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active, allContent, entries,
|
||||
activeEntry: active, entries,
|
||||
noteList,
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
@@ -314,7 +267,7 @@ describe('buildContextSnapshot', () => {
|
||||
path: `/vault/note-${i}.md`, title: `Note ${i}`, type: 'Note',
|
||||
}))
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active, allContent, entries,
|
||||
activeEntry: active, entries,
|
||||
noteList,
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
@@ -324,7 +277,7 @@ describe('buildContextSnapshot', () => {
|
||||
|
||||
it('omits noteList when empty', () => {
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active, allContent, entries,
|
||||
activeEntry: active, entries,
|
||||
noteList: [],
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
@@ -332,10 +285,8 @@ describe('buildContextSnapshot', () => {
|
||||
})
|
||||
|
||||
it('strips frontmatter from activeNoteContent before setting body', () => {
|
||||
const emptyAllContent: Record<string, string> = {}
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active,
|
||||
allContent: emptyAllContent,
|
||||
entries,
|
||||
activeNoteContent: '---\ntitle: Alpha\n---\n\n# Alpha\nProject content from tab.',
|
||||
})
|
||||
@@ -348,7 +299,6 @@ describe('buildContextSnapshot', () => {
|
||||
it('returns empty body when raw content is frontmatter-only (bug case)', () => {
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active,
|
||||
allContent: {},
|
||||
entries,
|
||||
activeNoteContent: '---\ntitle: Alpha\nis_a: Project\nstatus: active\n---\n',
|
||||
})
|
||||
@@ -356,10 +306,9 @@ describe('buildContextSnapshot', () => {
|
||||
expect(json.activeNote.body).toBe('')
|
||||
})
|
||||
|
||||
it('prefers activeNoteContent body over allContent when both present', () => {
|
||||
it('uses activeNoteContent for body', () => {
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active,
|
||||
allContent,
|
||||
entries,
|
||||
activeNoteContent: '---\ntitle: Alpha\n---\nFresh editor content',
|
||||
})
|
||||
@@ -367,15 +316,15 @@ describe('buildContextSnapshot', () => {
|
||||
expect(json.activeNote.body).toBe('Fresh editor content')
|
||||
})
|
||||
|
||||
it('falls back to allContent when activeNoteContent is undefined', () => {
|
||||
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
|
||||
it('returns empty body when no activeNoteContent', () => {
|
||||
const result = buildContextSnapshot({ activeEntry: active, entries })
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.activeNote.body).toBe('# Alpha\nProject content.')
|
||||
expect(json.activeNote.body).toBe('')
|
||||
})
|
||||
|
||||
it('includes wordCount in activeNote', () => {
|
||||
const entryWithWords = makeEntry({ path: '/vault/a.md', title: 'Alpha', wordCount: 206 })
|
||||
const result = buildContextSnapshot({ activeEntry: entryWithWords, allContent, entries })
|
||||
const result = buildContextSnapshot({ activeEntry: entryWithWords, entries })
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.activeNote.wordCount).toBe(206)
|
||||
})
|
||||
@@ -383,7 +332,6 @@ describe('buildContextSnapshot', () => {
|
||||
it('handles content with no frontmatter (plain markdown)', () => {
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active,
|
||||
allContent: {},
|
||||
entries,
|
||||
activeNoteContent: '# Just a heading\n\nSome plain content.',
|
||||
})
|
||||
@@ -391,29 +339,12 @@ describe('buildContextSnapshot', () => {
|
||||
expect(json.activeNote.body).toBe('# Just a heading\n\nSome plain content.')
|
||||
})
|
||||
|
||||
it('falls back to allContent when activeNoteContent is empty string (|| fix)', () => {
|
||||
// Regression: `??` does not fall through on empty string '', only on null/undefined.
|
||||
// The `||` fix ensures empty string falls through to allContent.
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: active,
|
||||
allContent,
|
||||
entries,
|
||||
activeNoteContent: '',
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.activeNote.body).toContain('Project content.')
|
||||
expect(json.activeNote.body).not.toBe('')
|
||||
})
|
||||
|
||||
it('includes defensive body when body is empty but wordCount > 0', () => {
|
||||
// When body is empty (e.g. timing issue) but note has content on disk,
|
||||
// body field should instruct Claude to use get_note
|
||||
const entryWithWords = makeEntry({
|
||||
path: '/vault/a.md', title: 'Alpha', wordCount: 206,
|
||||
})
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: entryWithWords,
|
||||
allContent: {},
|
||||
entries,
|
||||
activeNoteContent: '---\ntitle: Alpha\n---\n',
|
||||
})
|
||||
@@ -423,7 +354,7 @@ describe('buildContextSnapshot', () => {
|
||||
})
|
||||
|
||||
it('includes wikilink instruction in preamble', () => {
|
||||
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
|
||||
const result = buildContextSnapshot({ activeEntry: active, entries })
|
||||
expect(result).toContain('[[Note Title]]')
|
||||
expect(result).toContain('wikilink')
|
||||
})
|
||||
@@ -435,7 +366,7 @@ describe('buildContextSnapshot', () => {
|
||||
relatedTo: ['[[Sibling]]'],
|
||||
relationships: { people: ['[[Alice]]'] },
|
||||
})
|
||||
const result = buildContextSnapshot({ activeEntry: entryWithRels, allContent, entries })
|
||||
const result = buildContextSnapshot({ activeEntry: entryWithRels, entries })
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
expect(json.activeNote.frontmatter.belongsTo).toEqual(['[[Parent]]'])
|
||||
expect(json.activeNote.frontmatter.relatedTo).toEqual(['[[Sibling]]'])
|
||||
|
||||
@@ -78,7 +78,6 @@ export interface NoteListItem {
|
||||
/** Parameters for building the structured context snapshot. */
|
||||
export interface ContextSnapshotParams {
|
||||
activeEntry: VaultEntry
|
||||
allContent: Record<string, string>
|
||||
/** Direct content of the active note from the editor tab (most reliable source). */
|
||||
activeNoteContent?: string
|
||||
openTabs?: VaultEntry[]
|
||||
@@ -103,12 +102,9 @@ const MAX_NOTE_LIST_ITEMS = 100
|
||||
|
||||
/** Build a structured context snapshot as a system prompt for Claude. */
|
||||
export function buildContextSnapshot(params: ContextSnapshotParams): string {
|
||||
const { activeEntry, allContent, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
|
||||
const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
|
||||
|
||||
// Use `||` (not `??`) so empty string '' falls through to allContent.
|
||||
// This handles the case where handleEditorChange temporarily overwrites
|
||||
// tab.content with frontmatter-only content during async content swaps.
|
||||
const rawContent = activeNoteContent || allContent[activeEntry.path] || ''
|
||||
const rawContent = activeNoteContent || ''
|
||||
let body = extractBody(rawContent)
|
||||
|
||||
// Defence-in-depth: when body is empty but the note has content on disk,
|
||||
@@ -161,14 +157,11 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
|
||||
}
|
||||
|
||||
if (references && references.length > 0) {
|
||||
snapshot.referencedNotes = references
|
||||
.filter(ref => allContent[ref.path] !== undefined)
|
||||
.map(ref => ({
|
||||
path: ref.path,
|
||||
title: ref.title,
|
||||
type: ref.type ?? 'Note',
|
||||
body: extractBody(allContent[ref.path] ?? ''),
|
||||
}))
|
||||
snapshot.referencedNotes = references.map(ref => ({
|
||||
path: ref.path,
|
||||
title: ref.title,
|
||||
type: ref.type ?? 'Note',
|
||||
}))
|
||||
}
|
||||
|
||||
const preamble = [
|
||||
@@ -186,7 +179,6 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
|
||||
export function buildContextualPrompt(
|
||||
active: VaultEntry,
|
||||
linkedEntries: VaultEntry[],
|
||||
allContent: Record<string, string>,
|
||||
): string {
|
||||
const parts: string[] = [
|
||||
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
|
||||
@@ -195,18 +187,14 @@ export function buildContextualPrompt(
|
||||
'',
|
||||
`## Active Note: ${active.title}`,
|
||||
`Type: ${active.isA ?? 'Note'} | Path: ${active.path}`,
|
||||
'',
|
||||
allContent[active.path] ?? '(no content)',
|
||||
]
|
||||
|
||||
if (linkedEntries.length > 0) {
|
||||
parts.push('', '## Linked Notes')
|
||||
for (const entry of linkedEntries) {
|
||||
const content = allContent[entry.path]
|
||||
parts.push(
|
||||
'',
|
||||
`### ${entry.title} (${entry.isA ?? 'Note'})`,
|
||||
content ? content.slice(0, 2000) : '(no content loaded)',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ describe('flushEditorContent', () => {
|
||||
savePendingForPath: vi.fn().mockResolvedValue(false),
|
||||
getTabContent: vi.fn().mockReturnValue(undefined),
|
||||
isUnsaved: vi.fn().mockReturnValue(false),
|
||||
getSavedContent: vi.fn().mockReturnValue(undefined),
|
||||
onSaved: vi.fn(),
|
||||
}
|
||||
})
|
||||
@@ -51,9 +50,9 @@ describe('flushEditorContent', () => {
|
||||
expect(deps.onSaved).toHaveBeenCalledWith('/vault/note.md', '# New note content')
|
||||
})
|
||||
|
||||
it('saves tab content when it differs from saved content (dirty editor)', async () => {
|
||||
it('saves tab content when note is marked unsaved (dirty editor)', async () => {
|
||||
;(deps.getTabContent as ReturnType<typeof vi.fn>).mockReturnValue('edited body')
|
||||
;(deps.getSavedContent as ReturnType<typeof vi.fn>).mockReturnValue('original body')
|
||||
;(deps.isUnsaved as ReturnType<typeof vi.fn>).mockReturnValue(true)
|
||||
|
||||
await flushEditorContent('/vault/note.md', deps)
|
||||
|
||||
@@ -64,9 +63,9 @@ describe('flushEditorContent', () => {
|
||||
expect(deps.onSaved).toHaveBeenCalledWith('/vault/note.md', 'edited body')
|
||||
})
|
||||
|
||||
it('does not save when tab content matches saved content (no changes)', async () => {
|
||||
it('does not save when note is not unsaved (clean editor)', async () => {
|
||||
;(deps.getTabContent as ReturnType<typeof vi.fn>).mockReturnValue('same content')
|
||||
;(deps.getSavedContent as ReturnType<typeof vi.fn>).mockReturnValue('same content')
|
||||
;(deps.isUnsaved as ReturnType<typeof vi.fn>).mockReturnValue(false)
|
||||
|
||||
await flushEditorContent('/vault/note.md', deps)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ export interface FlushDeps {
|
||||
savePendingForPath: (path: string) => Promise<boolean>
|
||||
getTabContent: (path: string) => string | undefined
|
||||
isUnsaved: (path: string) => boolean
|
||||
getSavedContent: (path: string) => string | undefined
|
||||
onSaved?: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
@@ -22,7 +21,7 @@ export async function flushEditorContent(path: string, deps: FlushDeps): Promise
|
||||
const tabContent = deps.getTabContent(path)
|
||||
if (tabContent === undefined) return
|
||||
|
||||
if (deps.isUnsaved(path) || tabContent !== deps.getSavedContent(path)) {
|
||||
if (deps.isUnsaved(path)) {
|
||||
await persistContent(path, tabContent)
|
||||
deps.onSaved?.(path, tabContent)
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mergeTabContent } from './mergeTabContent'
|
||||
|
||||
describe('mergeTabContent', () => {
|
||||
it('adds tab content when allContent is empty', () => {
|
||||
const result = mergeTabContent({}, [
|
||||
{ entry: { path: '/vault/a.md' }, content: '# Hello\nBody text' },
|
||||
])
|
||||
expect(result['/vault/a.md']).toBe('# Hello\nBody text')
|
||||
})
|
||||
|
||||
it('overrides allContent with tab content (editor state is fresher)', () => {
|
||||
const result = mergeTabContent(
|
||||
{ '/vault/a.md': 'old saved content' },
|
||||
[{ entry: { path: '/vault/a.md' }, content: 'current editor content' }],
|
||||
)
|
||||
expect(result['/vault/a.md']).toBe('current editor content')
|
||||
})
|
||||
|
||||
it('preserves allContent for paths without open tabs', () => {
|
||||
const result = mergeTabContent(
|
||||
{ '/vault/b.md': 'other note content' },
|
||||
[{ entry: { path: '/vault/a.md' }, content: '# Hello' }],
|
||||
)
|
||||
expect(result['/vault/b.md']).toBe('other note content')
|
||||
expect(result['/vault/a.md']).toBe('# Hello')
|
||||
})
|
||||
|
||||
it('returns original object when no tabs have content to merge', () => {
|
||||
const original = { '/vault/a.md': 'content' }
|
||||
expect(mergeTabContent(original, [])).toBe(original)
|
||||
})
|
||||
|
||||
it('skips tabs with empty content', () => {
|
||||
const original = { '/vault/a.md': 'content' }
|
||||
const result = mergeTabContent(original, [
|
||||
{ entry: { path: '/vault/b.md' }, content: '' },
|
||||
])
|
||||
expect(result).toBe(original)
|
||||
expect(result['/vault/b.md']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles multiple tabs', () => {
|
||||
const result = mergeTabContent({}, [
|
||||
{ entry: { path: '/vault/a.md' }, content: 'Note A' },
|
||||
{ entry: { path: '/vault/b.md' }, content: 'Note B' },
|
||||
])
|
||||
expect(result['/vault/a.md']).toBe('Note A')
|
||||
expect(result['/vault/b.md']).toBe('Note B')
|
||||
})
|
||||
|
||||
it('does not mutate the original allContent object', () => {
|
||||
const original = { '/vault/a.md': 'old' }
|
||||
const result = mergeTabContent(original, [
|
||||
{ entry: { path: '/vault/a.md' }, content: 'new' },
|
||||
])
|
||||
expect(original['/vault/a.md']).toBe('old')
|
||||
expect(result['/vault/a.md']).toBe('new')
|
||||
expect(result).not.toBe(original)
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Merge open tab content into the allContent dictionary.
|
||||
* Tab content takes priority (it reflects the current editor state).
|
||||
* Returns the original object if no changes are needed (stable reference for useMemo).
|
||||
*/
|
||||
export function mergeTabContent(
|
||||
allContent: Record<string, string>,
|
||||
tabs: ReadonlyArray<{ entry: { path: string }; content: string }>,
|
||||
): Record<string, string> {
|
||||
let merged: Record<string, string> | null = null
|
||||
for (const tab of tabs) {
|
||||
if (!tab.content) continue
|
||||
if (allContent[tab.entry.path] === tab.content) continue
|
||||
if (!merged) merged = { ...allContent }
|
||||
merged[tab.entry.path] = tab.content
|
||||
}
|
||||
return merged ?? allContent
|
||||
}
|
||||
@@ -174,7 +174,7 @@ describe('buildRelationshipGroups', () => {
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { 'Belongs to': ['[[responsibility/building]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, building], {})
|
||||
const groups = buildRelationshipGroups(entity, [entity, building])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Belongs to')
|
||||
expect(groups.find((g) => g.label === 'Belongs to')!.entries[0].title).toBe('Building')
|
||||
@@ -190,7 +190,7 @@ describe('buildRelationshipGroups', () => {
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { Notes: ['[[note/note1]]', '[[note/note2]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, note1, note2], {})
|
||||
const groups = buildRelationshipGroups(entity, [entity, note1, note2])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Notes')
|
||||
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
|
||||
@@ -215,7 +215,7 @@ describe('buildRelationshipGroups', () => {
|
||||
Topics: ['[[topic/rust]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, ...entries], {})
|
||||
const groups = buildRelationshipGroups(entity, [entity, ...entries])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Belongs to')
|
||||
expect(labels).toContain('Notes')
|
||||
@@ -230,7 +230,7 @@ describe('buildRelationshipGroups', () => {
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, child], {})
|
||||
const groups = buildRelationshipGroups(entity, [entity, child])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Children')
|
||||
expect(groups.find((g) => g.label === 'Children')!.entries[0].title).toBe('Child')
|
||||
@@ -241,14 +241,14 @@ describe('buildRelationshipGroups', () => {
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { Type: ['[[type/project]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity], {})
|
||||
const groups = buildRelationshipGroups(entity, [entity])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).not.toContain('Type')
|
||||
})
|
||||
|
||||
it('returns empty groups for entity with no relationships', () => {
|
||||
const entity = makeEntry({ path: '/Laputa/note/solo.md', filename: 'solo.md', title: 'Solo', relationships: {} })
|
||||
const groups = buildRelationshipGroups(entity, [entity], {})
|
||||
const groups = buildRelationshipGroups(entity, [entity])
|
||||
expect(groups).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -263,7 +263,7 @@ describe('buildRelationshipGroups', () => {
|
||||
Notes: ['[[note/n1]]', '[[note/n2]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2], {})
|
||||
const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2])
|
||||
expect(groups.find((g) => g.label === 'Owner')!.entries).toHaveLength(1)
|
||||
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
|
||||
})
|
||||
@@ -275,7 +275,7 @@ describe('buildRelationshipGroups', () => {
|
||||
path: '/Laputa/type/project.md', filename: 'project.md', title: 'Project',
|
||||
isA: 'Type', relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2], {})
|
||||
const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2])
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Instances')
|
||||
expect(groups.find((g) => g.label === 'Instances')!.entries).toHaveLength(2)
|
||||
@@ -293,7 +293,7 @@ describe('buildRelationshipGroups', () => {
|
||||
Middle: ['[[note/b]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, a, b, c], {})
|
||||
const groups = buildRelationshipGroups(entity, [entity, a, b, c])
|
||||
const directLabels = groups.map((g) => g.label)
|
||||
expect(directLabels.indexOf('Alpha')).toBeLessThan(directLabels.indexOf('Middle'))
|
||||
expect(directLabels.indexOf('Middle')).toBeLessThan(directLabels.indexOf('Zebra'))
|
||||
@@ -308,20 +308,20 @@ describe('buildRelationshipGroups', () => {
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, referer], {})
|
||||
const groups = buildRelationshipGroups(entity, [entity, referer])
|
||||
expect(groups.find((g) => g.label === 'Referenced By')!.entries[0].title).toBe('Referer')
|
||||
})
|
||||
|
||||
it('Backlinks shows entries that mention the entity via wikilinks in content', () => {
|
||||
it('Backlinks shows entries that mention the entity via outgoingLinks', () => {
|
||||
const linker = makeEntry({
|
||||
path: '/Laputa/note/linker.md', filename: 'linker.md', title: 'Linker', modifiedAt: 1700000000,
|
||||
outgoingLinks: ['Alpha'],
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const allContent = { '/Laputa/note/linker.md': 'See [[Alpha]] for details.' }
|
||||
const groups = buildRelationshipGroups(entity, [entity, linker], allContent)
|
||||
const groups = buildRelationshipGroups(entity, [entity, linker])
|
||||
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -242,21 +242,16 @@ export function clearListSortFromLocalStorage(): void {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
|
||||
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[]): VaultEntry[] {
|
||||
const stem = entity.filename.replace(/\.md$/, '')
|
||||
const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
const targets = [entity.title, ...entity.aliases]
|
||||
const targets = new Set([entity.title, ...entity.aliases, stem, pathStem])
|
||||
|
||||
return allEntries.filter((e) => {
|
||||
if (e.path === entity.path) return false
|
||||
const content = allContent[e.path]
|
||||
if (!content) return false
|
||||
for (const t of targets) {
|
||||
if (content.includes(`[[${t}]]`)) return true
|
||||
}
|
||||
if (content.includes(`[[${stem}]]`)) return true
|
||||
if (content.includes(`[[${pathStem}]]`)) return true
|
||||
return content.includes(`[[${pathStem}|`)
|
||||
return e.outgoingLinks.some((link) =>
|
||||
targets.has(link) || targets.has(link.split('/').pop() ?? ''),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -292,7 +287,6 @@ class GroupBuilder {
|
||||
export function buildRelationshipGroups(
|
||||
entity: VaultEntry,
|
||||
allEntries: VaultEntry[],
|
||||
allContent: Record<string, string>,
|
||||
): RelationshipGroup[] {
|
||||
const b = new GroupBuilder(entity.path, allEntries)
|
||||
const rels = entity.relationships ?? {}
|
||||
@@ -312,7 +306,7 @@ export function buildRelationshipGroups(
|
||||
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
|
||||
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
|
||||
b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
|
||||
b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified))
|
||||
b.add('Backlinks', findBacklinks(entity, allEntries).sort(sortByModified))
|
||||
|
||||
return b.groups
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user