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-26 20:50:29 +01:00
wordCount : 0 ,
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-03-02 08:37:06 +01:00
template : null ,
2026-02-25 15:04:49 +01:00
outgoingLinks : [ ] ,
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-26 20:50:29 +01:00
wordCount : 0 ,
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-03-02 08:37:06 +01:00
template : null ,
2026-02-25 15:04:49 +01:00
outgoingLinks : [ ] ,
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-26 20:50:29 +01:00
wordCount : 0 ,
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-03-02 08:37:06 +01:00
template : null ,
2026-02-25 15:04:49 +01:00
outgoingLinks : [ ] ,
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-26 20:50:29 +01:00
wordCount : 0 ,
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-03-02 08:37:06 +01:00
template : null ,
2026-02-25 15:04:49 +01:00
outgoingLinks : [ ] ,
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-26 20:50:29 +01:00
wordCount : 0 ,
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-03-02 08:37:06 +01:00
template : null ,
2026-02-25 15:04:49 +01:00
outgoingLinks : [ ] ,
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 ( )
} )
2026-02-27 15:27:38 +01:00
it ( 'context view shows prominent card with snippet subtitle' , ( ) = > {
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
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
)
2026-02-27 15:27:38 +01:00
// Snippet text appears in the prominent card
expect ( screen . getByText ( 'Build a personal knowledge management app.' ) ) . 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
} )
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 ( )
} )
2026-02-27 22:46:26 +01:00
it ( 'Cmd+Click calls onSelectNote (opens in new tab)' , ( ) = > {
2026-02-23 20:12:54 +01:00
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 } )
2026-02-27 22:46:26 +01:00
expect ( noopSelect ) . toHaveBeenCalledWith ( mockEntries [ 0 ] )
2026-02-23 20:12:54 +01:00
expect ( noopReplace ) . not . toHaveBeenCalled ( )
} )
2026-02-27 22:46:26 +01:00
it ( 'Ctrl+Click calls onSelectNote (Windows/Linux)' , ( ) = > {
2026-02-23 20:12:54 +01:00
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 } )
2026-02-27 22:46:26 +01:00
expect ( noopSelect ) . toHaveBeenCalledWith ( mockEntries [ 0 ] )
2026-02-23 20:12:54 +01:00
expect ( noopReplace ) . not . toHaveBeenCalled ( )
} )
2026-02-27 22:46:26 +01:00
it ( 'Cmd+Click on entity pinned card calls onSelectNote' , ( ) = > {
2026-02-23 20:12:54 +01:00
render (
< NoteList entries = { mockEntries } selection = { { kind : 'entity' , entry : mockEntries [ 0 ] } } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / >
)
2026-02-26 20:50:29 +01:00
const titles = screen . getAllByText ( 'Build Laputa App' )
fireEvent . click ( titles [ titles . length - 1 ] , { metaKey : true } )
2026-02-27 22:46:26 +01:00
expect ( noopSelect ) . toHaveBeenCalledWith ( mockEntries [ 0 ] )
2026-02-23 20:12:54 +01:00
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 ( ) } / >
)
2026-02-26 20:50:29 +01:00
// Title appears in both header and pinned card — use getAllByText and click the pinned card instance
const titles = screen . getAllByText ( 'Build Laputa App' )
fireEvent . click ( titles [ titles . length - 1 ] )
2026-02-23 20:12:54 +01:00
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 : '' ,
2026-02-26 20:50:29 +01:00
wordCount : 0 ,
2026-02-21 17:12:45 +01:00
relationships : { } ,
icon : null ,
color : null ,
2026-02-21 17:27:19 +01:00
order : null ,
2026-03-02 08:37:06 +01:00
template : null ,
2026-02-25 15:04:49 +01:00
outgoingLinks : [ ] ,
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 : '' ,
2026-02-26 20:50:29 +01:00
wordCount : 0 ,
2026-02-21 17:12:45 +01:00
relationships : { } ,
icon : null ,
color : null ,
2026-02-21 17:27:19 +01:00
order : null ,
2026-03-02 08:37:06 +01:00
template : null ,
2026-02-25 15:04:49 +01:00
outgoingLinks : [ ] ,
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.' ,
2026-02-26 20:50:29 +01:00
wordCount : 0 ,
2026-02-21 16:56:48 +01:00
relationships : { } ,
icon : null ,
color : null ,
2026-02-22 11:00:07 +01:00
order : null ,
2026-03-02 08:37:06 +01:00
template : null ,
2026-02-25 15:04:49 +01:00
outgoingLinks : [ ] ,
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.' ,
2026-02-26 20:50:29 +01:00
wordCount : 0 ,
2026-02-21 16:56:48 +01:00
relationships : { } ,
icon : null ,
color : null ,
2026-02-22 11:00:07 +01:00
order : null ,
2026-03-02 08:37:06 +01:00
template : null ,
2026-02-25 15:04:49 +01:00
outgoingLinks : [ ] ,
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-24 15:54:24 +01:00
describe ( 'NoteList — status indicators' , ( ) = > {
it ( 'shows modified indicator dot for modified notes' , ( ) = > {
const getNoteStatus = ( path : string ) = > path === mockEntries [ 0 ] . path ? 'modified' as const : 'clean' as const
2026-02-23 21:15:13 +01:00
render (
2026-02-24 15:54:24 +01:00
< NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } getNoteStatus = { getNoteStatus } onCreateNote = { vi . fn ( ) } / >
2026-02-23 21:15:13 +01:00
)
const indicators = screen . getAllByTestId ( 'modified-indicator' )
expect ( indicators ) . toHaveLength ( 1 )
const noteRow = indicators [ 0 ] . closest ( '[data-testid="modified-indicator"]' ) ! . parentElement ! . parentElement !
expect ( noteRow . textContent ) . toContain ( 'Build Laputa App' )
} )
2026-02-24 15:54:24 +01:00
it ( 'does not show indicator when all notes are clean' , ( ) = > {
const getNoteStatus = ( ) = > 'clean' as const
2026-02-23 21:15:13 +01:00
render (
2026-02-24 15:54:24 +01:00
< NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } getNoteStatus = { getNoteStatus } onCreateNote = { vi . fn ( ) } / >
2026-02-23 21:15:13 +01:00
)
expect ( screen . queryByTestId ( 'modified-indicator' ) ) . not . toBeInTheDocument ( )
2026-02-24 15:54:24 +01:00
expect ( screen . queryByTestId ( 'new-indicator' ) ) . not . toBeInTheDocument ( )
2026-02-23 21:15:13 +01:00
} )
it ( 'shows multiple modified indicators for multiple modified files' , ( ) = > {
2026-02-24 15:54:24 +01:00
const modifiedPaths = new Set ( [ mockEntries [ 0 ] . path , mockEntries [ 1 ] . path ] )
const getNoteStatus = ( path : string ) = > modifiedPaths . has ( path ) ? 'modified' as const : 'clean' as const
2026-02-23 21:15:13 +01:00
render (
2026-02-24 15:54:24 +01:00
< NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } getNoteStatus = { getNoteStatus } onCreateNote = { vi . fn ( ) } / >
2026-02-23 21:15:13 +01:00
)
expect ( screen . getAllByTestId ( 'modified-indicator' ) ) . toHaveLength ( 2 )
} )
2026-02-24 15:54:24 +01:00
it ( 'does not show indicator when getNoteStatus prop is undefined' , ( ) = > {
2026-02-23 21:15:13 +01:00
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-24 15:54:24 +01:00
expect ( screen . queryByTestId ( 'new-indicator' ) ) . not . toBeInTheDocument ( )
} )
it ( 'shows green new indicator for new notes' , ( ) = > {
const getNoteStatus = ( path : string ) = > path === mockEntries [ 0 ] . path ? 'new' as const : 'clean' as const
render (
< NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } getNoteStatus = { getNoteStatus } onCreateNote = { vi . fn ( ) } / >
)
expect ( screen . getAllByTestId ( 'new-indicator' ) ) . toHaveLength ( 1 )
expect ( screen . queryByTestId ( 'modified-indicator' ) ) . not . toBeInTheDocument ( )
2026-02-23 21:15:13 +01:00
} )
} )
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 } ` ,
2026-02-26 20:50:29 +01:00
wordCount : 0 ,
2026-02-23 21:13:58 +01:00
relationships : { } ,
icon : null ,
color : null ,
order : null ,
2026-03-02 08:37:06 +01:00
template : null ,
2026-02-25 15:04:49 +01:00
outgoingLinks : [ ] ,
2026-02-23 21:13:58 +01:00
. . . overrides ,
} )
2026-02-26 13:40:27 +01:00
it ( 'renders 9000 entries without crashing' , { timeout : 30000 } , ( ) = > {
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-24 22:07:13 +01:00
it ( 'shows modified notes when both getNoteStatus and modifiedFiles are provided' , ( ) = > {
// Regression: App.tsx passes both getNoteStatus and modifiedFiles.
// The changes filter must use modifiedFiles for filtering even when getNoteStatus is present.
const getNoteStatus = ( path : string ) = > modifiedFiles . some ( ( f ) = > f . path === path ) ? 'modified' as const : 'clean' as const
render (
< NoteList entries = { mockEntries } selection = { changesSelection } selectedNote = { null } modifiedFiles = { modifiedFiles } getNoteStatus = { getNoteStatus } 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 ( )
} )
2026-03-02 03:07:02 +01:00
2026-03-02 23:25:54 +01:00
it ( 'matches entries by relative path suffix when absolute paths differ (cross-machine)' , ( ) = > {
// Simulate a cloned vault where cached entries have paths from a different machine
const crossMachineEntries : VaultEntry [ ] = mockEntries . map ( ( e ) = > ( {
. . . e ,
path : e.path.replace ( '/Users/luca/Laputa' , '/Users/other-machine/OtherVault' ) ,
} ) )
const modifiedFromCurrentMachine = [
{ 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 = { crossMachineEntries } selection = { changesSelection } selectedNote = { null } modifiedFiles = { modifiedFromCurrentMachine } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / >
)
// Even though absolute paths differ, entries should match via relative path suffix
expect ( screen . getByText ( 'Build Laputa App' ) ) . toBeInTheDocument ( )
expect ( screen . getByText ( 'Facebook Ads Strategy' ) ) . toBeInTheDocument ( )
expect ( screen . queryByText ( 'Matteo Cellini' ) ) . not . toBeInTheDocument ( )
} )
it ( 'shows error message when modifiedFilesError is set' , ( ) = > {
render (
< NoteList entries = { mockEntries } selection = { changesSelection } selectedNote = { null } modifiedFiles = { [ ] } modifiedFilesError = "git status failed: not a git repository" onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / >
)
expect ( screen . getByText ( /Failed to load changes/ ) ) . toBeInTheDocument ( )
expect ( screen . getByText ( /git status failed/ ) ) . toBeInTheDocument ( )
} )
2026-03-02 03:07:02 +01:00
it ( 'shows untracked (new) notes alongside modified notes in changes view' , ( ) = > {
const mixedFiles = [
{ path : mockEntries [ 0 ] . path , relativePath : 'project/26q1-laputa-app.md' , status : 'modified' as const } ,
{ path : mockEntries [ 2 ] . path , relativePath : 'person/matteo-cellini.md' , status : 'untracked' as const } ,
]
render (
< NoteList entries = { mockEntries } selection = { changesSelection } selectedNote = { null } modifiedFiles = { mixedFiles } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / >
)
expect ( screen . getByText ( 'Build Laputa App' ) ) . toBeInTheDocument ( )
expect ( screen . getByText ( 'Matteo Cellini' ) ) . toBeInTheDocument ( )
expect ( screen . queryByText ( 'Facebook Ads Strategy' ) ) . not . toBeInTheDocument ( )
} )
2026-02-24 14:46:18 +01:00
} )
2026-02-23 21:13:58 +01:00
} )
2026-02-27 15:14:21 +01:00
// --- Multi-select tests ---
describe ( 'NoteList — multi-select' , ( ) = > {
beforeEach ( ( ) = > {
noopSelect . mockClear ( )
noopReplace . mockClear ( )
} )
2026-02-27 22:46:26 +01:00
it ( 'Shift+Click selects a range of notes' , ( ) = > {
2026-02-27 15:14:21 +01:00
render ( < NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / > )
2026-02-27 22:46:26 +01:00
// Regular click to set anchor
fireEvent . click ( screen . getByText ( 'Build Laputa App' ) )
// Shift+Click to select range
fireEvent . click ( screen . getByText ( 'Matteo Cellini' ) , { shiftKey : true } )
// Should select all notes between Build Laputa App and Matteo Cellini (inclusive)
2026-02-27 15:14:21 +01:00
const selected = screen . getAllByTestId ( 'multi-selected-item' )
2026-02-27 22:46:26 +01:00
expect ( selected . length ) . toBeGreaterThanOrEqual ( 2 )
2026-02-27 15:14:21 +01:00
} )
it ( 'regular click clears multi-select and opens note' , ( ) = > {
render ( < NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / > )
2026-02-27 22:46:26 +01:00
// Select range via Shift+click
fireEvent . click ( screen . getByText ( 'Build Laputa App' ) )
fireEvent . click ( screen . getByText ( 'Facebook Ads Strategy' ) , { shiftKey : true } )
expect ( screen . getAllByTestId ( 'multi-selected-item' ) . length ) . toBeGreaterThanOrEqual ( 1 )
2026-02-27 15:14:21 +01:00
// Regular click clears selection and opens note
fireEvent . click ( screen . getByText ( 'Matteo Cellini' ) )
expect ( screen . queryByTestId ( 'multi-selected-item' ) ) . not . toBeInTheDocument ( )
expect ( noopReplace ) . toHaveBeenCalledWith ( mockEntries [ 2 ] )
} )
2026-02-27 22:46:26 +01:00
it ( 'Cmd+Click clears multi-select and opens in new tab' , ( ) = > {
2026-02-27 15:14:21 +01:00
render ( < NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / > )
2026-02-27 22:46:26 +01:00
// Select range via Shift+click
fireEvent . click ( screen . getByText ( 'Build Laputa App' ) )
fireEvent . click ( screen . getByText ( 'Facebook Ads Strategy' ) , { shiftKey : true } )
expect ( screen . getAllByTestId ( 'multi-selected-item' ) . length ) . toBeGreaterThanOrEqual ( 1 )
// Cmd+click clears selection and opens in new tab
fireEvent . click ( screen . getByText ( 'Matteo Cellini' ) , { metaKey : true } )
expect ( screen . queryByTestId ( 'multi-selected-item' ) ) . not . toBeInTheDocument ( )
expect ( noopSelect ) . toHaveBeenCalledWith ( mockEntries [ 2 ] )
2026-02-27 15:14:21 +01:00
} )
it ( 'shows bulk action bar with correct count' , ( ) = > {
render ( < NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / > )
2026-02-27 22:46:26 +01:00
fireEvent . click ( screen . getByText ( 'Build Laputa App' ) )
fireEvent . click ( screen . getByText ( 'Facebook Ads Strategy' ) , { shiftKey : true } )
2026-02-27 15:14:21 +01:00
expect ( screen . getByTestId ( 'bulk-action-bar' ) ) . toBeInTheDocument ( )
expect ( screen . getByText ( '2 selected' ) ) . toBeInTheDocument ( )
} )
it ( 'bulk archive calls onBulkArchive and clears selection' , ( ) = > {
const onBulkArchive = vi . fn ( )
render ( < NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } onBulkArchive = { onBulkArchive } / > )
2026-02-27 22:46:26 +01:00
fireEvent . click ( screen . getByText ( 'Build Laputa App' ) )
fireEvent . click ( screen . getByText ( 'Facebook Ads Strategy' ) , { shiftKey : true } )
2026-02-27 15:14:21 +01:00
fireEvent . click ( screen . getByTestId ( 'bulk-archive-btn' ) )
2026-02-27 22:46:26 +01:00
expect ( onBulkArchive ) . toHaveBeenCalledWith ( [ mockEntries [ 0 ] . path , mockEntries [ 1 ] . path ] )
2026-02-27 15:14:21 +01:00
expect ( screen . queryByTestId ( 'bulk-action-bar' ) ) . not . toBeInTheDocument ( )
} )
it ( 'bulk trash calls onBulkTrash and clears selection' , ( ) = > {
const onBulkTrash = vi . fn ( )
render ( < NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } onBulkTrash = { onBulkTrash } / > )
2026-02-27 22:46:26 +01:00
fireEvent . click ( screen . getByText ( 'Build Laputa App' ) )
fireEvent . click ( screen . getByText ( 'Facebook Ads Strategy' ) , { shiftKey : true } )
2026-02-27 15:14:21 +01:00
fireEvent . click ( screen . getByTestId ( 'bulk-trash-btn' ) )
2026-02-27 22:46:26 +01:00
expect ( onBulkTrash ) . toHaveBeenCalledWith ( [ mockEntries [ 0 ] . path , mockEntries [ 1 ] . path ] )
2026-02-27 15:14:21 +01:00
expect ( screen . queryByTestId ( 'bulk-action-bar' ) ) . not . toBeInTheDocument ( )
} )
it ( 'clear button on bulk action bar clears selection' , ( ) = > {
render ( < NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / > )
2026-02-27 22:46:26 +01:00
fireEvent . click ( screen . getByText ( 'Build Laputa App' ) )
fireEvent . click ( screen . getByText ( 'Facebook Ads Strategy' ) , { shiftKey : true } )
2026-02-27 15:14:21 +01:00
expect ( screen . getByTestId ( 'bulk-action-bar' ) ) . toBeInTheDocument ( )
fireEvent . click ( screen . getByTestId ( 'bulk-clear-btn' ) )
expect ( screen . queryByTestId ( 'bulk-action-bar' ) ) . not . toBeInTheDocument ( )
expect ( screen . queryByTestId ( 'multi-selected-item' ) ) . not . toBeInTheDocument ( )
} )
2026-02-27 22:46:26 +01:00
it ( 'Cmd+E archives selected notes when multiselect is active' , ( ) = > {
const onBulkArchive = vi . fn ( )
render ( < NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } onBulkArchive = { onBulkArchive } / > )
fireEvent . click ( screen . getByText ( 'Build Laputa App' ) )
fireEvent . click ( screen . getByText ( 'Facebook Ads Strategy' ) , { shiftKey : true } )
expect ( screen . getByTestId ( 'bulk-action-bar' ) ) . toBeInTheDocument ( )
fireEvent . keyDown ( window , { key : 'e' , metaKey : true } )
expect ( onBulkArchive ) . toHaveBeenCalledWith ( [ mockEntries [ 0 ] . path , mockEntries [ 1 ] . path ] )
expect ( screen . queryByTestId ( 'bulk-action-bar' ) ) . not . toBeInTheDocument ( )
} )
it ( 'Cmd+Backspace trashes selected notes when multiselect is active' , ( ) = > {
const onBulkTrash = vi . fn ( )
render ( < NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } onBulkTrash = { onBulkTrash } / > )
fireEvent . click ( screen . getByText ( 'Build Laputa App' ) )
fireEvent . click ( screen . getByText ( 'Facebook Ads Strategy' ) , { shiftKey : true } )
expect ( screen . getByTestId ( 'bulk-action-bar' ) ) . toBeInTheDocument ( )
fireEvent . keyDown ( window , { key : 'Backspace' , metaKey : true } )
expect ( onBulkTrash ) . toHaveBeenCalledWith ( [ mockEntries [ 0 ] . path , mockEntries [ 1 ] . path ] )
expect ( screen . queryByTestId ( 'bulk-action-bar' ) ) . not . toBeInTheDocument ( )
} )
it ( 'Cmd+Delete trashes selected notes when multiselect is active' , ( ) = > {
const onBulkTrash = vi . fn ( )
render ( < NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } onBulkTrash = { onBulkTrash } / > )
fireEvent . click ( screen . getByText ( 'Build Laputa App' ) )
fireEvent . click ( screen . getByText ( 'Facebook Ads Strategy' ) , { shiftKey : true } )
fireEvent . keyDown ( window , { key : 'Delete' , metaKey : true } )
expect ( onBulkTrash ) . toHaveBeenCalledWith ( [ mockEntries [ 0 ] . path , mockEntries [ 1 ] . path ] )
expect ( screen . queryByTestId ( 'bulk-action-bar' ) ) . not . toBeInTheDocument ( )
} )
2026-02-27 15:14:21 +01:00
it ( 'no bulk action bar when nothing is selected' , ( ) = > {
render ( < NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / > )
expect ( screen . queryByTestId ( 'bulk-action-bar' ) ) . not . toBeInTheDocument ( )
} )
} )
2026-02-27 22:43:19 +01:00
2026-02-28 19:43:30 +01:00
// --- Type note filtering tests ---
const typeEntry : VaultEntry = {
path : '/Users/luca/Laputa/types/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 : 'Defines the Project type.' ,
wordCount : 50 ,
relationships : { } ,
icon : null ,
color : null ,
order : null ,
2026-03-02 08:37:06 +01:00
template : null ,
2026-02-28 19:43:30 +01:00
outgoingLinks : [ ] ,
}
const entriesWithType = [ . . . mockEntries , typeEntry ]
describe ( 'NoteList — type note filtering' , ( ) = > {
beforeEach ( ( ) = > {
noopSelect . mockClear ( )
noopReplace . mockClear ( )
} )
it ( 'does not show type note PinnedCard when browsing a sectionGroup' , ( ) = > {
render (
< NoteList entries = { entriesWithType } selection = { { kind : 'sectionGroup' , type : 'Project' } } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / >
)
// The type note snippet should NOT be visible (PinnedCard was removed)
expect ( screen . queryByText ( 'Defines the Project type.' ) ) . not . toBeInTheDocument ( )
// But the Project instance should still be in the list
expect ( screen . getByText ( 'Build Laputa App' ) ) . toBeInTheDocument ( )
} )
it ( 'shows clickable header title that navigates to type note' , ( ) = > {
render (
< NoteList entries = { entriesWithType } selection = { { kind : 'sectionGroup' , type : 'Project' } } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / >
)
const headerLink = screen . getByTestId ( 'type-header-link' )
expect ( headerLink ) . toBeInTheDocument ( )
expect ( headerLink . textContent ) . toBe ( 'Project' )
expect ( headerLink . style . cursor ) . toBe ( 'pointer' )
fireEvent . click ( headerLink )
expect ( noopReplace ) . toHaveBeenCalledWith ( typeEntry )
} )
it ( 'header is not clickable when not viewing a type section' , ( ) = > {
render (
< NoteList entries = { entriesWithType } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / >
)
expect ( screen . queryByTestId ( 'type-header-link' ) ) . not . toBeInTheDocument ( )
} )
} )
2026-02-27 22:43:19 +01:00
describe ( 'NoteList — traffic light padding when sidebar collapsed' , ( ) = > {
it ( 'adds left padding to header when sidebarCollapsed is true' , ( ) = > {
const { container } = render (
< NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } sidebarCollapsed = { true } onCreateNote = { vi . fn ( ) } / >
)
const header = container . querySelector ( '.h-\\[52px\\]' ) as HTMLElement
expect ( header . style . paddingLeft ) . toBe ( '80px' )
} )
it ( 'does not add extra left padding when sidebarCollapsed is false' , ( ) = > {
const { container } = render (
< NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } sidebarCollapsed = { false } onCreateNote = { vi . fn ( ) } / >
)
const header = container . querySelector ( '.h-\\[52px\\]' ) as HTMLElement
expect ( header . style . paddingLeft ) . toBe ( '' )
} )
it ( 'does not add extra left padding when sidebarCollapsed is not provided' , ( ) = > {
const { container } = render (
< NoteList entries = { mockEntries } selection = { allSelection } selectedNote = { null } onSelectNote = { noopSelect } onReplaceActiveTab = { noopReplace } allContent = { { } } onCreateNote = { vi . fn ( ) } / >
)
const header = container . querySelector ( '.h-\\[52px\\]' ) as HTMLElement
expect ( header . style . paddingLeft ) . toBe ( '' )
} )
} )