2026-02-14 19:46:44 +01:00
|
|
|
import { render, screen, fireEvent } from '@testing-library/react'
|
2026-02-21 17:12:45 +01:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
2026-02-23 08:53:43 +01:00
|
|
|
import { NoteList } from './NoteList'
|
|
|
|
|
import { getSortComparator, filterEntries } from '../utils/noteListHelpers'
|
2026-02-14 19:44:39 +01:00
|
|
|
import type { VaultEntry, SidebarSelection } from '../types'
|
|
|
|
|
|
|
|
|
|
const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
2026-02-14 20:07:23 +01:00
|
|
|
const noopSelect = vi.fn()
|
2026-02-23 20:12:54 +01:00
|
|
|
const noopReplace = vi.fn()
|
2026-02-14 19:44:39 +01:00
|
|
|
|
|
|
|
|
const mockEntries: VaultEntry[] = [
|
|
|
|
|
{
|
|
|
|
|
path: '/Users/luca/Laputa/project/26q1-laputa-app.md',
|
|
|
|
|
filename: '26q1-laputa-app.md',
|
|
|
|
|
title: 'Build Laputa App',
|
|
|
|
|
isA: 'Project',
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: ['[[topic/software-development]]'],
|
|
|
|
|
status: 'Active',
|
|
|
|
|
owner: 'Luca',
|
|
|
|
|
cadence: null,
|
2026-02-21 16:36:18 +01:00
|
|
|
archived: false,
|
2026-02-21 16:56:48 +01:00
|
|
|
trashed: false,
|
|
|
|
|
trashedAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
modifiedAt: 1700000000,
|
2026-02-16 16:56:44 +01:00
|
|
|
createdAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
fileSize: 1024,
|
2026-02-17 17:14:29 +01:00
|
|
|
snippet: 'Build a personal knowledge management app.',
|
2026-02-17 19:11:01 +01:00
|
|
|
relationships: {
|
|
|
|
|
'Related to': ['[[topic/software-development]]'],
|
|
|
|
|
},
|
2026-02-21 13:23:10 +01:00
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
2026-02-21 17:27:19 +01:00
|
|
|
order: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
|
|
|
|
|
filename: 'facebook-ads-strategy.md',
|
|
|
|
|
title: 'Facebook Ads Strategy',
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: ['[[project/26q1-laputa-app]]'],
|
|
|
|
|
relatedTo: ['[[topic/growth]]'],
|
|
|
|
|
status: null,
|
|
|
|
|
owner: null,
|
|
|
|
|
cadence: null,
|
2026-02-21 16:36:18 +01:00
|
|
|
archived: false,
|
2026-02-21 16:56:48 +01:00
|
|
|
trashed: false,
|
|
|
|
|
trashedAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
modifiedAt: 1700000000,
|
2026-02-16 16:56:44 +01:00
|
|
|
createdAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
fileSize: 847,
|
2026-02-17 17:14:29 +01:00
|
|
|
snippet: 'Lookalike audiences convert 3x better.',
|
2026-02-17 19:11:01 +01:00
|
|
|
relationships: {
|
|
|
|
|
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
|
|
|
|
'Related to': ['[[topic/growth]]'],
|
|
|
|
|
},
|
2026-02-21 13:23:10 +01:00
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
2026-02-21 17:27:19 +01:00
|
|
|
order: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
path: '/Users/luca/Laputa/person/matteo-cellini.md',
|
|
|
|
|
filename: 'matteo-cellini.md',
|
|
|
|
|
title: 'Matteo Cellini',
|
|
|
|
|
isA: 'Person',
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: [],
|
|
|
|
|
status: null,
|
|
|
|
|
owner: null,
|
|
|
|
|
cadence: null,
|
2026-02-21 16:36:18 +01:00
|
|
|
archived: false,
|
2026-02-21 16:56:48 +01:00
|
|
|
trashed: false,
|
|
|
|
|
trashedAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
modifiedAt: 1700000000,
|
2026-02-16 16:56:44 +01:00
|
|
|
createdAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
fileSize: 320,
|
2026-02-17 17:14:29 +01:00
|
|
|
snippet: 'Sponsorship manager.',
|
2026-02-17 19:11:01 +01:00
|
|
|
relationships: {},
|
2026-02-21 13:23:10 +01:00
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
2026-02-21 17:27:19 +01:00
|
|
|
order: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md',
|
|
|
|
|
filename: '2026-02-14-kickoff.md',
|
|
|
|
|
title: 'Kickoff Meeting',
|
|
|
|
|
isA: 'Event',
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: [],
|
|
|
|
|
status: null,
|
|
|
|
|
owner: null,
|
|
|
|
|
cadence: null,
|
2026-02-21 16:36:18 +01:00
|
|
|
archived: false,
|
2026-02-21 16:56:48 +01:00
|
|
|
trashed: false,
|
|
|
|
|
trashedAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
modifiedAt: 1700000000,
|
2026-02-16 16:56:44 +01:00
|
|
|
createdAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
fileSize: 512,
|
2026-02-17 17:14:29 +01:00
|
|
|
snippet: 'Project kickoff meeting notes.',
|
2026-02-17 19:11:01 +01:00
|
|
|
relationships: {},
|
2026-02-21 13:23:10 +01:00
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
2026-02-21 17:27:19 +01:00
|
|
|
order: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
path: '/Users/luca/Laputa/topic/software-development.md',
|
|
|
|
|
filename: 'software-development.md',
|
|
|
|
|
title: 'Software Development',
|
|
|
|
|
isA: 'Topic',
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: [],
|
|
|
|
|
status: null,
|
|
|
|
|
owner: null,
|
|
|
|
|
cadence: null,
|
2026-02-21 16:36:18 +01:00
|
|
|
archived: false,
|
2026-02-21 16:56:48 +01:00
|
|
|
trashed: false,
|
|
|
|
|
trashedAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
modifiedAt: 1700000000,
|
2026-02-16 16:56:44 +01:00
|
|
|
createdAt: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
fileSize: 256,
|
2026-02-17 17:14:29 +01:00
|
|
|
snippet: 'Frontend, backend, and systems programming.',
|
2026-02-17 19:11:01 +01:00
|
|
|
relationships: {},
|
2026-02-21 13:23:10 +01:00
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
2026-02-21 17:27:19 +01:00
|
|
|
order: null,
|
2026-02-14 19:44:39 +01:00
|
|
|
},
|
|
|
|
|
]
|
2026-02-14 18:25:11 +01:00
|
|
|
|
|
|
|
|
describe('NoteList', () => {
|
|
|
|
|
it('shows empty state when no entries', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('No notes found')).toBeInTheDocument()
|
2026-02-14 18:25:11 +01:00
|
|
|
})
|
|
|
|
|
|
2026-02-14 19:44:39 +01:00
|
|
|
it('renders all entries with All Notes filter', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
it('filters by People (section group)', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
it('filters by Events (section group)', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('filters by section group type', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-15 10:30:13 +01:00
|
|
|
it('shows entity pinned at top with grouped children', () => {
|
2026-02-20 22:02:04 +01:00
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-14 19:44:39 +01:00
|
|
|
)
|
2026-02-15 10:30:13 +01:00
|
|
|
// Entity title appears in header and pinned card
|
|
|
|
|
expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1)
|
|
|
|
|
// Child entry in "Children" group
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
2026-02-15 10:30:13 +01:00
|
|
|
// Unrelated entries not shown
|
2026-02-14 19:44:39 +01:00
|
|
|
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
2026-02-15 10:30:13 +01:00
|
|
|
// Group headers shown
|
|
|
|
|
expect(screen.getByText('Children')).toBeInTheDocument()
|
2026-02-17 19:11:01 +01:00
|
|
|
expect(screen.getByText('Related to')).toBeInTheDocument()
|
2026-02-14 18:25:11 +01:00
|
|
|
})
|
2026-02-14 19:44:39 +01:00
|
|
|
|
|
|
|
|
it('filters by topic (relatedTo references)', () => {
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-14 19:44:39 +01:00
|
|
|
)
|
|
|
|
|
// Build Laputa App has relatedTo: [[topic/software-development]]
|
|
|
|
|
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
|
|
|
|
})
|
2026-02-14 19:46:44 +01:00
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
it('shows search input when search icon is clicked', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-17 11:03:23 +01:00
|
|
|
// Search is hidden by default
|
|
|
|
|
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
|
|
|
|
|
// Click search icon to show it
|
|
|
|
|
fireEvent.click(screen.getByTitle('Search notes'))
|
2026-02-14 19:46:44 +01:00
|
|
|
expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('filters by search query (case-insensitive substring)', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-17 11:03:23 +01:00
|
|
|
// Open search
|
|
|
|
|
fireEvent.click(screen.getByTitle('Search notes'))
|
2026-02-14 19:46:44 +01:00
|
|
|
const input = screen.getByPlaceholderText('Search notes...')
|
|
|
|
|
fireEvent.change(input, { target: { value: 'facebook' } })
|
|
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts entries by last modified descending', () => {
|
|
|
|
|
const entriesWithDifferentDates: VaultEntry[] = [
|
|
|
|
|
{ ...mockEntries[0], modifiedAt: 1000, title: 'Oldest' },
|
|
|
|
|
{ ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' },
|
|
|
|
|
{ ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' },
|
|
|
|
|
]
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-14 20:07:23 +01:00
|
|
|
const titles = screen.getAllByText(/Oldest|Newest|Middle/)
|
2026-02-14 19:46:44 +01:00
|
|
|
const titleTexts = titles.map((el) => el.textContent)
|
|
|
|
|
expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
|
|
|
|
|
})
|
2026-02-14 19:48:59 +01:00
|
|
|
|
2026-02-17 11:03:23 +01:00
|
|
|
it('does not render type badge or status on note items', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-17 11:03:23 +01:00
|
|
|
// Type badges like "Project", "Note" etc. should not appear as separate badge elements
|
|
|
|
|
// The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge
|
|
|
|
|
expect(screen.queryByText('Active')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('header shows search and plus icons instead of count badge', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-17 11:03:23 +01:00
|
|
|
expect(screen.getByTitle('Search notes')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTitle('Create new note')).toBeInTheDocument()
|
|
|
|
|
})
|
feat: implement context view in NoteList
When a note is selected from the sidebar (entity selection), NoteList
now shows a context view instead of a flat list:
- Prominent top card with type-colored background, bold title, snippet,
timestamp, and type icon
- Grouped relationship sections (Children, Events, Referenced By,
Belongs To, Related To, Backlinks) with collapse/expand chevrons
- Backlinks detected by scanning allContent for wikilink references
- Groups show ALL-CAPS headers with count and CaretDown/CaretRight toggle
- Normal flat list shown when sidebar selection is "All Notes"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:47:38 +01:00
|
|
|
|
|
|
|
|
it('context view shows backlinks from allContent', () => {
|
|
|
|
|
const allContent = {
|
|
|
|
|
[mockEntries[2].path]: 'Met with [[project/26q1-laputa-app]] team.',
|
|
|
|
|
}
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={allContent} onCreateNote={vi.fn()} />
|
feat: implement context view in NoteList
When a note is selected from the sidebar (entity selection), NoteList
now shows a context view instead of a flat list:
- Prominent top card with type-colored background, bold title, snippet,
timestamp, and type icon
- Grouped relationship sections (Children, Events, Referenced By,
Belongs To, Related To, Backlinks) with collapse/expand chevrons
- Backlinks detected by scanning allContent for wikilink references
- Groups show ALL-CAPS headers with count and CaretDown/CaretRight toggle
- Normal flat list shown when sidebar selection is "All Notes"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:47:38 +01:00
|
|
|
)
|
|
|
|
|
expect(screen.getByText('Backlinks')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('context view collapses and expands groups', () => {
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
feat: implement context view in NoteList
When a note is selected from the sidebar (entity selection), NoteList
now shows a context view instead of a flat list:
- Prominent top card with type-colored background, bold title, snippet,
timestamp, and type icon
- Grouped relationship sections (Children, Events, Referenced By,
Belongs To, Related To, Backlinks) with collapse/expand chevrons
- Backlinks detected by scanning allContent for wikilink references
- Groups show ALL-CAPS headers with count and CaretDown/CaretRight toggle
- Normal flat list shown when sidebar selection is "All Notes"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:47:38 +01:00
|
|
|
)
|
|
|
|
|
// Children group is expanded by default
|
|
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
|
|
|
|
// Click the Children header to collapse
|
|
|
|
|
fireEvent.click(screen.getByText('Children'))
|
|
|
|
|
// Items should be hidden
|
|
|
|
|
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
|
|
|
|
// Click again to expand
|
|
|
|
|
fireEvent.click(screen.getByText('Children'))
|
|
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('context view shows prominent card with entity snippet', () => {
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
feat: implement context view in NoteList
When a note is selected from the sidebar (entity selection), NoteList
now shows a context view instead of a flat list:
- Prominent top card with type-colored background, bold title, snippet,
timestamp, and type icon
- Grouped relationship sections (Children, Events, Referenced By,
Belongs To, Related To, Backlinks) with collapse/expand chevrons
- Backlinks detected by scanning allContent for wikilink references
- Groups show ALL-CAPS headers with count and CaretDown/CaretRight toggle
- Normal flat list shown when sidebar selection is "All Notes"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:47:38 +01:00
|
|
|
)
|
|
|
|
|
// Snippet appears in the prominent card
|
|
|
|
|
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
|
|
|
|
|
})
|
2026-02-14 18:25:11 +01:00
|
|
|
})
|
2026-02-21 17:12:45 +01:00
|
|
|
|
2026-02-23 20:12:54 +01:00
|
|
|
describe('NoteList click behavior', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
noopSelect.mockClear()
|
|
|
|
|
noopReplace.mockClear()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('regular click calls onReplaceActiveTab (opens in current tab)', () => {
|
|
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
|
|
|
|
fireEvent.click(screen.getByText('Build Laputa App'))
|
|
|
|
|
expect(noopReplace).toHaveBeenCalledWith(mockEntries[0])
|
|
|
|
|
expect(noopSelect).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('Cmd+Click calls onSelectNote (opens in new tab)', () => {
|
|
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
|
|
|
|
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
|
|
|
|
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
|
|
|
|
expect(noopReplace).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('Ctrl+Click calls onSelectNote (opens in new tab, Windows/Linux)', () => {
|
|
|
|
|
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
|
|
|
|
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
|
|
|
|
|
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
|
|
|
|
expect(noopReplace).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('Cmd+Click on entity pinned card calls onSelectNote', () => {
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getByText('Build a personal knowledge management app.'), { metaKey: true })
|
|
|
|
|
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
|
|
|
|
expect(noopReplace).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('regular click on entity pinned card calls onReplaceActiveTab', () => {
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getByText('Build a personal knowledge management app.'))
|
|
|
|
|
expect(noopReplace).toHaveBeenCalledWith(mockEntries[0])
|
|
|
|
|
expect(noopSelect).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('click on child note in entity view calls onReplaceActiveTab', () => {
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
|
|
|
|
|
expect(noopReplace).toHaveBeenCalledWith(mockEntries[1])
|
|
|
|
|
expect(noopSelect).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-21 17:12:45 +01:00
|
|
|
describe('getSortComparator', () => {
|
|
|
|
|
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
|
|
|
|
|
path: '/test.md',
|
|
|
|
|
filename: 'test.md',
|
|
|
|
|
title: 'Test',
|
|
|
|
|
isA: null,
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: [],
|
|
|
|
|
status: null,
|
|
|
|
|
owner: null,
|
|
|
|
|
cadence: null,
|
|
|
|
|
archived: false,
|
2026-02-22 11:00:07 +01:00
|
|
|
trashed: false,
|
|
|
|
|
trashedAt: null,
|
2026-02-21 17:12:45 +01:00
|
|
|
modifiedAt: null,
|
|
|
|
|
createdAt: null,
|
|
|
|
|
fileSize: 100,
|
|
|
|
|
snippet: '',
|
|
|
|
|
relationships: {},
|
|
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
2026-02-21 17:27:19 +01:00
|
|
|
order: null,
|
2026-02-21 17:12:45 +01:00
|
|
|
...overrides,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts by modified date descending', () => {
|
|
|
|
|
const a = makeEntry({ title: 'A', modifiedAt: 1000 })
|
|
|
|
|
const b = makeEntry({ title: 'B', modifiedAt: 3000 })
|
|
|
|
|
const c = makeEntry({ title: 'C', modifiedAt: 2000 })
|
|
|
|
|
const sorted = [a, b, c].sort(getSortComparator('modified'))
|
|
|
|
|
expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts by created date descending', () => {
|
|
|
|
|
const a = makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 })
|
|
|
|
|
const b = makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 })
|
|
|
|
|
const c = makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 })
|
|
|
|
|
const sorted = [a, b, c].sort(getSortComparator('created'))
|
|
|
|
|
expect(sorted.map((e) => e.title)).toEqual(['A', 'C', 'B'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts by created date, falling back to modifiedAt when createdAt is null', () => {
|
|
|
|
|
const a = makeEntry({ title: 'A', createdAt: null, modifiedAt: 5000 })
|
|
|
|
|
const b = makeEntry({ title: 'B', createdAt: 2000, modifiedAt: 1000 })
|
|
|
|
|
const sorted = [a, b].sort(getSortComparator('created'))
|
|
|
|
|
expect(sorted.map((e) => e.title)).toEqual(['A', 'B'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts by title alphabetically', () => {
|
|
|
|
|
const a = makeEntry({ title: 'Zebra' })
|
|
|
|
|
const b = makeEntry({ title: 'Alpha' })
|
|
|
|
|
const c = makeEntry({ title: 'Middle' })
|
|
|
|
|
const sorted = [a, b, c].sort(getSortComparator('title'))
|
|
|
|
|
expect(sorted.map((e) => e.title)).toEqual(['Alpha', 'Middle', 'Zebra'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts by status (Active > Paused > Done > null)', () => {
|
|
|
|
|
const a = makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 })
|
|
|
|
|
const b = makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 })
|
|
|
|
|
const c = makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 })
|
|
|
|
|
const d = makeEntry({ title: 'Paused', status: 'Paused', modifiedAt: 1000 })
|
|
|
|
|
const sorted = [a, b, c, d].sort(getSortComparator('status'))
|
|
|
|
|
expect(sorted.map((e) => e.title)).toEqual(['Active', 'Paused', 'Done', 'NoStatus'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts by status with modified date as tiebreaker', () => {
|
|
|
|
|
const a = makeEntry({ title: 'OlderActive', status: 'Active', modifiedAt: 1000 })
|
|
|
|
|
const b = makeEntry({ title: 'NewerActive', status: 'Active', modifiedAt: 3000 })
|
|
|
|
|
const sorted = [a, b].sort(getSortComparator('status'))
|
|
|
|
|
expect(sorted.map((e) => e.title)).toEqual(['NewerActive', 'OlderActive'])
|
|
|
|
|
})
|
2026-02-22 12:10:29 +01:00
|
|
|
|
|
|
|
|
it('sorts by modified date ascending when direction is asc', () => {
|
|
|
|
|
const a = makeEntry({ title: 'A', modifiedAt: 1000 })
|
|
|
|
|
const b = makeEntry({ title: 'B', modifiedAt: 3000 })
|
|
|
|
|
const c = makeEntry({ title: 'C', modifiedAt: 2000 })
|
|
|
|
|
const sorted = [a, b, c].sort(getSortComparator('modified', 'asc'))
|
|
|
|
|
expect(sorted.map((e) => e.title)).toEqual(['A', 'C', 'B'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts by title descending when direction is desc', () => {
|
|
|
|
|
const a = makeEntry({ title: 'Zebra' })
|
|
|
|
|
const b = makeEntry({ title: 'Alpha' })
|
|
|
|
|
const c = makeEntry({ title: 'Middle' })
|
|
|
|
|
const sorted = [a, b, c].sort(getSortComparator('title', 'desc'))
|
|
|
|
|
expect(sorted.map((e) => e.title)).toEqual(['Zebra', 'Middle', 'Alpha'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts by created date ascending when direction is asc', () => {
|
|
|
|
|
const a = makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 })
|
|
|
|
|
const b = makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 })
|
|
|
|
|
const c = makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 })
|
|
|
|
|
const sorted = [a, b, c].sort(getSortComparator('created', 'asc'))
|
|
|
|
|
expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts by status descending (null first, Done before Active)', () => {
|
|
|
|
|
const a = makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 })
|
|
|
|
|
const b = makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 })
|
|
|
|
|
const c = makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 })
|
|
|
|
|
const sorted = [a, b, c].sort(getSortComparator('status', 'desc'))
|
|
|
|
|
expect(sorted.map((e) => e.title)).toEqual(['NoStatus', 'Done', 'Active'])
|
|
|
|
|
})
|
2026-02-21 17:12:45 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('NoteList sort controls', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
try { localStorage.removeItem('laputa-sort-preferences') } catch { /* noop */ }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
|
|
|
|
|
path: '/test.md',
|
|
|
|
|
filename: 'test.md',
|
|
|
|
|
title: 'Test',
|
|
|
|
|
isA: null,
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: [],
|
|
|
|
|
status: null,
|
|
|
|
|
owner: null,
|
|
|
|
|
cadence: null,
|
|
|
|
|
archived: false,
|
2026-02-22 11:00:07 +01:00
|
|
|
trashed: false,
|
|
|
|
|
trashedAt: null,
|
2026-02-21 17:12:45 +01:00
|
|
|
modifiedAt: null,
|
|
|
|
|
createdAt: null,
|
|
|
|
|
fileSize: 100,
|
|
|
|
|
snippet: '',
|
|
|
|
|
relationships: {},
|
|
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
2026-02-21 17:27:19 +01:00
|
|
|
order: null,
|
2026-02-21 17:12:45 +01:00
|
|
|
...overrides,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows sort button in note list header for flat view', () => {
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-21 17:12:45 +01:00
|
|
|
)
|
|
|
|
|
expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows sort dropdown per relationship subsection in entity view', () => {
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-21 17:12:45 +01:00
|
|
|
)
|
|
|
|
|
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('opens sort menu on click and shows all options', () => {
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-21 17:12:45 +01:00
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
|
|
|
|
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTestId('sort-option-modified')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTestId('sort-option-created')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTestId('sort-option-title')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTestId('sort-option-status')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('changes sort order when an option is selected', () => {
|
|
|
|
|
const entries = [
|
|
|
|
|
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
|
|
|
|
|
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
|
|
|
|
|
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
|
|
|
|
]
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-21 17:12:45 +01:00
|
|
|
)
|
|
|
|
|
// Default sort: by modified (Zebra first)
|
|
|
|
|
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
|
|
|
|
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
|
|
|
|
|
|
|
|
|
|
// Switch to title sort
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-option-title'))
|
|
|
|
|
|
|
|
|
|
// Now should be alphabetical
|
|
|
|
|
titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
|
|
|
|
expect(titles).toEqual(['Alpha', 'Middle', 'Zebra'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('closes sort menu after selecting an option', () => {
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-21 17:12:45 +01:00
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
|
|
|
|
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-option-title'))
|
|
|
|
|
expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-22 12:10:29 +01:00
|
|
|
it('shows direction arrows in sort dropdown menu', () => {
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-22 12:10:29 +01:00
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
|
|
|
|
// Each option should have asc and desc direction buttons
|
|
|
|
|
expect(screen.getByTestId('sort-dir-asc-modified')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTestId('sort-dir-desc-modified')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTestId('sort-dir-asc-title')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTestId('sort-dir-desc-title')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reverses sort order when clicking direction arrow', () => {
|
|
|
|
|
const entries = [
|
|
|
|
|
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
|
|
|
|
|
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
|
|
|
|
|
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
|
|
|
|
]
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-22 12:10:29 +01:00
|
|
|
)
|
|
|
|
|
// Default sort: modified descending (Zebra first at 3000)
|
|
|
|
|
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
|
|
|
|
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
|
|
|
|
|
|
|
|
|
|
// Click the asc arrow for modified to reverse
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-dir-asc-modified'))
|
|
|
|
|
|
|
|
|
|
// Now ascending: Alpha (1000) first
|
|
|
|
|
titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
|
|
|
|
expect(titles).toEqual(['Alpha', 'Middle', 'Zebra'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('persists sort direction via saveSortPreferences', () => {
|
|
|
|
|
const entries = [
|
|
|
|
|
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
|
|
|
|
|
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
|
|
|
|
|
]
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-22 12:10:29 +01:00
|
|
|
)
|
|
|
|
|
// Select title sort with desc direction
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-dir-desc-title'))
|
|
|
|
|
|
|
|
|
|
// Verify direction took effect: desc title means Z before A
|
|
|
|
|
const titles = screen.getAllByText(/Zebra|Alpha/).map((el) => el.textContent)
|
|
|
|
|
expect(titles).toEqual(['Zebra', 'Alpha'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows direction icon on the sort button that reflects current direction', () => {
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-22 12:10:29 +01:00
|
|
|
)
|
|
|
|
|
// Default: modified desc → should have ArrowDown icon
|
|
|
|
|
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
// Switch to title (default asc) and verify icon changes
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-option-title'))
|
|
|
|
|
// Title default is asc → should show ArrowUp icon
|
|
|
|
|
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-21 17:12:45 +01:00
|
|
|
it('sorts relationship subsection entries when sort option changed', () => {
|
|
|
|
|
// Create an entity with children that have different titles
|
|
|
|
|
const parent = makeEntry({
|
|
|
|
|
path: '/parent.md',
|
|
|
|
|
filename: 'parent.md',
|
|
|
|
|
title: 'Parent',
|
|
|
|
|
isA: 'Project',
|
|
|
|
|
})
|
|
|
|
|
const child1 = makeEntry({
|
|
|
|
|
path: '/child1.md',
|
|
|
|
|
filename: 'child1.md',
|
|
|
|
|
title: 'Zebra Note',
|
|
|
|
|
belongsTo: ['[[parent]]'],
|
|
|
|
|
modifiedAt: 3000,
|
|
|
|
|
})
|
|
|
|
|
const child2 = makeEntry({
|
|
|
|
|
path: '/child2.md',
|
|
|
|
|
filename: 'child2.md',
|
|
|
|
|
title: 'Alpha Note',
|
|
|
|
|
belongsTo: ['[[parent]]'],
|
|
|
|
|
modifiedAt: 1000,
|
|
|
|
|
})
|
|
|
|
|
const entries = [parent, child1, child2]
|
|
|
|
|
|
|
|
|
|
render(
|
2026-02-23 20:12:54 +01:00
|
|
|
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
2026-02-21 17:12:45 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Default sort: by modified — Zebra Note (3000) before Alpha Note (1000)
|
|
|
|
|
let titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((el) => el.textContent)
|
|
|
|
|
expect(titles).toEqual(['Zebra Note', 'Alpha Note'])
|
|
|
|
|
|
|
|
|
|
// Switch to title sort
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-button-Children'))
|
|
|
|
|
fireEvent.click(screen.getByTestId('sort-option-title'))
|
|
|
|
|
|
|
|
|
|
// Now alphabetical: Alpha before Zebra
|
|
|
|
|
titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((el) => el.textContent)
|
|
|
|
|
expect(titles).toEqual(['Alpha Note', 'Zebra Note'])
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-02-21 18:34:40 +01:00
|
|
|
|
2026-02-21 16:56:48 +01:00
|
|
|
// --- Trash feature tests ---
|
|
|
|
|
|
|
|
|
|
const trashedEntry: VaultEntry = {
|
|
|
|
|
path: '/vault/note/old-draft.md',
|
|
|
|
|
filename: 'old-draft.md',
|
|
|
|
|
title: 'Old Draft Notes',
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: [],
|
|
|
|
|
status: null,
|
|
|
|
|
owner: null,
|
|
|
|
|
cadence: null,
|
|
|
|
|
archived: false,
|
|
|
|
|
trashed: true,
|
|
|
|
|
trashedAt: Date.now() / 1000 - 86400 * 5,
|
|
|
|
|
modifiedAt: 1700000000,
|
|
|
|
|
createdAt: null,
|
|
|
|
|
fileSize: 280,
|
|
|
|
|
snippet: 'Some draft content that is no longer needed.',
|
|
|
|
|
relationships: {},
|
|
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
2026-02-22 11:00:07 +01:00
|
|
|
order: null,
|
2026-02-21 16:56:48 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const expiredTrashedEntry: VaultEntry = {
|
|
|
|
|
path: '/vault/note/deprecated-api.md',
|
|
|
|
|
filename: 'deprecated-api.md',
|
|
|
|
|
title: 'Deprecated API Notes',
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: [],
|
|
|
|
|
status: null,
|
|
|
|
|
owner: null,
|
|
|
|
|
cadence: null,
|
|
|
|
|
archived: false,
|
|
|
|
|
trashed: true,
|
|
|
|
|
trashedAt: Date.now() / 1000 - 86400 * 35,
|
|
|
|
|
modifiedAt: 1700000000,
|
|
|
|
|
createdAt: null,
|
|
|
|
|
fileSize: 190,
|
|
|
|
|
snippet: 'Old API docs replaced by v2.',
|
|
|
|
|
relationships: {},
|
|
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
2026-02-22 11:00:07 +01:00
|
|
|
order: null,
|
2026-02-21 16:56:48 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const entriesWithTrashed = [...mockEntries, trashedEntry, expiredTrashedEntry]
|
|
|
|
|
|
|
|
|
|
describe('filterEntries — trash', () => {
|
|
|
|
|
it('excludes trashed entries from "all" filter', () => {
|
|
|
|
|
const result = filterEntries(entriesWithTrashed, { kind: 'filter', filter: 'all' })
|
|
|
|
|
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
|
|
|
|
|
expect(result.find((e) => e.title === 'Build Laputa App')).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('excludes trashed entries from section group', () => {
|
|
|
|
|
const result = filterEntries(entriesWithTrashed, { kind: 'sectionGroup', type: 'Note' })
|
|
|
|
|
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('trash filter returns only trashed entries', () => {
|
|
|
|
|
const result = filterEntries(entriesWithTrashed, { kind: 'filter', filter: 'trash' })
|
|
|
|
|
expect(result).toHaveLength(2)
|
|
|
|
|
expect(result.every((e) => e.trashed)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('archived filter excludes trashed entries', () => {
|
|
|
|
|
const archivedAndTrashed = [
|
|
|
|
|
...mockEntries,
|
|
|
|
|
{ ...mockEntries[0], path: '/archived.md', archived: true, trashed: false, trashedAt: null, title: 'Archived Note' },
|
|
|
|
|
trashedEntry,
|
|
|
|
|
]
|
|
|
|
|
const result = filterEntries(archivedAndTrashed, { kind: 'filter', filter: 'archived' })
|
|
|
|
|
expect(result.find((e) => e.title === 'Archived Note')).toBeDefined()
|
|
|
|
|
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('topic filter excludes trashed entries', () => {
|
|
|
|
|
const topicEntry: VaultEntry = { ...mockEntries[4] } // Software Development topic
|
|
|
|
|
const trashedWithTopic: VaultEntry = {
|
|
|
|
|
...trashedEntry,
|
|
|
|
|
relatedTo: ['[[topic/software-development]]'],
|
|
|
|
|
}
|
|
|
|
|
const all = [...mockEntries, trashedWithTopic]
|
|
|
|
|
const result = filterEntries(all, { kind: 'topic', entry: topicEntry })
|
|
|
|
|
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
|
|
|
|
|
// Normal entry with that topic should still appear
|
|
|
|
|
expect(result.find((e) => e.title === 'Build Laputa App')).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-23 21:15:13 +01:00
|
|
|
describe('NoteList — modified indicators', () => {
|
|
|
|
|
it('shows modified indicator dot for entries in modifiedFiles', () => {
|
|
|
|
|
const modifiedFiles = [
|
|
|
|
|
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
|
|
|
|
]
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={modifiedFiles} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
const indicators = screen.getAllByTestId('modified-indicator')
|
|
|
|
|
expect(indicators).toHaveLength(1)
|
|
|
|
|
// The indicator's parent div contains the title text
|
|
|
|
|
const noteRow = indicators[0].closest('[data-testid="modified-indicator"]')!.parentElement!.parentElement!
|
|
|
|
|
expect(noteRow.textContent).toContain('Build Laputa App')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not show modified indicator when modifiedFiles is empty', () => {
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={[]} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows multiple modified indicators for multiple modified files', () => {
|
|
|
|
|
const modifiedFiles = [
|
|
|
|
|
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
|
|
|
|
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
|
|
|
|
|
]
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={modifiedFiles} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not show modified indicator when modifiedFiles prop is undefined', () => {
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-21 16:56:48 +01:00
|
|
|
describe('NoteList — trash view', () => {
|
|
|
|
|
const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' }
|
|
|
|
|
|
|
|
|
|
it('shows "Trash" header when trash filter is active', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-21 16:56:48 +01:00
|
|
|
expect(screen.getByText('Trash')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows only trashed entries in trash view', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-21 16:56:48 +01:00
|
|
|
expect(screen.getByText('Old Draft Notes')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Deprecated API Notes')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows TRASHED badge on trashed entries', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-21 16:56:48 +01:00
|
|
|
const badges = screen.getAllByText('TRASHED')
|
|
|
|
|
expect(badges.length).toBeGreaterThanOrEqual(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows 30-day warning banner when expired notes exist', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-21 16:56:48 +01:00
|
|
|
expect(screen.getByText('Notes in trash for 30+ days will be permanently deleted')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText(/1 note is past the 30-day retention period/)).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows "Trash is empty" when no trashed entries', () => {
|
2026-02-23 20:12:54 +01:00
|
|
|
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
2026-02-21 16:56:48 +01:00
|
|
|
expect(screen.getByText('Trash is empty')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-02-23 21:13:58 +01:00
|
|
|
|
|
|
|
|
// --- Virtual list performance tests ---
|
|
|
|
|
|
|
|
|
|
describe('NoteList — virtual list with large datasets', () => {
|
|
|
|
|
const makeEntry = (i: number, overrides?: Partial<VaultEntry>): VaultEntry => ({
|
|
|
|
|
path: `/vault/note/note-${i}.md`,
|
|
|
|
|
filename: `note-${i}.md`,
|
|
|
|
|
title: `Note ${i}`,
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: [],
|
|
|
|
|
status: null,
|
|
|
|
|
owner: null,
|
|
|
|
|
cadence: null,
|
|
|
|
|
archived: false,
|
|
|
|
|
trashed: false,
|
|
|
|
|
trashedAt: null,
|
|
|
|
|
modifiedAt: 1700000000 - i * 60,
|
|
|
|
|
createdAt: null,
|
|
|
|
|
fileSize: 500,
|
|
|
|
|
snippet: `Content of note ${i}`,
|
|
|
|
|
relationships: {},
|
|
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
|
|
|
|
order: null,
|
|
|
|
|
...overrides,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-23 21:35:56 +01:00
|
|
|
it('renders 9000 entries without crashing', { timeout: 15000 }, () => {
|
2026-02-23 21:13:58 +01:00
|
|
|
const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i))
|
|
|
|
|
const { container } = render(
|
|
|
|
|
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
// Virtuoso mock renders all items; the real component only renders visible ones
|
|
|
|
|
expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders items from a large dataset via Virtuoso', () => {
|
|
|
|
|
const largeDataset = Array.from({ length: 500 }, (_, i) => makeEntry(i))
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByText('Note 0')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Note 499')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('search filters large dataset correctly', () => {
|
|
|
|
|
const entries = [
|
|
|
|
|
makeEntry(0, { title: 'Alpha Strategy' }),
|
|
|
|
|
...Array.from({ length: 998 }, (_, i) => makeEntry(i + 1, { title: `Filler Note ${i + 1}` })),
|
|
|
|
|
makeEntry(999, { title: 'Beta Strategy' }),
|
|
|
|
|
]
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getByTitle('Search notes'))
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
|
|
|
|
|
expect(screen.getByText('Alpha Strategy')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Beta Strategy')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorting works with large dataset', () => {
|
|
|
|
|
const entries = [
|
|
|
|
|
makeEntry(0, { title: 'Zebra', modifiedAt: 1000 }),
|
|
|
|
|
makeEntry(1, { title: 'Alpha', modifiedAt: 3000 }),
|
|
|
|
|
...Array.from({ length: 100 }, (_, i) => makeEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })),
|
|
|
|
|
]
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
// Default sort is modified desc — Alpha (3000) should come first
|
|
|
|
|
const firstTitle = screen.getAllByText(/^Alpha$|^Zebra$/)[0]
|
|
|
|
|
expect(firstTitle.textContent).toBe('Alpha')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('section group filter works with large mixed-type dataset', () => {
|
|
|
|
|
const entries = [
|
|
|
|
|
...Array.from({ length: 100 }, (_, i) => makeEntry(i, { isA: 'Project', title: `Project ${i}` })),
|
|
|
|
|
...Array.from({ length: 200 }, (_, i) => makeEntry(100 + i, { isA: 'Note', title: `Note ${i}` })),
|
|
|
|
|
]
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByText('Project 0')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Note 0')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('selection highlighting works in virtualized list', () => {
|
|
|
|
|
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
|
|
|
|
|
const selected = entries[5]
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByText('Note 5')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('click handler works on virtualized items', () => {
|
|
|
|
|
noopReplace.mockClear()
|
|
|
|
|
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getByText('Note 50'))
|
|
|
|
|
expect(noopReplace).toHaveBeenCalledWith(entries[50])
|
|
|
|
|
})
|
2026-02-24 14:46:18 +01:00
|
|
|
|
|
|
|
|
describe('changes filter', () => {
|
|
|
|
|
const changesSelection: SidebarSelection = { kind: 'filter', filter: 'changes' }
|
|
|
|
|
const modifiedFiles = [
|
|
|
|
|
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
|
|
|
|
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
it('shows only modified notes in changes view', () => {
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Kickoff Meeting')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows header title "Changes"', () => {
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByText('Changes')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows empty state when no modified files', () => {
|
|
|
|
|
render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByText('No pending changes')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('updates list when modifiedFiles changes', () => {
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
// Simulate one file being committed (removed from modifiedFiles)
|
|
|
|
|
const fewerModified = [modifiedFiles[0]]
|
|
|
|
|
rerender(
|
|
|
|
|
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-02-23 21:13:58 +01:00
|
|
|
})
|