Files
tolaria/src/components/InspectorPanels.test.tsx
Luca Rossi 8e5cbb2a34 feat: note subtitle — show metadata (date + word count) (#94)
* feat: show metadata subtitle (date + word count) instead of snippet

Replace the note list subtitle with a compact metadata summary showing
relative date and word count (e.g. "2d ago · 342 words") instead of
the first paragraph of note content. Adds word_count to VaultEntry on
the Rust backend, computed during vault scan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: cargo fmt formatting

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:50:29 +00:00

559 lines
20 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
import type { ReferencedByItem } from './InspectorPanels'
import type { VaultEntry, GitCommit } from '../types'
// jsdom doesn't implement scrollIntoView
Element.prototype.scrollIntoView = vi.fn()
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
...overrides,
})
describe('DynamicRelationshipsPanel', () => {
const onNavigate = vi.fn()
const onAddProperty = vi.fn()
const personTypeEntry = makeEntry({
path: '/vault/type/person.md', filename: 'person.md', title: 'Person',
isA: 'Type', color: 'yellow', icon: 'user',
})
const entries = [
makeEntry({ path: '/vault/project/my-project.md', filename: 'my-project.md', title: 'My Project', isA: 'Project' }),
makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI', isA: 'Topic' }),
personTypeEntry,
]
const typeEntryMap: Record<string, VaultEntry> = { Person: personTypeEntry }
beforeEach(() => {
vi.clearAllMocks()
})
it.each([
{
name: 'applies type color via typeEntryMap',
entry: { path: '/vault/people/luca.md', filename: 'luca.md', title: 'Luca', isA: 'Person' as const },
ref: '[[Luca]]', key: 'Owner', tMap: typeEntryMap, expectedColor: 'var(--accent-yellow)',
},
{
name: 'resolves by title when filename differs',
entry: { path: '/vault/people/john-doe.md', filename: 'john-doe.md', title: 'John Doe', isA: 'Person' as const },
ref: '[[John Doe]]', key: 'Owner', tMap: typeEntryMap, expectedColor: 'var(--accent-yellow)',
},
{
name: 'shows neutral color for notes with no type',
entry: { path: '/vault/misc/random.md', filename: 'random.md', title: 'Random', isA: null },
ref: '[[Random]]', key: 'Related to', tMap: {} as Record<string, VaultEntry>, expectedColor: 'var(--muted-foreground)',
},
])('$name', ({ entry: overrides, ref, key, tMap, expectedColor }) => {
const { container } = render(
<DynamicRelationshipsPanel
typeEntryMap={tMap}
frontmatter={{ [key]: [ref] }}
entries={[...entries, makeEntry(overrides)]}
onNavigate={onNavigate}
/>
)
const chip = container.querySelector('.group\\/link button')
expect(chip).toBeTruthy()
expect(chip!.style.color).toBe(expectedColor)
})
it('hides empty state label when frontmatter has no relations', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ Status: 'Active', title: 'Test' }}
entries={entries}
onNavigate={onNavigate}
/>
)
expect(screen.queryByText('No relationships')).not.toBeInTheDocument()
})
it('renders relationship groups with wikilinks', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{
'Belongs to': ['[[project/my-project]]'],
'Related to': ['[[topic/ai]]'],
}}
entries={entries}
onNavigate={onNavigate}
/>
)
expect(screen.getByText('Belongs to')).toBeInTheDocument()
expect(screen.getByText('Related to')).toBeInTheDocument()
})
it('navigates when clicking a relationship link', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
/>
)
// Click the rendered link
const link = screen.getByText('My Project')
fireEvent.click(link)
expect(onNavigate).toHaveBeenCalledWith('project/my-project')
})
it('renders single string wikilink value', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ Owner: '[[person/luca]]' }}
entries={[makeEntry({ path: '/vault/person/luca.md', filename: 'luca.md', title: 'Luca', isA: 'Person' })] }
onNavigate={onNavigate}
/>
)
expect(screen.getByText('Owner')).toBeInTheDocument()
expect(screen.getByText('Luca')).toBeInTheDocument()
})
it('renders + Link existing button when onAddProperty provided', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{}}
entries={entries}
onNavigate={onNavigate}
onAddProperty={onAddProperty}
/>
)
expect(screen.getByText('+ Link existing')).toBeInTheDocument()
})
it('opens add relationship form when button clicked', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{}}
entries={entries}
onNavigate={onNavigate}
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Link existing'))
expect(screen.getByPlaceholderText('Relationship name')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Note title')).toBeInTheDocument()
})
it('adds relationship via form', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{}}
entries={entries}
onNavigate={onNavigate}
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Link existing'))
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } })
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
fireEvent.click(screen.getByText('Add'))
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[AI]]')
})
it('cancels add relationship form', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{}}
entries={entries}
onNavigate={onNavigate}
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Link existing'))
fireEvent.click(screen.getByText('Cancel'))
expect(screen.getByText('+ Link existing')).toBeInTheDocument()
})
it('dims archived entries', () => {
const archivedEntry = makeEntry({
path: '/vault/project/old.md', filename: 'old.md', title: 'Old Project', isA: 'Project', archived: true,
})
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/old]]'] }}
entries={[archivedEntry]}
onNavigate={onNavigate}
/>
)
// The button title should indicate "Archived" status
expect(screen.getByTitle('Archived')).toBeInTheDocument()
})
it('shows trashed indicator for trashed entries', () => {
const trashedEntry = makeEntry({
path: '/vault/project/trash.md', filename: 'trash.md', title: 'Trash Project', isA: 'Project', trashed: true,
})
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/trash]]'] }}
entries={[trashedEntry]}
onNavigate={onNavigate}
/>
)
expect(screen.getByTitle('Trashed')).toBeInTheDocument()
})
it('handles aliased wikilinks [[path|Display]]', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project|My Cool Project]]'] }}
entries={entries}
onNavigate={onNavigate}
/>
)
expect(screen.getByText('My Cool Project')).toBeInTheDocument()
})
describe('relation editing', () => {
const onUpdateProperty = vi.fn()
const onDeleteProperty = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('shows remove buttons on relation refs when editing is enabled', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
expect(screen.getByTestId('remove-relation-ref')).toBeInTheDocument()
})
it('does not show remove buttons when editing is disabled', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
/>
)
expect(screen.queryByTestId('remove-relation-ref')).not.toBeInTheDocument()
})
it('calls onDeleteProperty when removing the last ref in a group', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
fireEvent.click(screen.getByTestId('remove-relation-ref'))
expect(onDeleteProperty).toHaveBeenCalledWith('Belongs to')
})
it('calls onUpdateProperty with remaining refs when removing one of many', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Related to': ['[[project/my-project]]', '[[topic/ai]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
const removeButtons = screen.getAllByTestId('remove-relation-ref')
fireEvent.click(removeButtons[0]) // Remove first ref
expect(onUpdateProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]')
})
it('calls onUpdateProperty with string when two refs become one', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Has': ['[[project/my-project]]', '[[topic/ai]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
const removeButtons = screen.getAllByTestId('remove-relation-ref')
fireEvent.click(removeButtons[0])
// Should pass a single string, not an array of one
expect(onUpdateProperty).toHaveBeenCalledWith('Has', '[[topic/ai]]')
})
it('shows inline add button for each relationship group when editing is enabled', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
})
it('opens inline add input when add button clicked', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
fireEvent.click(screen.getByTestId('add-relation-ref'))
expect(screen.getByTestId('add-relation-ref-input')).toBeInTheDocument()
})
it('adds a note to an existing relationship via inline add', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'AI' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[AI]]'])
})
it('does not add duplicate refs', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[AI]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'AI' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateProperty).not.toHaveBeenCalled()
})
it('closes inline add on Escape', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.keyDown(input, { key: 'Escape' })
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
})
})
})
describe('BacklinksPanel', () => {
const onNavigate = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('renders nothing when empty', () => {
const { container } = render(<BacklinksPanel typeEntryMap={{}} backlinks={[]} onNavigate={onNavigate} />)
expect(container.innerHTML).toBe('')
})
it('renders backlink entries', () => {
const backlinks = [
makeEntry({ title: 'Referencing Note', isA: 'Note' }),
makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }),
]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
expect(screen.getByText('Referencing Note')).toBeInTheDocument()
expect(screen.getByText('Another Note')).toBeInTheDocument()
})
it('navigates when clicking backlink', () => {
const backlinks = [makeEntry({ title: 'Reference' })]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
fireEvent.click(screen.getByText('Reference'))
expect(onNavigate).toHaveBeenCalledWith('Reference')
})
it('shows count when backlinks exist', () => {
const backlinks = [makeEntry(), makeEntry({ path: '/vault/b.md', title: 'B' })]
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
expect(screen.getByText('2')).toBeInTheDocument()
})
})
describe('ReferencedByPanel', () => {
const onNavigate = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('renders nothing when empty', () => {
const { container } = render(<ReferencedByPanel typeEntryMap={{}} items={[]} onNavigate={onNavigate} />)
expect(container.innerHTML).toBe('')
})
it('renders referenced-by entries grouped by relationship key', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/proc/a.md', title: 'Write Essays', isA: 'Procedure' }), viaKey: 'Belongs to' },
{ entry: makeEntry({ path: '/vault/essay/b.md', title: 'On Writing Well', isA: 'Essay' }), viaKey: 'Belongs to' },
{ entry: makeEntry({ path: '/vault/exp/c.md', title: 'SEO Experiment', isA: 'Experiment' }), viaKey: 'Related to' },
]
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
expect(screen.getByText('Write Essays')).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
expect(screen.getByText('SEO Experiment')).toBeInTheDocument()
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
expect(screen.getByText(/via Related to/)).toBeInTheDocument()
})
it('shows count badge when items exist', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/a.md', title: 'A' }), viaKey: 'Has' },
{ entry: makeEntry({ path: '/vault/b.md', title: 'B' }), viaKey: 'Has' },
{ entry: makeEntry({ path: '/vault/c.md', title: 'C' }), viaKey: 'Topics' },
]
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
expect(screen.getByText('3')).toBeInTheDocument()
})
it('navigates when clicking a referenced-by entry', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/a.md', title: 'My Note' }), viaKey: 'Belongs to' },
]
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
fireEvent.click(screen.getByText('My Note'))
expect(onNavigate).toHaveBeenCalledWith('My Note')
})
it('dims archived entries in referenced-by', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/a.md', title: 'Old Note', archived: true }), viaKey: 'Has' },
]
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
expect(screen.getByTitle('Archived')).toBeInTheDocument()
})
it('shows trashed indicator for trashed entries', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/a.md', title: 'Trash Note', trashed: true }), viaKey: 'Has' },
]
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
expect(screen.getByTitle('Trashed')).toBeInTheDocument()
})
})
describe('GitHistoryPanel', () => {
const onViewCommitDiff = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('shows "No revision history" when empty', () => {
render(<GitHistoryPanel commits={[]} />)
expect(screen.getByText('No revision history')).toBeInTheDocument()
})
it('renders commit entries', () => {
const commits: GitCommit[] = [
{ hash: 'abc1234567890', shortHash: 'abc1234', message: 'Initial commit', author: 'luca', date: Math.floor(Date.now() / 1000) - 3600 },
{ hash: 'def4567890123', shortHash: 'def4567', message: 'Fix bug', author: 'jane', date: Math.floor(Date.now() / 1000) - 86400 * 2 },
]
render(<GitHistoryPanel commits={commits} onViewCommitDiff={onViewCommitDiff} />)
expect(screen.getByText('abc1234')).toBeInTheDocument()
expect(screen.getByText('def4567')).toBeInTheDocument()
expect(screen.getByText('Initial commit')).toBeInTheDocument()
expect(screen.getByText('Fix bug')).toBeInTheDocument()
expect(screen.getByText('luca')).toBeInTheDocument()
expect(screen.getByText('jane')).toBeInTheDocument()
})
it('calls onViewCommitDiff when clicking commit hash', () => {
const commits: GitCommit[] = [
{ hash: 'abc1234567890', shortHash: 'abc1234', message: 'test', author: '', date: Math.floor(Date.now() / 1000) },
]
render(<GitHistoryPanel commits={commits} onViewCommitDiff={onViewCommitDiff} />)
fireEvent.click(screen.getByText('abc1234'))
expect(onViewCommitDiff).toHaveBeenCalledWith('abc1234567890')
})
it('displays relative dates correctly', () => {
const now = Math.floor(Date.now() / 1000)
const commits: GitCommit[] = [
{ hash: 'a', shortHash: 'a1', message: 'm1', author: '', date: now }, // today
{ hash: 'b', shortHash: 'b1', message: 'm2', author: '', date: now - 86400 }, // yesterday
{ hash: 'c', shortHash: 'c1', message: 'm3', author: '', date: now - 86400 * 10 }, // 10d ago
{ hash: 'd', shortHash: 'd1', message: 'm4', author: '', date: now - 86400 * 45 }, // ~1.5mo ago
]
render(<GitHistoryPanel commits={commits} />)
expect(screen.getByText('today')).toBeInTheDocument()
expect(screen.getByText('yesterday')).toBeInTheDocument()
expect(screen.getByText('10d ago')).toBeInTheDocument()
})
})