Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148// src/layout/file-tree/__tests__/useFileTree.test.tsximport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'import { renderHook, render, act } from '@testing-library/react'import type { AutomergeUrl } from '@automerge/automerge-repo'import { useFileTree } from '../useFileTree'import { EXPANDED_FOLDERS_KEY } from '../file-tree-persistence'import { createTFile, createTFolder, type FileStats } from '../../../plugin/files'import type { EventRef, EventRefImpl } from '../../../plugin/events'
const stat: FileStats = { ctime: 0, mtime: 0, size: 0 }
function createMockVault() { const root = createTFolder('', null) const listeners: Map<string, Set<(...args: any[]) => void>> = new Map() const urlMap = new Map<AutomergeUrl, any>() const mockVault = { getRoot: () => root, getAbstractFileByUrl: vi.fn((url: AutomergeUrl) => urlMap.get(url) ?? null), on: vi.fn((name: string, cb: (...args: any[]) => void): EventRef => { if (!listeners.has(name)) listeners.set(name, new Set()) listeners.get(name)!.add(cb) // Return EventRefImpl cast to EventRef return { e: mockVault as any, n: name, fn: cb } as EventRef }), offref: vi.fn((ref: EventRef) => { // Cast to internal type to access fields const impl = ref as EventRefImpl listeners.get(impl.n)?.delete(impl.fn as any) }), _trigger: (name: string, ...args: any[]) => { listeners.get(name)?.forEach(cb => cb(...args)) }, _root: root, _urlMap: urlMap, } return mockVault}
describe('useFileTree', () => { beforeEach(() => { localStorage.clear() }) afterEach(() => { localStorage.clear() })
it('loads expanded set from localStorage', () => { localStorage.setItem(EXPANDED_FOLDERS_KEY, JSON.stringify(['automerge:a'])) const v = createMockVault() const { result } = renderHook(() => useFileTree(v as any)) expect(result.current.isExpanded('automerge:a' as AutomergeUrl)).toBe(true) expect(result.current.isExpanded('automerge:c' as AutomergeUrl)).toBe(false) })
it('returns root children from vault', () => { const v = createMockVault() v._root.children.push(createTFolder('Notes', v._root)) const { result } = renderHook(() => useFileTree(v as any)) expect(result.current.rootChildren).toHaveLength(1) })
it('toggleExpanded toggles and persists', () => { const v = createMockVault() const { result } = renderHook(() => useFileTree(v as any)) const url = 'automerge:f' as AutomergeUrl act(() => { result.current.toggleExpanded(url) }) expect(result.current.isExpanded(url)).toBe(true) act(() => { result.current.toggleExpanded(url) }) expect(result.current.isExpanded(url)).toBe(false) })
it('getFolderChildren returns TFolder.children', () => { const v = createMockVault() const folder = createTFolder('Notes', v._root) folder.children.push(createTFile('hello.md', folder, stat)) v._root.children.push(folder) const folderUrl = 'automerge:notes' as AutomergeUrl v._urlMap.set(folderUrl, folder) const { result } = renderHook(() => useFileTree(v as any)) expect(result.current.getFolderChildren(folderUrl)).toHaveLength(1) })
it('subscribes to create/delete/rename/modify and unsubscribes on unmount', () => { // `modify` was added when FileIndex started populating real // timestamps from Automerge change history — date-sort modes need // the panel to re-render when an existing file's mtime changes. const v = createMockVault() const { unmount } = renderHook(() => useFileTree(v as any)) expect(v.on).toHaveBeenCalledWith('create', expect.any(Function)) expect(v.on).toHaveBeenCalledWith('delete', expect.any(Function)) expect(v.on).toHaveBeenCalledWith('rename', expect.any(Function)) expect(v.on).toHaveBeenCalledWith('modify', expect.any(Function)) unmount() expect(v.offref).toHaveBeenCalledTimes(5) })
it('re-renders on vault create event', () => { const v = createMockVault() const { result } = renderHook(() => useFileTree(v as any)) expect(result.current.rootChildren).toHaveLength(0) v._root.children.push(createTFile('new.md', v._root, stat)) act(() => { v._trigger('create', v._root.children[0]) }) expect(result.current.rootChildren).toHaveLength(1) })
it('re-renders on vault delete event', () => { const v = createMockVault() const file = createTFile('old.md', v._root, stat) v._root.children.push(file) const { result } = renderHook(() => useFileTree(v as any)) v._root.children.splice(0, 1) act(() => { v._trigger('delete', file) }) expect(result.current.rootChildren).toHaveLength(0) })
it('re-renders on vault rename event', () => { const v = createMockVault() const file = createTFile('old.md', v._root, stat) v._root.children.push(file) const { result } = renderHook(() => useFileTree(v as any)) // Simulate rename: change the file's name (Vault does this before firing event) file.name = 'new.md' act(() => { v._trigger('rename', file, 'old.md') }) // After re-render, the hook should reflect the updated name expect(result.current.rootChildren[0].name).toBe('new.md') })
it('re-renders on vault modify event so date-sort modes pick up new mtime', () => { // Adversary P1-1: the previous version of this test was tautological // (asserted reference equality on a live array, plus a stat mutation // it set itself). Replace with a render-counter probe so we actually // observe the hook re-renders after `modify`. const v = createMockVault() const file = createTFile('a.md', v._root, stat) v._root.children.push(file)
let renderCount = 0 function Probe() { renderCount++ useFileTree(v as any) return null } render(<Probe />) const before = renderCount
act(() => { v._trigger('modify', file) })
// The hook MUST bump its version counter on modify (otherwise // downstream useMemo's keyed on the counter + sort mode would // return stale output for date-sort modes). expect(renderCount).toBeGreaterThan(before) })})