import type { ComponentProps } from 'react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { render as rtlRender, screen, fireEvent, act, within } from '@testing-library/react' import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels' import { TooltipProvider } from './ui/tooltip' import type { ReferencedByItem } from './InspectorPanels' import type { VaultEntry, GitCommit } from '../types' // jsdom doesn't implement scrollIntoView Element.prototype.scrollIntoView = vi.fn() const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: TooltipProvider }) const makeEntry = (overrides: Partial = {}): 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, modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [], ...overrides, }) const makeWorkspace = ( alias: string, path: string, label = alias, ): NonNullable => ({ id: alias, label, alias, path, shortLabel: label.slice(0, 2).toUpperCase(), color: null, icon: null, mounted: true, available: true, defaultForNewNotes: false, }) describe('DynamicRelationshipsPanel', () => { const onNavigate = vi.fn() const onAddProperty = vi.fn() const personTypeEntry = makeEntry({ path: '/vault/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 = { Person: personTypeEntry } beforeEach(() => { vi.clearAllMocks() }) const renderRelationshipsPanel = ( overrides: Partial> = {}, ) => render( , ) 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, expectedColor: 'var(--muted-foreground)', }, ])('$name', ({ entry: overrides, ref, key, tMap, expectedColor }) => { const { container } = render( ) const chip = container.querySelector('.group\\/link') expect(chip).toBeTruthy() expect(chip!.style.color).toBe(expectedColor) }) it('hides empty state label when frontmatter has no relations', () => { render( ) expect(screen.queryByText('No relationships')).not.toBeInTheDocument() }) it('renders relationship groups with wikilinks', () => { render( ) expect(screen.getByText('Belongs to')).toBeInTheDocument() expect(screen.getByText('Related to')).toBeInTheDocument() }) it('shows missing type-defined relationships as editable placeholders', () => { const bookType = makeEntry({ path: '/vault/book.md', filename: 'book.md', title: 'Book', isA: 'Type', relationships: { Mentor: ['[[person/alice]]'], }, }) renderRelationshipsPanel({ entry: makeEntry({ title: 'Dune', isA: 'Book' }), entries: [...entries, bookType], frontmatter: {}, onAddProperty, }) const placeholder = screen.getByTestId('type-derived-relationship') expect(within(placeholder).getByText('Mentor')).toHaveClass('text-muted-foreground/40') fireEvent.click(within(placeholder).getByTestId('add-relation-ref')) fireEvent.change(within(placeholder).getByTestId('add-relation-ref-input'), { target: { value: 'My Project' }, }) fireEvent.keyDown(within(placeholder).getByTestId('add-relation-ref-input'), { key: 'Enter' }) expect(onAddProperty).toHaveBeenCalledWith('Mentor', '[[project/my-project]]') }) it('navigates when clicking a relationship link', () => { render( ) // 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( ) expect(screen.getByText('Owner')).toBeInTheDocument() expect(screen.getByText('Luca')).toBeInTheDocument() }) it('renders + Add relationship button when onAddProperty provided', () => { renderRelationshipsPanel({ onAddProperty }) expect(screen.getByText('+ Add relationship')).toBeInTheDocument() }) it('opens add relationship form when button clicked', () => { renderRelationshipsPanel({ onAddProperty }) fireEvent.click(screen.getByText('+ Add relationship')) expect(screen.getByPlaceholderText('Relationship name')).toBeInTheDocument() expect(screen.getByPlaceholderText('Note title')).toBeInTheDocument() }) it('adds relationship via form', () => { renderRelationshipsPanel({ onAddProperty }) fireEvent.click(screen.getByText('+ Add relationship')) fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } }) fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } }) fireEvent.click(screen.getByTestId('submit-add-relationship')) expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]') }) it('prefixes selected cross-workspace relationship targets from the add form', () => { const personal = makeWorkspace('personal', '/personal', 'Personal') const team = makeWorkspace('team', '/team', 'Team') const source = makeEntry({ path: '/personal/source.md', filename: 'source.md', title: 'Source', workspace: personal, }) const remote = makeEntry({ path: '/team/topic/remote.md', filename: 'remote.md', title: 'Remote', workspace: team, }) renderRelationshipsPanel({ entry: source, entries: [source, remote, ...entries], vaultPath: '/personal', onAddProperty, }) fireEvent.click(screen.getByText('+ Add relationship')) fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } }) const noteInput = screen.getByPlaceholderText('Note title') fireEvent.focus(noteInput) fireEvent.change(noteInput, { target: { value: 'Remote' } }) expect(screen.getByTestId('note-search-workspace-badge')).toHaveTextContent('TE') fireEvent.click(screen.getByText('Remote')) fireEvent.click(screen.getByTestId('submit-add-relationship')) expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[team/topic/remote]]') }) it('cancels add relationship form', () => { renderRelationshipsPanel({ onAddProperty }) fireEvent.click(screen.getByText('+ Add relationship')) fireEvent.click(screen.getByText('Cancel')) expect(screen.getByText('+ Add relationship')).toBeInTheDocument() }) describe('suggested relationship slots', () => { it('shows Belongs to/Related to/Has slots when no relationships exist', () => { renderRelationshipsPanel({ onAddProperty }) const slots = screen.getAllByTestId('suggested-relationship') expect(slots.length).toBe(3) expect(screen.getByText('Belongs to')).toBeInTheDocument() expect(screen.getByText('Related to')).toBeInTheDocument() expect(screen.getByText('Has')).toBeInTheDocument() }) it('hides slot when canonical snake_case relationship already exists', () => { renderRelationshipsPanel({ frontmatter: { belongs_to: '[[Project Alpha]]' }, onAddProperty, }) const slots = screen.getAllByTestId('suggested-relationship') expect(slots.length).toBe(2) expect(screen.queryAllByText('Belongs to').every(el => !el.closest('[data-testid="suggested-relationship"]'))).toBe(true) }) it('does not show slots when onAddProperty not provided', () => { renderRelationshipsPanel() expect(screen.queryByTestId('suggested-relationship')).not.toBeInTheDocument() }) }) it('dims archived entries', () => { const archivedEntry = makeEntry({ path: '/vault/project/old.md', filename: 'old.md', title: 'Old Project', isA: 'Project', archived: true, }) render( ) // The button title should indicate "Archived" status expect(screen.getByTitle('Archived')).toBeInTheDocument() }) it('handles aliased wikilinks [[path|Display]]', () => { render( ) expect(screen.getByText('My Cool Project')).toBeInTheDocument() }) it('shows emoji icon before label when entry has an emoji', () => { const emojiEntry = makeEntry({ path: '/vault/note/rocket.md', filename: 'rocket.md', title: 'Rocket Note', isA: 'Note', icon: '🚀', }) render( ) expect(screen.getByText('🚀')).toBeInTheDocument() expect(screen.getByText('Rocket Note')).toBeInTheDocument() }) it('does not show emoji when entry has no icon', () => { render( ) const link = screen.getByText('My Project') const container = link.closest('.group\\/link') expect(container?.textContent).not.toMatch(/^[\p{Emoji_Presentation}]/u) }) describe('relation editing', () => { const onUpdateProperty = vi.fn() const onDeleteProperty = vi.fn() const renderEditableRelationships = ( overrides: Partial> = {}, ) => renderRelationshipsPanel({ frontmatter: { 'Belongs to': ['[[project/my-project]]'] }, onUpdateProperty, onDeleteProperty, ...overrides, }) const openInlineAdd = (value?: string) => { fireEvent.click(screen.getByTestId('add-relation-ref')) const input = screen.getByTestId('add-relation-ref-input') if (value !== undefined) { fireEvent.change(input, { target: { value } }) } return input } beforeEach(() => { vi.clearAllMocks() }) it('shows remove buttons on relation refs when editing is enabled', () => { renderEditableRelationships() expect(screen.getByTestId('remove-relation-ref')).toBeInTheDocument() }) it('does not show remove buttons when editing is disabled', () => { renderRelationshipsPanel({ frontmatter: { 'Belongs to': ['[[project/my-project]]'] } }) expect(screen.queryByTestId('remove-relation-ref')).not.toBeInTheDocument() }) it('calls onDeleteProperty when removing the last ref in a group', () => { renderEditableRelationships() fireEvent.click(screen.getByTestId('remove-relation-ref')) expect(onDeleteProperty).toHaveBeenCalledWith('Belongs to') }) it.each([ { name: 'calls onUpdateProperty with remaining refs when removing one of many', frontmatter: { 'Related to': ['[[project/my-project]]', '[[topic/ai]]'] }, expectedKey: 'Related to', expectedValue: '[[topic/ai]]', }, { name: 'calls onUpdateProperty with string when two refs become one', frontmatter: { Has: ['[[project/my-project]]', '[[topic/ai]]'] }, expectedKey: 'Has', expectedValue: '[[topic/ai]]', }, ])('$name', ({ frontmatter, expectedKey, expectedValue }) => { renderEditableRelationships({ frontmatter }) const removeButtons = screen.getAllByTestId('remove-relation-ref') fireEvent.click(removeButtons[0]) expect(onUpdateProperty).toHaveBeenCalledWith(expectedKey, expectedValue) }) it('shows inline add button for each relationship group when editing is enabled', () => { renderEditableRelationships() expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument() }) it('opens inline add input when add button clicked', () => { renderEditableRelationships() openInlineAdd() expect(screen.getByTestId('add-relation-ref-input')).toBeInTheDocument() }) it('adds a note to an existing relationship via inline add', () => { renderEditableRelationships() const input = openInlineAdd('AI') fireEvent.keyDown(input, { key: 'Enter' }) expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]']) }) it('clicks a search result to add the relationship and close the inline editor', () => { renderEditableRelationships() openInlineAdd('AI') fireEvent.click(screen.getByText('AI')) expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]']) expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument() }) it('prefixes inline cross-workspace relationship targets with the target workspace alias', () => { const personal = makeWorkspace('personal', '/personal', 'Personal') const team = makeWorkspace('team', '/team', 'Team') const source = makeEntry({ path: '/personal/source.md', filename: 'source.md', title: 'Source', workspace: personal, }) const remote = makeEntry({ path: '/team/topic/remote.md', filename: 'remote.md', title: 'Remote', workspace: team, }) renderEditableRelationships({ entry: source, entries: [source, remote, ...entries], vaultPath: '/personal', }) const input = openInlineAdd('Remote') fireEvent.keyDown(input, { key: 'Enter' }) expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[team/topic/remote]]']) }) it('does not add duplicate refs', () => { renderEditableRelationships({ frontmatter: { 'Belongs to': ['[[topic/ai]]'] } }) const input = openInlineAdd('AI') fireEvent.keyDown(input, { key: 'Enter' }) expect(onUpdateProperty).not.toHaveBeenCalled() }) it('ignores empty inline-add Enter presses', () => { renderEditableRelationships() const input = openInlineAdd() fireEvent.keyDown(input, { key: 'Enter' }) expect(onUpdateProperty).not.toHaveBeenCalled() expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument() }) it('closes inline add on Escape', () => { renderEditableRelationships() const input = openInlineAdd() fireEvent.keyDown(input, { key: 'Escape' }) expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument() expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument() }) }) describe('create & open from inline add', () => { const onUpdateProperty = vi.fn() const onDeleteProperty = vi.fn() const onCreateAndOpenNote = vi.fn<(title: string) => Promise>() const renderInlineCreatePanel = ( overrides: Partial> = {}, ) => renderRelationshipsPanel({ frontmatter: { 'Belongs to': ['[[project/my-project]]'] }, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote, ...overrides, }) const openInlineCreate = (value: string) => { fireEvent.click(screen.getByTestId('add-relation-ref')) const input = screen.getByTestId('add-relation-ref-input') fireEvent.change(input, { target: { value } }) return input } beforeEach(() => { vi.clearAllMocks() onCreateAndOpenNote.mockResolvedValue(true) }) it('shows "Create & open" option when typed title does not match any note', () => { renderInlineCreatePanel() openInlineCreate('Brand New Note') expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument() expect(screen.getByText(/Create & open/)).toBeInTheDocument() expect(screen.getByText(/Brand New Note/)).toBeInTheDocument() }) it('does not show "Create & open" when typed title matches an existing note', () => { renderInlineCreatePanel() openInlineCreate('AI') expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument() }) it('calls onCreateAndOpenNote and adds wikilink when "Create & open" clicked', async () => { renderInlineCreatePanel() openInlineCreate('Brand New Note') fireEvent.click(screen.getByTestId('create-and-open-option')) expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note') await vi.waitFor(() => { expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[brand-new-note]]']) }) }) it('does not add wikilink when note creation fails', async () => { onCreateAndOpenNote.mockResolvedValue(false) renderInlineCreatePanel() openInlineCreate('Failing Note') fireEvent.click(screen.getByTestId('create-and-open-option')) expect(onCreateAndOpenNote).toHaveBeenCalledWith('Failing Note') // Give async handler time to resolve await vi.waitFor(() => { expect(onCreateAndOpenNote).toHaveBeenCalled() }) // Wikilink is deferred to next tick and only added on success expect(onUpdateProperty).not.toHaveBeenCalled() }) it('shows both existing matches and "Create & open" for partial matches', () => { renderInlineCreatePanel() // "My" partially matches "My Project" but is not an exact match. openInlineCreate('My') // Should show search results AND create option expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument() }) it('does not show "Create & open" when onCreateAndOpenNote is not provided', () => { renderInlineCreatePanel({ onCreateAndOpenNote: undefined }) openInlineCreate('Brand New Note') expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument() }) }) describe('create & open from AddRelationshipForm', () => { const onCreateAndOpenNote = vi.fn<(title: string) => Promise>() beforeEach(() => { vi.clearAllMocks() onCreateAndOpenNote.mockResolvedValue(true) }) it('shows "Create & open" option in target input when title does not exist', () => { renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote }) fireEvent.click(screen.getByText('+ Add relationship')) fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } }) const noteInput = screen.getByPlaceholderText('Note title') fireEvent.focus(noteInput) fireEvent.change(noteInput, { target: { value: 'New Person' } }) expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument() }) it('submits from the relationship-name input on Enter and cancels the form from the note input on Escape', () => { renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote }) fireEvent.click(screen.getByText('+ Add relationship')) fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } }) fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } }) fireEvent.keyDown(screen.getByPlaceholderText('Relationship name'), { key: 'Enter' }) expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[topic/ai]]') fireEvent.click(screen.getByText('+ Add relationship')) fireEvent.keyDown(screen.getByPlaceholderText('Note title'), { key: 'Escape' }) expect(screen.queryByPlaceholderText('Relationship name')).not.toBeInTheDocument() }) it('hides the target dropdown after blur once the grace timeout expires', () => { vi.useFakeTimers() renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote }) fireEvent.click(screen.getByText('+ Add relationship')) fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } }) const noteInput = screen.getByPlaceholderText('Note title') fireEvent.focus(noteInput) fireEvent.change(noteInput, { target: { value: 'New Person' } }) expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument() act(() => { fireEvent.blur(noteInput) vi.advanceTimersByTime(150) }) expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument() vi.useRealTimers() }) it('creates note and adds relationship via form', async () => { renderRelationshipsPanel({ onAddProperty, onCreateAndOpenNote }) fireEvent.click(screen.getByText('+ Add relationship')) fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } }) const noteInput = screen.getByPlaceholderText('Note title') fireEvent.focus(noteInput) fireEvent.change(noteInput, { target: { value: 'New Person' } }) fireEvent.click(screen.getByTestId('create-and-open-option')) expect(onCreateAndOpenNote).toHaveBeenCalledWith('New Person') await vi.waitFor(() => { expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[new-person]]') }) }) }) }) describe('BacklinksPanel', () => { const onNavigate = vi.fn() beforeEach(() => { vi.clearAllMocks() }) it('renders nothing when empty', () => { const { container } = render() expect(container.innerHTML).toBe('') }) const twoBacklinks = [ { entry: makeEntry({ title: 'Referencing Note', isA: 'Note' }), context: null }, { entry: makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }), context: null }, ] it('renders header and all backlinks immediately (no collapse)', () => { render() expect(screen.getByText('Backlinks')).toBeInTheDocument() expect(screen.getByText('Referencing Note')).toBeInTheDocument() expect(screen.getByText('Another Note')).toBeInTheDocument() }) it('navigates when clicking backlink', () => { const backlinks = [{ entry: makeEntry({ title: 'Reference' }), context: null }] render() fireEvent.click(screen.getByText('Reference')) expect(onNavigate).toHaveBeenCalledWith('Reference') }) it('shows paragraph context preview when available', () => { const backlinks = [ { entry: makeEntry({ title: 'Referencing Note' }), context: 'This references [[My Note]] in context.' }, ] render() expect(screen.getByText('This references [[My Note]] in context.')).toBeInTheDocument() }) it('shows emoji icon before backlink title when entry has an emoji', () => { const backlinks = [{ entry: makeEntry({ title: 'Starred Note', icon: '⭐' }), context: null, }] render() expect(screen.getByText('⭐')).toBeInTheDocument() expect(screen.getByText('Starred Note')).toBeInTheDocument() }) it('does not show emoji when backlink entry has no icon', () => { const backlinks = [{ entry: makeEntry({ title: 'Plain Note' }), context: null }] render() expect(screen.getByText('Plain Note')).toBeInTheDocument() const btn = screen.getByText('Plain Note').closest('button') const spans = btn?.querySelectorAll('span.shrink-0') const emojiSpans = Array.from(spans ?? []).filter(s => /[\p{Emoji_Presentation}]/u.test(s.textContent ?? '')) expect(emojiSpans).toHaveLength(0) }) }) describe('ReferencedByPanel', () => { const onNavigate = vi.fn() beforeEach(() => { vi.clearAllMocks() }) it('renders nothing when empty', () => { const { container } = render() 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() expect(screen.getByTestId('derived-relationships-separator')).toBeInTheDocument() expect(screen.getByText('Write Essays')).toBeInTheDocument() expect(screen.getByText('On Writing Well')).toBeInTheDocument() expect(screen.getByText('SEO Experiment')).toBeInTheDocument() expect(screen.getByText('Children')).toBeInTheDocument() expect(screen.getByText('Referenced by')).toBeInTheDocument() expect(screen.queryByText('Derived relationships')).not.toBeInTheDocument() expect(screen.queryByText('Read-only groups sourced from other notes.')).not.toBeInTheDocument() expect(screen.queryByText('Events')).not.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() 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() expect(screen.getByTitle('Archived')).toBeInTheDocument() }) it('adds an inverse-relationship tooltip to each derived relationship label', async () => { const items: ReferencedByItem[] = [ { entry: makeEntry({ path: '/vault/a.md', title: 'Child Note' }), viaKey: 'Belongs to' }, ] render() fireEvent.focus(screen.getByText('Children')) expect(await screen.findByRole('tooltip')).toHaveTextContent('Derived inverse relationship, inverse of Belongs to.') }) }) describe('GitHistoryPanel', () => { const onViewCommitDiff = vi.fn() beforeEach(() => { vi.clearAllMocks() }) it('renders nothing when empty', () => { const { container } = render() expect(container.innerHTML).toBe('') }) it('renders commit entries with hash and message', () => { 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() expect(screen.getByText('abc1234')).toBeInTheDocument() expect(screen.getByText('def4567')).toBeInTheDocument() }) it('calls onViewCommitDiff when clicking commit entry', () => { const commits: GitCommit[] = [ { hash: 'abc1234567890', shortHash: 'abc1234', message: 'test', author: '', date: Math.floor(Date.now() / 1000) }, ] render() 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() expect(screen.getByText('today')).toBeInTheDocument() expect(screen.getByText('yesterday')).toBeInTheDocument() expect(screen.getByText('10d ago')).toBeInTheDocument() }) }) describe('InstancesPanel', () => { const onNavigate = vi.fn() const quarterType = makeEntry({ path: '/vault/quarter.md', filename: 'quarter.md', title: 'Quarter', isA: 'Type', color: 'blue', icon: 'calendar', }) const typeEntryMap: Record = { Quarter: quarterType } beforeEach(() => { vi.clearAllMocks() }) it('renders nothing when entry is not a Type', () => { const entry = makeEntry({ title: 'Random Note', isA: 'Note' }) const { container } = render( ) expect(container.innerHTML).toBe('') }) it('renders nothing when Type has zero instances', () => { const { container } = render( ) expect(container.innerHTML).toBe('') }) it('renders instances of a Type sorted by modifiedAt descending', () => { const instances = [ makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }), makeEntry({ path: '/vault/quarter/q2.md', title: 'Q2 2026', isA: 'Quarter', modifiedAt: 3000 }), makeEntry({ path: '/vault/quarter/q3.md', title: 'Q3 2026', isA: 'Quarter', modifiedAt: 2000 }), ] render( ) expect(screen.getByText('Instances (3)')).toBeInTheDocument() const buttons = screen.getAllByRole('button').filter(b => ['Q1 2026', 'Q2 2026', 'Q3 2026'].includes(b.textContent?.replace(/\s*\(.*\)/, '') ?? '')) // Q2 (3000) should come before Q3 (2000) before Q1 (1000) expect(buttons[0].textContent).toContain('Q2 2026') expect(buttons[1].textContent).toContain('Q3 2026') expect(buttons[2].textContent).toContain('Q1 2026') }) it('dims archived instances', () => { const instances = [ makeEntry({ path: '/vault/quarter/old.md', title: 'Q4 2024', isA: 'Quarter', archived: true, modifiedAt: 1000 }), ] render( ) expect(screen.getByTitle('Archived')).toBeInTheDocument() }) it('navigates when clicking an instance', () => { const instances = [ makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }), ] render( ) fireEvent.click(screen.getByText('Q1 2026')) expect(onNavigate).toHaveBeenCalledWith('Q1 2026') }) it('caps display at 50 instances and shows count badge', () => { const instances = Array.from({ length: 80 }, (_, i) => makeEntry({ path: `/vault/quarter/q${i}.md`, title: `Instance ${i}`, isA: 'Quarter', modifiedAt: 80 - i, }) ) render( ) expect(screen.getByText('Instances (80)')).toBeInTheDocument() // Only 50 link buttons rendered const allButtons = screen.getAllByRole('button') const instanceButtons = allButtons.filter(b => b.textContent?.startsWith('Instance')) expect(instanceButtons.length).toBe(50) expect(screen.getByText('showing 50 of 80')).toBeInTheDocument() }) it('does not show Instances section for non-Type note even if title matches a type name', () => { const notAType = makeEntry({ title: 'Quarter', isA: 'Project' }) const instances = [ makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }), ] const { container } = render( ) expect(container.innerHTML).toBe('') }) })