import { render, screen, fireEvent } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { Sidebar } from './Sidebar' import type { VaultEntry, SidebarSelection } from '../types' // Mock localStorage for section visibility tests const localStorageMock = (() => { let store: Record = {} return { getItem: (key: string) => store[key] ?? null, setItem: (key: string, value: string) => { store[key] = value }, removeItem: (key: string) => { delete store[key] }, clear: () => { store = {} }, } })() Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) const mockEntries: VaultEntry[] = [ { path: '/vault/project/build-app.md', filename: 'build-app.md', title: 'Build Laputa App', isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: 'Active', owner: 'Luca', cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 1024, snippet: '', relationships: {}, icon: null, color: null, order: null, }, { path: '/vault/responsibility/grow-newsletter.md', filename: 'grow-newsletter.md', title: 'Grow Newsletter', isA: 'Responsibility', aliases: [], belongsTo: [], relatedTo: [], status: 'Active', owner: 'Luca', cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 512, snippet: '', relationships: {}, icon: null, color: null, order: null, }, { path: '/vault/experiment/stock-screener.md', filename: 'stock-screener.md', title: 'Stock Screener', isA: 'Experiment', aliases: [], belongsTo: [], relatedTo: [], status: 'Active', owner: 'Luca', cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 256, snippet: '', relationships: {}, icon: null, color: null, order: null, }, { path: '/vault/procedure/weekly-essays.md', filename: 'weekly-essays.md', title: 'Write Weekly Essays', isA: 'Procedure', aliases: [], belongsTo: [], relatedTo: [], status: 'Active', owner: 'Luca', cadence: 'Weekly', archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 128, snippet: '', relationships: {}, icon: null, color: null, order: null, }, { path: '/vault/topic/software-development.md', filename: 'software-development.md', title: 'Software Development', isA: 'Topic', aliases: ['Dev', 'Coding'], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 256, snippet: '', relationships: {}, icon: null, color: null, order: null, }, { path: '/vault/topic/trading.md', filename: 'trading.md', title: 'Trading', isA: 'Topic', aliases: ['Algorithmic Trading'], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 180, snippet: '', relationships: {}, icon: null, color: null, order: null, }, { path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice', isA: 'Person', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', relationships: {}, icon: null, color: null, order: null, }, { path: '/vault/event/kickoff.md', filename: 'kickoff.md', title: 'Kickoff Meeting', isA: 'Event', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', relationships: {}, icon: null, color: null, order: null, }, ] const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' } describe('Sidebar', () => { it('renders top nav items (All Notes and Favorites)', () => { render( {}} />) expect(screen.getByText('All Notes')).toBeInTheDocument() expect(screen.getByText('Favorites')).toBeInTheDocument() }) it('renders section group headers with new labels', () => { render( {}} />) expect(screen.getByText('Projects')).toBeInTheDocument() expect(screen.getByText('Experiments')).toBeInTheDocument() expect(screen.getByText('Responsibilities')).toBeInTheDocument() expect(screen.getByText('Procedures')).toBeInTheDocument() expect(screen.getByText('People')).toBeInTheDocument() expect(screen.getByText('Events')).toBeInTheDocument() expect(screen.getByText('Topics')).toBeInTheDocument() expect(screen.getByText('Types')).toBeInTheDocument() }) it('shows entity names under their section groups after expanding', () => { render( {}} />) // Sections start collapsed by default — expand them first fireEvent.click(screen.getByLabelText('Expand Projects')) fireEvent.click(screen.getByLabelText('Expand Responsibilities')) fireEvent.click(screen.getByLabelText('Expand Experiments')) fireEvent.click(screen.getByLabelText('Expand Procedures')) expect(screen.getByText('Build Laputa App')).toBeInTheDocument() expect(screen.getByText('Grow Newsletter')).toBeInTheDocument() expect(screen.getByText('Stock Screener')).toBeInTheDocument() expect(screen.getByText('Write Weekly Essays')).toBeInTheDocument() }) it('shows People and Events items after expanding', () => { render( {}} />) fireEvent.click(screen.getByLabelText('Expand People')) fireEvent.click(screen.getByLabelText('Expand Events')) expect(screen.getByText('Alice')).toBeInTheDocument() expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument() }) it('collapses and expands sections', () => { render( {}} />) // Start collapsed — items hidden expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() // Expand fireEvent.click(screen.getByLabelText('Expand Projects')) expect(screen.getByText('Build Laputa App')).toBeInTheDocument() // Collapse fireEvent.click(screen.getByLabelText('Collapse Projects')) expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() }) it('calls onSelect when clicking an entity', () => { const onSelect = vi.fn() render() fireEvent.click(screen.getByLabelText('Expand Projects')) fireEvent.click(screen.getByText('Build Laputa App')) expect(onSelect).toHaveBeenCalledWith({ kind: 'entity', entry: mockEntries[0], }) }) it('calls onSelect when clicking a section header', () => { const onSelect = vi.fn() render() fireEvent.click(screen.getByText('Projects')) expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Project', }) }) it('calls onSelect with sectionGroup for People', () => { const onSelect = vi.fn() render() fireEvent.click(screen.getByText('People')) expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Person', }) }) it('renders Topics section with topic entries after expanding', () => { render( {}} />) expect(screen.getByText('Topics')).toBeInTheDocument() fireEvent.click(screen.getByLabelText('Expand Topics')) expect(screen.getByText('Software Development')).toBeInTheDocument() expect(screen.getByText('Trading')).toBeInTheDocument() }) it('calls onSelect with topic kind when clicking a topic', () => { const onSelect = vi.fn() render() fireEvent.click(screen.getByLabelText('Expand Topics')) fireEvent.click(screen.getByText('Software Development')) expect(onSelect).toHaveBeenCalledWith({ kind: 'topic', entry: mockEntries[4], }) }) it('renders + buttons for each section group when onCreateType is provided', () => { const onCreateType = vi.fn() render( {}} onCreateType={onCreateType} />) const createButtons = screen.getAllByTitle(/^New /) expect(createButtons.length).toBe(8) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics, Types }) it('calls onCreateType with correct type when + button is clicked', () => { const onCreateType = vi.fn() render( {}} onCreateType={onCreateType} />) fireEvent.click(screen.getByTitle('New Project')) expect(onCreateType).toHaveBeenCalledWith('Project') }) it('does not render + buttons when onCreateType is not provided', () => { render( {}} />) expect(screen.queryByTitle('New Project')).not.toBeInTheDocument() }) it('renders commit button even when no modified files', () => { render( {}} onCommitPush={() => {}} />) expect(screen.getByText('Commit & Push')).toBeInTheDocument() }) it('shows badge on commit button when modified files exist', () => { render( {}} modifiedCount={3} onCommitPush={() => {}} />) expect(screen.getByText('Commit & Push')).toBeInTheDocument() const badges = screen.getAllByText('3') expect(badges.length).toBeGreaterThanOrEqual(1) }) describe('dynamic custom type sections', () => { const entriesWithCustomTypes: VaultEntry[] = [ ...mockEntries, { path: '/vault/type/recipe.md', filename: 'recipe.md', title: 'Recipe', isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', relationships: {}, icon: null, color: null, order: null, }, { path: '/vault/type/book.md', filename: 'book.md', title: 'Book', isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', relationships: {}, icon: null, color: null, order: null, }, { path: '/vault/recipe/pasta.md', filename: 'pasta.md', title: 'Pasta Carbonara', isA: 'Recipe', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 300, snippet: '', relationships: {}, icon: null, color: null, order: null, }, ] it('renders custom type sections derived from Type entries', () => { render( {}} onCreateType={() => {}} />) expect(screen.getByText('Books')).toBeInTheDocument() expect(screen.getByText('Recipes')).toBeInTheDocument() }) it('shows instances of custom types under their section after expanding', () => { render( {}} onCreateType={() => {}} />) fireEvent.click(screen.getByLabelText('Expand Recipes')) expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument() }) it('renders + button on custom type sections for creating instances', () => { const onCreateType = vi.fn() render( {}} onCreateType={onCreateType} />) fireEvent.click(screen.getByTitle('New Recipe')) expect(onCreateType).toHaveBeenCalledWith('Recipe') }) it('calls onCreateNewType when + is clicked on Types section', () => { const onCreateNewType = vi.fn() render( {}} onCreateNewType={onCreateNewType} />) fireEvent.click(screen.getByTitle('New Type')) expect(onCreateNewType).toHaveBeenCalled() }) it('does not show built-in types as custom sections', () => { const projectTypeEntry: VaultEntry = { path: '/vault/type/project.md', filename: 'project.md', title: 'Project', isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', relationships: {}, icon: null, color: null, order: null, } render( {}} />) // "Projects" should appear once (the built-in section), not twice const projectLabels = screen.getAllByText('Projects') expect(projectLabels.length).toBe(1) }) }) describe('customize section visibility', () => { beforeEach(() => { localStorageMock.clear() }) it('renders a "Customize sections" button', () => { render( {}} />) expect(screen.getByTitle('Customize sections')).toBeInTheDocument() }) it('opens popover with toggle for each section when clicking customize button', () => { render( {}} />) fireEvent.click(screen.getByTitle('Customize sections')) expect(screen.getByText('Show in sidebar')).toBeInTheDocument() expect(screen.getByLabelText('Toggle Projects')).toBeInTheDocument() expect(screen.getByLabelText('Toggle People')).toBeInTheDocument() expect(screen.getByLabelText('Toggle Topics')).toBeInTheDocument() }) it('hides a section when its toggle is clicked off', () => { render( {}} />) // People section header should be visible initially expect(screen.getByText('People')).toBeInTheDocument() // Open customize popover and toggle People off fireEvent.click(screen.getByTitle('Customize sections')) fireEvent.click(screen.getByLabelText('Toggle People')) // People section header should be gone (use getAllByText to handle popover) const peopleElements = screen.queryAllByText('People') // Only the toggle label in the popover should remain, not the section header expect(peopleElements.length).toBeLessThanOrEqual(1) }) it('re-shows a section when its toggle is clicked on again', () => { render( {}} />) // Hide People fireEvent.click(screen.getByTitle('Customize sections')) fireEvent.click(screen.getByLabelText('Toggle People')) // Show People again fireEvent.click(screen.getByLabelText('Toggle People')) // People section should be visible again — popover toggle + section header = 2 "People" texts const peopleElements = screen.getAllByText('People') expect(peopleElements.length).toBe(2) }) it('persists hidden sections in localStorage', () => { const { unmount } = render( {}} />) fireEvent.click(screen.getByTitle('Customize sections')) fireEvent.click(screen.getByLabelText('Toggle Events')) unmount() // Verify localStorage was updated const stored = JSON.parse(localStorage.getItem('laputa-hidden-sections') || '[]') expect(stored).toContain('Event') }) it('restores hidden sections from localStorage on mount', () => { localStorage.setItem('laputa-hidden-sections', JSON.stringify(['Person', 'Event'])) render( {}} />) // People and Events section headers should be hidden expect(screen.queryByText('People')).not.toBeInTheDocument() expect(screen.queryByText('Events')).not.toBeInTheDocument() // Other section headers should still be visible expect(screen.getByText('Projects')).toBeInTheDocument() expect(screen.getByText('Topics')).toBeInTheDocument() }) it('does not affect All Notes or other sidebar filters when sections are hidden', () => { localStorage.setItem('laputa-hidden-sections', JSON.stringify(['Project', 'Person'])) render( {}} />) // Top nav items still present expect(screen.getByText('All Notes')).toBeInTheDocument() expect(screen.getByText('Favorites')).toBeInTheDocument() }) it('closes popover when clicking outside', () => { render(
outside
{}} />
) fireEvent.click(screen.getByTitle('Customize sections')) expect(screen.getByText('Show in sidebar')).toBeInTheDocument() fireEvent.mouseDown(screen.getByTestId('outside')) expect(screen.queryByText('Show in sidebar')).not.toBeInTheDocument() }) }) describe('section ordering by type order property', () => { const entriesWithOrder: VaultEntry[] = [ ...mockEntries, // Type entries with order values — reversed from default { path: '/vault/type/project.md', filename: 'project.md', title: 'Project', isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', relationships: {}, icon: null, color: null, order: 5, }, { path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', relationships: {}, icon: null, color: null, order: 0, }, { path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '', relationships: {}, icon: null, color: null, order: 1, }, ] it('sorts sections by order from Type entries', () => { render( {}} />) // Get all section header labels const headers = screen.getAllByText(/^(Topics|People|Projects|Experiments|Responsibilities|Procedures|Events|Types)$/) const labels = headers.map((el) => el.textContent) // Topics (order: 0) and People (order: 1) should come before Projects (order: 5) const topicsIdx = labels.indexOf('Topics') const peopleIdx = labels.indexOf('People') const projectsIdx = labels.indexOf('Projects') expect(topicsIdx).toBeLessThan(projectsIdx) expect(peopleIdx).toBeLessThan(projectsIdx) expect(topicsIdx).toBeLessThan(peopleIdx) }) it('renders drag handle on section headers', () => { render( {}} />) const dragHandles = screen.getAllByLabelText(/^Drag to reorder/) // Should have one drag handle per visible section group expect(dragHandles.length).toBeGreaterThan(0) }) }) })