import { render, screen, fireEvent } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { NoteItem } from './NoteItem' import { makeEntry } from '../test-utils/noteListTestUtils' vi.mock('../utils/url', async () => { const actual = await vi.importActual('../utils/url') as typeof import('../utils/url') return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) } }) const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & { openExternalUrl: ReturnType } const NOW_SECONDS = 1_744_286_400 describe('NoteItem', () => { beforeEach(() => { openExternalUrl.mockClear() }) it('renders unsupported binary files as non-clickable muted rows', () => { const binaryEntry = makeEntry({ path: '/vault/archive.zip', filename: 'archive.zip', title: 'archive.zip', fileKind: 'binary', }) const onClickNote = vi.fn() render() const item = screen.getByTestId('binary-file-item') expect(item.className).toContain('opacity-50') expect(item).toHaveAttribute('title', 'Cannot open this file type') fireEvent.click(item) expect(onClickNote).not.toHaveBeenCalled() }) it('renders image files as clickable rows with an image file indicator', () => { const imageEntry = makeEntry({ path: '/vault/photo.png', filename: 'photo.png', title: 'photo.png', fileKind: 'binary', }) const onClickNote = vi.fn() render() const item = screen.getByTestId('image-file-item') expect(item.className).not.toContain('opacity-50') expect(item).toHaveAttribute('title', 'Open image preview') fireEvent.click(item) expect(onClickNote).toHaveBeenCalledWith(imageEntry, expect.any(Object)) expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'image') }) it('renders PDF files as clickable rows with a PDF file indicator', () => { const pdfEntry = makeEntry({ path: '/vault/reports/brief.pdf', filename: 'brief.pdf', title: 'brief.pdf', fileKind: 'binary', }) const onClickNote = vi.fn() render() const item = screen.getByTestId('pdf-file-item') expect(item.className).not.toContain('opacity-50') expect(item).toHaveAttribute('title', 'Open PDF preview') fireEvent.click(item) expect(onClickNote).toHaveBeenCalledWith(pdfEntry, expect.any(Object)) expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'pdf') }) it('renders audio and video files as clickable media preview rows', () => { const audioEntry = makeEntry({ path: '/vault/attachments/interview.mp3', filename: 'interview.mp3', title: 'interview.mp3', fileKind: 'binary', }) const videoEntry = makeEntry({ path: '/vault/attachments/demo.mp4', filename: 'demo.mp4', title: 'demo.mp4', fileKind: 'binary', }) const onClickNote = vi.fn() render( <> , ) expect(screen.getByTestId('audio-file-item')).toHaveAttribute('title', 'Open audio preview') expect(screen.getByTestId('video-file-item')).toHaveAttribute('title', 'Open video preview') fireEvent.click(screen.getByTestId('audio-file-item')) fireEvent.click(screen.getByTestId('video-file-item')) expect(onClickNote).toHaveBeenCalledWith(audioEntry, expect.any(Object)) expect(onClickNote).toHaveBeenCalledWith(videoEntry, expect.any(Object)) }) it('renders text files as clickable rows', () => { const textEntry = makeEntry({ path: '/vault/config.yml', filename: 'config.yml', title: 'config.yml', fileKind: 'text', }) const onClickNote = vi.fn() render() const item = screen.getByText('config.yml').closest('div')! fireEvent.click(item) expect(onClickNote).toHaveBeenCalled() }) it('uses CSS named colors from the Type document for note type indicators', () => { const ideaType = makeEntry({ path: '/vault/type/idea.md', filename: 'idea.md', title: 'Idea', isA: 'Type', color: 'cyan', }) const ideaEntry = makeEntry({ path: '/vault/ideas/native-cyan-idea.md', filename: 'native-cyan-idea.md', title: 'Native Cyan Idea', isA: 'Idea', }) render( , ) expect(screen.getByTestId('type-icon')).toHaveStyle({ color: 'rgb(0, 255, 255)' }) }) it('shows the title with filename metadata when a change status is present', () => { const entry = { ...makeEntry({ filename: 'my-note.md', title: 'My Note Title' }), __changeAddedLines: 42, __changeDeletedLines: 7, } render() expect(screen.getByText('My Note Title')).toBeInTheDocument() expect(screen.getByText('my-note.md')).toBeInTheDocument() expect(screen.getByTestId('change-note-filename')).toHaveClass('truncate', 'text-[12px]', 'leading-[1.5]', 'text-muted-foreground') expect(screen.getByTestId('change-stat-added')).toHaveTextContent('+42') expect(screen.getByTestId('change-stat-deleted')).toHaveTextContent('-7') }) it('renders the correct symbol for modified files', () => { const entry = makeEntry({ filename: 'note.md' }) render() expect(screen.getByTestId('change-status-icon').textContent).toBe('ยท') }) it('renders the correct symbol for added files', () => { const entry = makeEntry({ filename: 'new-note.md' }) render() expect(screen.getByTestId('change-status-icon').textContent).toBe('+') }) it('shows a neutral fallback when line stats are unavailable', () => { const entry = makeEntry({ filename: 'binary-note.md', title: 'Binary Note' }) render() expect(screen.getByTestId('change-stat-fallback')).toHaveTextContent('Diff unavailable') }) it('renders the regular title when no change status is set', () => { const entry = makeEntry({ filename: 'note.md', title: 'My Note' }) render() expect(screen.getByText('My Note')).toBeInTheDocument() expect(screen.queryByText('note.md')).not.toBeInTheDocument() expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument() }) it('adds more breathing room between note sections', () => { const entry = makeEntry({ title: 'Spaced note', snippet: 'Body preview', createdAt: NOW_SECONDS - 86400 * 3, modifiedAt: NOW_SECONDS - 86400, properties: { Status: 'Active' }, }) render( , ) expect(screen.getByTestId('note-content-stack').className).toContain('space-y-2') }) it('shows created date on the right side of the date row when available', () => { vi.useFakeTimers() vi.setSystemTime(new Date(NOW_SECONDS * 1000)) const entry = makeEntry({ title: 'Dated note', createdAt: NOW_SECONDS - 86400 * 5, modifiedAt: NOW_SECONDS - 86400 * 2, }) render() const dateRow = screen.getByTestId('note-date-row') expect(dateRow.className).toContain('grid') expect(dateRow).toHaveTextContent('2d ago') expect(dateRow).toHaveTextContent('Created 5d ago') }) it('leaves the right side empty when no creation date exists', () => { vi.useFakeTimers() vi.setSystemTime(new Date(NOW_SECONDS * 1000)) const entry = makeEntry({ title: 'Modified note', createdAt: null, modifiedAt: NOW_SECONDS - 3600, }) render() expect(screen.getByTestId('note-date-row')).toHaveTextContent('1h ago') expect(screen.queryByText(/Created /)).not.toBeInTheDocument() }) it('colors relationship chips by target type and opens the related note on Cmd+click only', () => { const linkedIdea = makeEntry({ path: '/vault/ideas/build-app.md', filename: 'build-app.md', title: 'Build App', isA: 'Idea', }) const ideaType = makeEntry({ path: '/vault/type/idea.md', filename: 'idea.md', title: 'Idea', isA: 'Type', color: 'cyan', icon: 'wrench', }) const sourceEntry = makeEntry({ path: '/vault/note/source.md', filename: 'source.md', title: 'Source', isA: 'Note', relationships: { 'Belongs to': ['[[ideas/build-app]]'] }, }) const onClickNote = vi.fn() render( , ) const chip = screen.getByTestId('property-chip-belongs-to-0') expect(chip).toHaveTextContent('Build App') expect(chip.className).toContain('cursor-pointer') expect(chip).toHaveStyle({ color: 'rgb(0, 255, 255)' }) expect(chip.getAttribute('style')).toContain('background-color: color-mix(in srgb, cyan 14%, transparent)') fireEvent.click(chip) expect(onClickNote).not.toHaveBeenCalled() fireEvent.click(chip, { metaKey: true }) expect(onClickNote).toHaveBeenCalledWith(linkedIdea, expect.objectContaining({ metaKey: true })) }) it('falls back to the built-in type icon for relationship chips when the Type has no custom icon', () => { const linkedTopic = makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI', isA: 'topic', }) const topicType = makeEntry({ path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type', color: 'green', icon: null, }) const sourceEntry = makeEntry({ path: '/vault/note/source.md', filename: 'source.md', title: 'Source', isA: 'Note', relationships: { Topics: ['[[topic/ai]]'] }, }) render( , ) const chip = screen.getByTestId('property-chip-topics-0') expect(chip).toHaveTextContent('AI') expect(chip).toHaveStyle({ color: 'var(--accent-green)', backgroundColor: 'var(--accent-green-light)' }) expect(chip.querySelector('svg')).not.toBeNull() }) it('preserves exact linked note title formatting in relationship chips', () => { const linkedTopic = makeEntry({ path: '/vault/topic/ai-ml.md', filename: 'ai-ml.md', title: 'AI / ML', isA: 'Topic', }) const topicType = makeEntry({ path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type', color: 'green', }) const sourceEntry = makeEntry({ path: '/vault/note/source.md', filename: 'source.md', title: 'Source', isA: 'Note', relationships: { Topics: ['[[topic/ai-ml]]'] }, }) render( , ) expect(screen.getByTestId('property-chip-topics-0')).toHaveTextContent('AI / ML') }) it('keeps explicit wikilink aliases in relationship chips', () => { const linkedProject = makeEntry({ path: '/vault/project/my-project.md', filename: 'my-project.md', title: 'My Project', isA: 'Project', }) const sourceEntry = makeEntry({ path: '/vault/note/source.md', filename: 'source.md', title: 'Source', isA: 'Note', relationships: { 'Belongs to': ['[[project/my-project|My Cool Project]]'] }, }) render( , ) expect(screen.getByTestId('property-chip-belongs-to-0')).toHaveTextContent('My Cool Project') }) it('opens URL chips on Cmd+click only and keeps regular clicks inert', () => { const entry = makeEntry({ path: '/vault/note/source.md', filename: 'source.md', title: 'Source', properties: { URL: 'https://example.com/docs' }, }) const onClickNote = vi.fn() render( , ) const chip = screen.getByTestId('property-chip-url-0') expect(chip).toHaveTextContent('example.com') expect(chip.className).toContain('cursor-pointer') expect(chip).toHaveStyle({ color: 'var(--accent-blue)', backgroundColor: 'var(--accent-blue-light)' }) fireEvent.click(chip) expect(openExternalUrl).not.toHaveBeenCalled() expect(onClickNote).not.toHaveBeenCalled() fireEvent.click(chip, { metaKey: true }) expect(openExternalUrl).toHaveBeenCalledWith('https://example.com/docs') expect(onClickNote).not.toHaveBeenCalled() }) it('renders broken relationship chips as neutral and non-interactive', () => { const entry = makeEntry({ path: '/vault/note/source.md', filename: 'source.md', title: 'Source', relationships: { Related: ['[[missing/note]]'] }, }) const onClickNote = vi.fn() render( , ) const chip = screen.getByTestId('property-chip-related-0') expect(chip).toHaveTextContent('Note') expect(chip.className).not.toContain('cursor-pointer') fireEvent.click(chip, { metaKey: true }) expect(onClickNote).not.toHaveBeenCalled() expect(openExternalUrl).not.toHaveBeenCalled() }) })