Pad/internal/io/pool/mock/filesystem.go
Greg Pomerantz 6a43e3c0db Add e2e test harness for testing editor logic without Gio display
- internal/test/e2e/: FrameCapture, Harness, ElementAssertions, helpers
- internal/editor/logic.go: Add done channel and Done() method for graceful shutdown
- internal/editor/mock_setup.go: Mock filesystem for tests
- internal/browser/: Browser layout and search logic
- internal/io/: Worker pool for async tasks
- Update architecture docs and spec
2026-05-31 08:56:30 -04:00

430 lines
9.0 KiB
Go

// Package mock provides a configurable mock filesystem for testing
// the IO worker pool without touching the real disk.
package mock
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// File represents a file or directory in the mock filesystem.
type File struct {
Path string
Name string
Content []byte
ModTime time.Time
Size int64
IsDir bool
}
// DirEntry mimics fs.DirEntry behavior for the mock.
type DirEntry struct {
name string
isDir bool
modTime time.Time
size int64
}
func (d DirEntry) Name() string { return d.name }
func (d DirEntry) IsDir() bool { return d.isDir }
func (d DirEntry) Info() (os.FileInfo, error) {
return &mockFileInfo{name: d.name, isDir: d.isDir, modTime: d.modTime, size: d.size}, nil
}
// mockFileInfo implements os.FileInfo for the mock.
type mockFileInfo struct {
name string
isDir bool
modTime time.Time
size int64
}
func (fi *mockFileInfo) Name() string { return fi.name }
func (fi *mockFileInfo) Size() int64 { return fi.size }
func (fi *mockFileInfo) Mode() os.FileMode { return 0644 }
func (fi *mockFileInfo) ModTime() time.Time { return fi.modTime }
func (fi *mockFileInfo) IsDir() bool { return fi.isDir }
func (fi *mockFileInfo) Sys() any { return nil }
// FileChangeEvent represents an external filesystem change.
type FileChangeEvent struct {
Path string
EventType string // "Created", "Modified", "Deleted"
Time time.Time
}
// notifyEvent is a pending notification collected while holding the lock.
type notifyEvent struct {
path string
eventType string
}
// FileSystem is a thread-safe in-memory filesystem for testing.
type FileSystem struct {
mu sync.RWMutex
files map[string]*File // path -> file
delay time.Duration // uniform delay applied to all operations
changes chan FileChangeEvent // async change notifications (nil = no notifications)
}
// NewFileSystem creates an empty mock filesystem.
func NewFileSystem() *FileSystem {
return &FileSystem{
files: make(map[string]*File),
}
}
// SetDelay sets a uniform delay applied to all IO operations.
func (fs *FileSystem) SetDelay(d time.Duration) {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.delay = d
}
// SetChangeEvents sets the channel for async change notifications.
// Pass nil to disable notifications.
func (fs *FileSystem) SetChangeEvents(ch chan FileChangeEvent) {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.changes = ch
}
// sendNotifications sends any pending notification events.
// Caller must NOT hold the mutex.
func (fs *FileSystem) sendNotifications(events []notifyEvent) {
fs.mu.RLock()
ch := fs.changes
fs.mu.RUnlock()
if ch == nil {
return
}
for _, e := range events {
select {
case ch <- FileChangeEvent{
Path: e.path,
EventType: e.eventType,
Time: time.Now(),
}:
default:
// Drop event if channel is full (non-blocking)
}
}
}
// CreateFile creates a file with the given content at the specified path.
func (fs *FileSystem) CreateFile(path string, content []byte) error {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
name := filepath.Base(path)
now := time.Now()
f := &File{
Path: path,
Name: name,
Content: content,
ModTime: now,
Size: int64(len(content)),
IsDir: false,
}
fs.files[path] = f
// Collect notification while holding the lock
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, "Created"})
}
fs.mu.Unlock()
// Send notifications outside the lock (avoids deadlock)
fs.sendNotifications(events)
return nil
}
// ReadFile returns the content of a file at the given path.
func (fs *FileSystem) ReadFile(path string) ([]byte, error) {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
f, ok := fs.files[path]
if !ok {
fs.mu.Unlock()
return nil, fmt.Errorf("file not found: %s", path)
}
content := append([]byte(nil), f.Content...)
fs.mu.Unlock()
return content, nil
}
// WriteFile replaces the content of a file at the given path.
func (fs *FileSystem) WriteFile(path string, content []byte) error {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
f, ok := fs.files[path]
if !ok {
fs.mu.Unlock()
return fmt.Errorf("file not found: %s", path)
}
f.Content = content
f.Size = int64(len(content))
f.ModTime = time.Now()
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, "Modified"})
}
fs.mu.Unlock()
fs.sendNotifications(events)
return nil
}
// DeleteFile removes a file at the given path.
func (fs *FileSystem) DeleteFile(path string) error {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
if _, ok := fs.files[path]; !ok {
fs.mu.Unlock()
return fmt.Errorf("file not found: %s", path)
}
delete(fs.files, path)
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, "Deleted"})
}
fs.mu.Unlock()
fs.sendNotifications(events)
return nil
}
// CreateDir creates a directory at the given path.
func (fs *FileSystem) CreateDir(path string) error {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
name := filepath.Base(path)
f := &File{
Path: path,
Name: name,
ModTime: time.Now(),
IsDir: true,
}
fs.files[path] = f
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, "Created"})
}
fs.mu.Unlock()
fs.sendNotifications(events)
return nil
}
// ReadDir returns a list of entries in the given directory.
func (fs *FileSystem) ReadDir(path string) ([]DirEntry, error) {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
dirPath := filepath.Clean(path)
if _, ok := fs.files[dirPath]; !ok {
fs.mu.Unlock()
return nil, fmt.Errorf("directory not found: %s", path)
}
var entries []DirEntry
for _, f := range fs.files {
if f.Path == dirPath {
continue
}
parent := filepath.Dir(f.Path)
if parent == dirPath {
entries = append(entries, DirEntry{
name: f.Name,
isDir: f.IsDir,
modTime: f.ModTime,
size: f.Size,
})
}
}
// Sort by name
for i := 0; i < len(entries); i++ {
for j := i + 1; j < len(entries); j++ {
if entries[i].name > entries[j].name {
entries[i], entries[j] = entries[j], entries[i]
}
}
}
fs.mu.Unlock()
return entries, nil
}
// DirExists returns true if the path exists and is a directory.
func (fs *FileSystem) DirExists(path string) bool {
fs.mu.RLock()
defer fs.mu.RUnlock()
f, ok := fs.files[path]
return ok && f.IsDir
}
// FileExists returns true if the path exists and is a file.
func (fs *FileSystem) FileExists(path string) bool {
fs.mu.RLock()
defer fs.mu.RUnlock()
f, ok := fs.files[path]
return ok && !f.IsDir
}
// GetFile returns the file at the given path, or nil if not found.
func (fs *FileSystem) GetFile(path string) *File {
fs.mu.RLock()
defer fs.mu.RUnlock()
return fs.files[path]
}
// SetFileContent atomically replaces the content of an existing file.
func (fs *FileSystem) SetFileContent(path string, content []byte) {
fs.mu.Lock()
if fs.delay > 0 {
fs.mu.Unlock()
time.Sleep(fs.delay)
fs.mu.Lock()
}
f, ok := fs.files[path]
if !ok {
fs.mu.Unlock()
return
}
f.Content = content
f.Size = int64(len(content))
f.ModTime = time.Now()
var events []notifyEvent
if fs.changes != nil {
events = append(events, notifyEvent{path, "Modified"})
}
fs.mu.Unlock()
fs.sendNotifications(events)
}
// AddFile adds a file without triggering change notifications.
// Useful for pre-populating the filesystem before tests.
func (fs *FileSystem) AddFile(path string, content []byte, modTime time.Time) {
fs.mu.Lock()
defer fs.mu.Unlock()
name := filepath.Base(path)
f := &File{
Path: path,
Name: name,
Content: content,
ModTime: modTime,
Size: int64(len(content)),
IsDir: false,
}
fs.files[path] = f
}
// AddDir adds a directory without triggering change notifications.
func (fs *FileSystem) AddDir(path string, modTime time.Time) {
fs.mu.Lock()
defer fs.mu.Unlock()
name := filepath.Base(path)
f := &File{
Path: path,
Name: name,
ModTime: modTime,
IsDir: true,
}
fs.files[path] = f
}
// RemoveFile removes a file without triggering change notifications.
func (fs *FileSystem) RemoveFile(path string) {
fs.mu.Lock()
defer fs.mu.Unlock()
delete(fs.files, path)
}
// ListPaths returns all file paths in the filesystem, optionally filtered by prefix.
func (fs *FileSystem) ListPaths(prefix string) []string {
fs.mu.RLock()
defer fs.mu.RUnlock()
var paths []string
for path := range fs.files {
if prefix == "" || filepath.HasPrefix(path, prefix) {
paths = append(paths, path)
}
}
return paths
}
// Count returns the number of files and directories in the mock filesystem.
func (fs *FileSystem) Count() (files int, dirs int) {
fs.mu.RLock()
defer fs.mu.RUnlock()
for _, f := range fs.files {
if f.IsDir {
dirs++
} else {
files++
}
}
return
}