// src/layout/__tests__/SidebarViewContainer.sort.test.tsx
//
// Integration smoke for the chrome-row live update contract using a REAL
// FileTreeView (not a hand-rolled stub). This is Test 16 in the test
// plan and is the artifact-based proof of the full production pipeline:
//
// menu click → SortMenuButton.onChange → FileTreeView.setSortMode
// → view-sort-store + sort-mode-persistence + requestHeaderActionsUpdate
// → Workspace._requestHeaderActionsUpdate
// → reamsEvents.trigger('view-header-actions-change', leaf)
// → SidebarViewContainer's useState counter bumps
// → headerActions useMemo re-runs
// → SortMenuButton receives new currentMode prop
// → aria-label flips
// → vault state stamped.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
render,
cleanup,
act,
fireEvent,
within,
} from '@testing-library/react'
import type { AutomergeUrl } from '@automerge/automerge-repo'
import { App } from '../../plugin/app'
import { Vault } from '../../plugin/vault'
import { FileIndex } from '../../services/file-index'
import { Workspace } from '../../plugin/workspace'
import { MetadataCache } from '../../services/metadata'
import { ReamsEvents } from '../../plugin/reams/events'
import { createTFile } from '../../plugin/files'
import { SidebarViewContainer } from '../SidebarViewContainer'
import { LayoutProvider } from '../LayoutContext'
import {
FileTreeView,
FILE_TREE_VIEW_TYPE,
} from '../../plugins/file-tree/file-tree-view'
import {
QuickAccessView,
QUICK_ACCESS_VIEW_TYPE,
} from '../../plugins/quick-access/quick-access-view'
import { SORT_MODES, getSortModeMetadata } from '../../plugin/sidebar/sort-mode'
import { openTestVaultPreferences } from '../../services/__tests__/vault-state-test-helpers'
import { readVaultPreference, type VaultPreferences } from '../../services/vault-preferences'
// FileTree pulls metadata from SearchContext via useSearch().
vi.mock('../../services/SearchContext', () => ({
useSearch: () => ({
metadataCache: {
subscribe: () => () => {},
getVersion: () => 0,
getDisplayTitle: () => null,
getIndexedDocuments: () => [],
on: () => ({ e: {}, n: 'changed', fn: () => {} }),
offref: () => {},
},
}),
}))
interface Harness {
app: App
}
interface FileSpec {
path: string
ctime: number
mtime: number
}
function makeHarness(files: FileSpec[] = []): Harness {
const mockDocumentManager = {
createFile: vi.fn(),
createFolder: vi.fn(),
readText: vi.fn(async (url: AutomergeUrl) => ({ status: 'available' as const, documentId: url, heads: ['h0'], content: '' })),
updateContent: vi.fn(),
listFolder: vi.fn().mockResolvedValue([]),
createFileInFolder: vi.fn(),
getFile: vi.fn().mockResolvedValue(null),
} as any
const fileIndex = new FileIndex(mockDocumentManager, 'automerge:root' as AutomergeUrl)
const vault = new Vault('Test', fileIndex, mockDocumentManager)
const workspace = new Workspace(document.createElement('div'))
const metadataCache = new MetadataCache()
metadataCache.setVault(vault)
const app = new App(vault, workspace)
app.setMetadataCache(metadataCache)
app.setDocumentManager(mockDocumentManager)
const root = vault.getRoot()
for (const spec of files) {
const file = createTFile(spec.path, root, {
ctime: spec.ctime,
mtime: spec.mtime,
size: 0,
})
file.vault = vault
root.children.push(file)
fileIndex.registerFile(
`automerge:${spec.path}` as AutomergeUrl,
'automerge:root' as AutomergeUrl,
file,
)
}
// SidebarViewContainer subscribes to app.reamsEvents — App ctor already
// instantiates it, but we double-check to keep the harness explicit.
if (!(app as any).reamsEvents) {
;(app as any).reamsEvents = new ReamsEvents()
}
return { app }
}
describe('SidebarViewContainer integration: live sort-icon update', () => {
let harness: Harness | null = null
let preferences: VaultPreferences
beforeEach(async () => {
localStorage.clear()
preferences = await openTestVaultPreferences()
harness = null
})
afterEach(() => {
cleanup()
harness?.app.destroy()
preferences.close()
harness = null
document.querySelectorAll('.menu').forEach((m) => m.remove())
})
it('FileTreeView: a real menu click updates the icon and visible row order', async () => {
harness = makeHarness([
{ path: 'apple.md', ctime: 300, mtime: 200 },
{ path: 'banana.md', ctime: 100, mtime: 300 },
{ path: 'cherry.md', ctime: 200, mtime: 100 },
])
const { app } = harness
const workspace = app.workspace
workspace.registerViewType(
FILE_TREE_VIEW_TYPE,
(leaf) => new FileTreeView(leaf, app),
)
const leaf = workspace.getLeftLeaf(true)
await leaf.openView(FILE_TREE_VIEW_TYPE)
const { container } = render(
,
)
// Scope all button queries to the chrome-row header — the FileTree
// body renders its own buttons and we don't want to accidentally
// catch one of those.
const header = container.querySelector('.sidebar-view-header') as HTMLElement
expect(header).not.toBeNull()
// Initial: default mode is `name-asc`. The sort button's aria-label
// includes that mode's label.
const initialBtn = within(header).getByRole('button', { name: /Sort:/ })
expect(initialBtn.getAttribute('aria-label')).toContain(
getSortModeMetadata('name-asc').label,
)
expect(within(container).getAllByRole('treeitem').map((row) => row.textContent?.trim())).toEqual([
'apple.md',
'banana.md',
'cherry.md',
])
// Open the menu, click the modified-desc item.
act(() => {
fireEvent.click(initialBtn)
})
const items = document.querySelectorAll('.menu .menu-item')
expect(items.length).toBe(SORT_MODES.length)
// Index lookup against SORT_MODES so the test is robust to future
// reordering of the canonical mode list.
const targetIdx = SORT_MODES.indexOf('modified-desc')
act(() => {
;(items[targetIdx] as HTMLElement).click()
})
// Chrome-row icon updates without a tab switch — the button's
// aria-label now reflects the new mode. Re-query (the React tree
// re-rendered, the button instance may have changed).
const updatedBtn = within(header).getByRole('button', { name: /Sort:/ })
expect(updatedBtn.getAttribute('aria-label')).toContain(
getSortModeMetadata('modified-desc').label,
)
expect(within(container).getAllByRole('treeitem').map((row) => row.textContent?.trim())).toEqual([
'banana.md',
'apple.md',
'cherry.md',
])
// Persistence reflects the new mode at the file-tree view key.
const raw = readVaultPreference('reams:sidebar-sort-modes')
expect(raw).not.toBeNull()
const parsed = JSON.parse(raw!)
expect(parsed.version).toBe(1)
expect(parsed.modes[FILE_TREE_VIEW_TYPE]).toBe('modified-desc')
})
it('QuickAccessView: a real menu click updates the icon and visible row order', async () => {
harness = makeHarness([
{ path: 'apple.md', ctime: 300, mtime: 200 },
{ path: 'banana.md', ctime: 100, mtime: 300 },
{ path: 'cherry.md', ctime: 200, mtime: 100 },
])
const { app } = harness
const workspace = app.workspace
workspace.registerViewType(
QUICK_ACCESS_VIEW_TYPE,
(leaf) => new QuickAccessView(leaf, app),
)
const leaf = workspace.getLeftLeaf(true)
await leaf.openView(QUICK_ACCESS_VIEW_TYPE)
const { container } = render(
,
)
const header = container.querySelector('.sidebar-view-header') as HTMLElement
expect(header).not.toBeNull()
const initialBtn = within(header).getByRole('button', { name: /Sort:/ })
expect(initialBtn.getAttribute('aria-label')).toContain(
getSortModeMetadata('name-asc').label,
)
expect(
within(container)
.getAllByTestId('quick-access-row')
.map((row) => row.getAttribute('data-path')),
).toEqual(['apple.md', 'banana.md', 'cherry.md'])
act(() => {
fireEvent.click(initialBtn)
})
const items = document.querySelectorAll('.menu .menu-item')
expect(items.length).toBe(SORT_MODES.length)
const targetIdx = SORT_MODES.indexOf('created-asc')
act(() => {
;(items[targetIdx] as HTMLElement).click()
})
const updatedBtn = within(header).getByRole('button', { name: /Sort:/ })
expect(updatedBtn.getAttribute('aria-label')).toContain(
getSortModeMetadata('created-asc').label,
)
expect(
within(container)
.getAllByTestId('quick-access-row')
.map((row) => row.getAttribute('data-path')),
).toEqual(['banana.md', 'cherry.md', 'apple.md'])
const raw = readVaultPreference('reams:sidebar-sort-modes')
expect(raw).not.toBeNull()
const parsed = JSON.parse(raw!)
expect(parsed.modes[QUICK_ACCESS_VIEW_TYPE]).toBe('created-asc')
})
})