import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPanel' import type { VaultEntry } from '../types' // Radix Select needs ResizeObserver and pointer/scroll APIs in JSDOM beforeAll(() => { global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } Element.prototype.scrollIntoView = vi.fn() Element.prototype.hasPointerCapture = () => false Element.prototype.setPointerCapture = vi.fn() Element.prototype.releasePointerCapture = vi.fn() }) 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, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '', relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], ...overrides, }) describe('containsWikilinks', () => { it('returns true for string wikilinks', () => { expect(containsWikilinks('[[My Note]]')).toBe(true) }) it('returns false for non-wikilink strings', () => { expect(containsWikilinks('plain text')).toBe(false) }) it('returns true for arrays containing wikilinks', () => { expect(containsWikilinks(['[[Note1]]', '[[Note2]]'])).toBe(true) }) it('returns false for arrays without wikilinks', () => { expect(containsWikilinks(['tag1', 'tag2'])).toBe(false) }) it('returns false for booleans', () => { expect(containsWikilinks(true)).toBe(false) }) it('returns false for null', () => { expect(containsWikilinks(null)).toBe(false) }) }) describe('DynamicPropertiesPanel', () => { const onUpdateProperty = vi.fn() const onDeleteProperty = vi.fn() const onAddProperty = vi.fn() const onNavigate = vi.fn() beforeEach(() => { vi.clearAllMocks() }) it('renders type row', () => { render( ) expect(screen.getByText('Type')).toBeInTheDocument() expect(screen.getByText('Note')).toBeInTheDocument() }) it('renders word count', () => { render( ) expect(screen.getByText('Words')).toBeInTheDocument() }) it('renders status as colored pill', () => { render( ) // Status rendered with CSS text-transform: uppercase, DOM text is still "Active" expect(screen.getByTitle('Active')).toBeInTheDocument() }) it('renders properties from frontmatter', () => { render( ) expect(screen.getByText('cadence')).toBeInTheDocument() expect(screen.getByText('Weekly')).toBeInTheDocument() expect(screen.getByText('owner')).toBeInTheDocument() expect(screen.getByText('Luca')).toBeInTheDocument() }) it('skips aliases and relationship keys', () => { render( ) // aliases and "Belongs to" should be skipped expect(screen.queryByText('aliases')).not.toBeInTheDocument() expect(screen.queryByText('Belongs to')).not.toBeInTheDocument() expect(screen.getByText('cadence')).toBeInTheDocument() }) it('skips is_a, Is A, and type keys (shown via TypeRow instead)', () => { render( ) expect(screen.queryByText('is_a')).not.toBeInTheDocument() expect(screen.queryByText('Is A')).not.toBeInTheDocument() // 'type' as a property label should not appear (the TypeRow renders 'Type' differently) const typeLabels = screen.getAllByText('Type') // Only the TypeRow label should exist, not a property row expect(typeLabels).toHaveLength(1) expect(screen.getByTitle('Active')).toBeInTheDocument() }) it('renders boolean property as toggle', () => { render( ) // Boolean should show as Yes/No toggle const toggleBtn = screen.getByText('\u2717 No') fireEvent.click(toggleBtn) expect(onUpdateProperty).toHaveBeenCalledWith('archived', true) }) it('renders array property as tag pills', () => { render( ) expect(screen.getByText('ai')).toBeInTheDocument() expect(screen.getByText('ml')).toBeInTheDocument() expect(screen.getByText('deep-learning')).toBeInTheDocument() }) it('shows Add property button', () => { render( ) expect(screen.getByText('+ Add property')).toBeInTheDocument() }) it('opens add property form when button clicked', () => { render( ) fireEvent.click(screen.getByText('+ Add property')) expect(screen.getByPlaceholderText('Property name')).toBeInTheDocument() expect(screen.getByPlaceholderText('Value')).toBeInTheDocument() }) it('adds property via the add form', () => { render( ) fireEvent.click(screen.getByText('+ Add property')) const keyInput = screen.getByPlaceholderText('Property name') const valueInput = screen.getByPlaceholderText('Value') fireEvent.change(keyInput, { target: { value: 'priority' } }) fireEvent.change(valueInput, { target: { value: 'high' } }) fireEvent.click(screen.getByText('Add')) expect(onAddProperty).toHaveBeenCalledWith('priority', 'high') }) it('handles navigating to type via click in read-only mode', () => { render( ) fireEvent.click(screen.getByText('Project')) expect(onNavigate).toHaveBeenCalledWith('type/project') }) describe('TypeSelector', () => { const typeEntries = [ makeEntry({ path: '/vault/type/project.md', title: 'Project', isA: 'Type' }), makeEntry({ path: '/vault/type/person.md', title: 'Person', isA: 'Type' }), makeEntry({ path: '/vault/type/topic.md', title: 'Topic', isA: 'Type' }), ] it('renders as dropdown when editable', () => { render( ) expect(screen.getByTestId('type-selector')).toBeInTheDocument() expect(screen.getByRole('combobox')).toBeInTheDocument() }) it('shows available types in dropdown', () => { render( ) fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' }) expect(screen.getByRole('option', { name: 'None' })).toBeInTheDocument() expect(screen.getByRole('option', { name: 'Person' })).toBeInTheDocument() expect(screen.getByRole('option', { name: 'Project' })).toBeInTheDocument() expect(screen.getByRole('option', { name: 'Topic' })).toBeInTheDocument() }) function openAndSelect(optionName: string) { fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' }) fireEvent.click(screen.getByRole('option', { name: optionName })) } it('calls onUpdateProperty when type selected', () => { render( ) openAndSelect('Project') expect(onUpdateProperty).toHaveBeenCalledWith('type', 'Project') }) it('clears type when None selected', () => { render( ) openAndSelect('None') expect(onUpdateProperty).toHaveBeenCalledWith('type', null) }) it('shows current type even when not in available types', () => { render( ) fireEvent.pointerDown(screen.getByRole('combobox'), { button: 0, pointerType: 'mouse' }) expect(screen.getByRole('option', { name: 'CustomType' })).toBeInTheDocument() }) it('shows None placeholder when entry has no type', () => { render( ) expect(screen.getByTestId('type-selector')).toBeInTheDocument() expect(screen.getByText('None')).toBeInTheDocument() }) }) it('renders modified date', () => { render( ) expect(screen.getByText('Modified')).toBeInTheDocument() }) it('handles editing status', () => { render( ) // Click status pill to start editing (rendered with CSS uppercase, DOM text is "Active") fireEvent.click(screen.getByTitle('Active')) // Should show edit input const input = screen.getByDisplayValue('Active') fireEvent.change(input, { target: { value: 'Done' } }) fireEvent.keyDown(input, { key: 'Enter' }) expect(onUpdateProperty).toHaveBeenCalledWith('Status', 'Done') }) it('deletes property when delete button clicked', () => { render( ) const deleteBtn = screen.getByTitle('Delete property') fireEvent.click(deleteBtn) expect(onDeleteProperty).toHaveBeenCalledWith('custom_field') }) it('coerces true/false strings to booleans on save', () => { render( ) // Edit the value fireEvent.click(screen.getByText('false')) const input = screen.getByDisplayValue('false') fireEvent.change(input, { target: { value: 'true' } }) fireEvent.keyDown(input, { key: 'Enter' }) expect(onUpdateProperty).toHaveBeenCalledWith('archived', true) }) it('coerces numeric strings to numbers on save', () => { render( ) fireEvent.click(screen.getByText('3')) const input = screen.getByDisplayValue('3') fireEvent.change(input, { target: { value: '5' } }) fireEvent.keyDown(input, { key: 'Enter' }) expect(onUpdateProperty).toHaveBeenCalledWith('order', 5) }) it('cancels add form on Escape', () => { render( ) fireEvent.click(screen.getByText('+ Add property')) const keyInput = screen.getByPlaceholderText('Property name') fireEvent.keyDown(keyInput, { key: 'Escape' }) // Form should be hidden, button should reappear expect(screen.getByText('+ Add property')).toBeInTheDocument() }) it('adds property on Enter in form', () => { render( ) fireEvent.click(screen.getByText('+ Add property')) const keyInput = screen.getByPlaceholderText('Property name') const valueInput = screen.getByPlaceholderText('Value') fireEvent.change(keyInput, { target: { value: 'key' } }) fireEvent.change(valueInput, { target: { value: 'val' } }) fireEvent.keyDown(valueInput, { key: 'Enter' }) expect(onAddProperty).toHaveBeenCalledWith('key', 'val') }) it('handles comma-separated values as array', () => { render( ) fireEvent.click(screen.getByText('+ Add property')) const keyInput = screen.getByPlaceholderText('Property name') const valueInput = screen.getByPlaceholderText('Value') fireEvent.change(keyInput, { target: { value: 'tags' } }) fireEvent.change(valueInput, { target: { value: 'a, b, c' } }) fireEvent.keyDown(valueInput, { key: 'Enter' }) expect(onAddProperty).toHaveBeenCalledWith('tags', ['a', 'b', 'c']) }) it('handles cancel button in add form', () => { render( ) fireEvent.click(screen.getByText('+ Add property')) fireEvent.click(screen.getByText('Cancel')) expect(screen.getByText('+ Add property')).toBeInTheDocument() }) describe('editable vs read-only distinction', () => { it('renders Info section header', () => { render( ) expect(screen.getByText('Info')).toBeInTheDocument() }) it('renders Modified and Words in read-only Info section', () => { render( ) const readOnlyRows = screen.getAllByTestId('readonly-property') const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent) expect(labels).toContain('Modified') expect(labels).toContain('Words') }) it('renders Created date in Info section', () => { render( ) const readOnlyRows = screen.getAllByTestId('readonly-property') const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent) expect(labels).toContain('Created') }) it('renders file size in Info section', () => { render( ) expect(screen.getByText('Size')).toBeInTheDocument() expect(screen.getByText('4.2 KB')).toBeInTheDocument() }) it('shows em dash for null timestamps in Info section', () => { render( ) // Two em dashes for null Modified and Created const dashes = screen.getAllByText('\u2014') expect(dashes.length).toBeGreaterThanOrEqual(2) }) it('editable properties have hover styling via data-testid', () => { render( ) const editableRows = screen.getAllByTestId('editable-property') expect(editableRows.length).toBe(2) // Editable rows have hover:bg-muted class for interactivity editableRows.forEach(row => { expect(row.className).toContain('hover:bg-muted') }) }) it('read-only rows do not have hover styling', () => { render( ) const readOnlyRows = screen.getAllByTestId('readonly-property') readOnlyRows.forEach(row => { expect(row.className).not.toContain('hover:bg-muted') }) }) it('formats file sizes correctly', () => { // Small file — bytes const { rerender } = render( ) expect(screen.getByText('500 B')).toBeInTheDocument() // KB range rerender( ) expect(screen.getByText('2.0 KB')).toBeInTheDocument() // MB range rerender( ) expect(screen.getByText('1.0 MB')).toBeInTheDocument() }) }) describe('URL property rendering', () => { it('renders URL values with link styling instead of plain EditableValue', () => { render( ) expect(screen.getByTestId('url-link')).toBeInTheDocument() expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com') }) it('renders bare domain values as URL links', () => { render( ) expect(screen.getByTestId('url-link')).toBeInTheDocument() }) it('does not render plain text as URL link', () => { render( ) expect(screen.queryByTestId('url-link')).not.toBeInTheDocument() }) it('shows edit button on URL property', () => { render( ) expect(screen.getByTestId('url-edit-btn')).toBeInTheDocument() }) it('enters edit mode when edit button clicked on URL property', () => { render( ) fireEvent.click(screen.getByTestId('url-edit-btn')) expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument() }) }) })