Gim/internal/command/handlers_test.go

2948 lines
74 KiB
Go

package command
import (
"os"
"path/filepath"
"strings"
"testing"
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// ==================================================
// Mock Model Implementation
// ==================================================
type mockModel struct {
windows []*core.Window
activeWindow *core.Window
buffers []*core.Buffer
settings core.EditorSettings
mode core.Mode
registers map[rune]core.Register
insertKeys []string
command string
commandCursor int
commandError error
commandOutput string
}
func newMockModel() *mockModel {
buf := core.NewBufferBuilder().
WithLines([]string{""}).
Build()
win := core.NewWindowBuilder().
WithBuffer(&buf).
WithHeight(24).
WithWidth(80).
Build()
return &mockModel{
windows: []*core.Window{&win},
activeWindow: &win,
buffers: []*core.Buffer{&buf},
settings: core.NewDefaultSettings(),
mode: core.NormalMode,
registers: core.DefaultRegisters(),
}
}
func newMockModelWithBuffer(buf *core.Buffer) *mockModel {
win := core.NewWindowBuilder().
WithBuffer(buf).
WithHeight(24).
WithWidth(80).
Build()
return &mockModel{
windows: []*core.Window{&win},
activeWindow: &win,
buffers: []*core.Buffer{buf},
settings: core.NewDefaultSettings(),
mode: core.NormalMode,
registers: core.DefaultRegisters(),
}
}
// Core Data Access
func (m *mockModel) Windows() []*core.Window { return m.windows }
func (m *mockModel) ActiveWindow() *core.Window { return m.activeWindow }
func (m *mockModel) Buffers() []*core.Buffer { return m.buffers }
func (m *mockModel) SetBuffers(bufs []*core.Buffer) { m.buffers = bufs }
func (m *mockModel) ActiveBuffer() *core.Buffer { return m.activeWindow.Buffer }
// Insert Mode State
func (m *mockModel) InsertKeys() []string { return m.insertKeys }
func (m *mockModel) SetInsertKeys(keys []string) { m.insertKeys = keys }
func (m *mockModel) SetInsertRecording(count int, action action.Action) {}
func (m *mockModel) ExitInsertMode() {}
// Command Mode State
func (m *mockModel) Command() string { return m.command }
func (m *mockModel) SetCommand(cmd string) { m.command = cmd }
func (m *mockModel) CommandCursor() int { return m.commandCursor }
func (m *mockModel) SetCommandCursor(cur int) { m.commandCursor = cur }
func (m *mockModel) CommandError() error { return m.commandError }
func (m *mockModel) SetCommandError(err error) { m.commandError = err }
func (m *mockModel) CommandOutput() string { return m.commandOutput }
func (m *mockModel) SetCommandOutput(out string) { m.commandOutput = out }
// Editor-wide State
func (m *mockModel) Mode() core.Mode { return m.mode }
func (m *mockModel) SetMode(mode core.Mode) { m.mode = mode }
func (m *mockModel) Settings() core.EditorSettings { return m.settings }
func (m *mockModel) SetSettings(s core.EditorSettings) { m.settings = s }
// Registers
func (m *mockModel) Registers() map[rune]core.Register { return m.registers }
func (m *mockModel) GetRegister(name rune) (core.Register, bool) {
reg, ok := m.registers[name]
return reg, ok
}
func (m *mockModel) SetRegister(name rune, t core.RegisterType, cnt []string) error {
m.registers[name] = core.Register{Type: t, Content: cnt}
return nil
}
func (m *mockModel) UpdateDefaultRegister(t core.RegisterType, cnt []string) {
m.registers['"'] = core.Register{Type: t, Content: cnt}
}
// ==================================================
// cmdWrite Tests
// ==================================================
func TestCmdWrite(t *testing.T) {
t.Run("writes buffer content to file", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{"line 1", "line 2", "line 3"}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
content, err := os.ReadFile(filename)
if err != nil {
t.Fatalf("file not written: %v", err)
}
expected := "line 1\nline 2\nline 3\n"
if string(content) != expected {
t.Errorf("got %q, want %q", string(content), expected)
}
})
t.Run("writes to argument filename instead of buffer filename", func(t *testing.T) {
tmpDir := t.TempDir()
originalFile := filepath.Join(tmpDir, "original.txt")
newFile := filepath.Join(tmpDir, "new.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(originalFile).
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{newFile}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
// Verify new file was created
if _, err := os.Stat(newFile); os.IsNotExist(err) {
t.Error("new file not created")
}
// Verify original file was NOT created
if _, err := os.Stat(originalFile); !os.IsNotExist(err) {
t.Error("original file should not exist")
}
content, _ := os.ReadFile(newFile)
expected := "content\n"
if string(content) != expected {
t.Errorf("got %q, want %q", string(content), expected)
}
})
t.Run("truncates existing file when overwriting", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
// Write longer content first
originalContent := "this is very long original content\nwith multiple lines\nand more text\n"
os.WriteFile(filename, []byte(originalContent), 0644)
// Create buffer with shorter content
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{"short"}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
content, _ := os.ReadFile(filename)
expected := "short\n"
if string(content) != expected {
t.Errorf("file not truncated properly: got %q, want %q", string(content), expected)
}
})
t.Run("refuses to write readonly buffer", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{"content"}).
ReadOnly().
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for readonly buffer")
}
if !strings.Contains(m.commandError.Error(), "readonly") {
t.Errorf("error should mention readonly: %v", m.commandError)
}
// Verify file was NOT created
if _, err := os.Stat(filename); !os.IsNotExist(err) {
t.Error("file should not be created for readonly buffer")
}
})
t.Run("refuses to write scratch buffer", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
buf := core.NewBufferBuilder().
WithType(core.ScatchBuffer).
WithFilename(filename).
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for scratch buffer")
}
if !strings.Contains(m.commandError.Error(), "ScratchBuffer") {
t.Errorf("error should mention ScratchBuffer: %v", m.commandError)
}
})
t.Run("errors when no filename and buffer has no filename", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error when no filename available")
}
if !strings.Contains(m.commandError.Error(), "no file name") {
t.Errorf("error should mention no file name: %v", m.commandError)
}
})
t.Run("errors on invalid path", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("/invalid/nonexistent/path/file.txt").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for invalid path")
}
})
t.Run("writes empty buffer", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "empty.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
content, err := os.ReadFile(filename)
if err != nil {
t.Fatalf("file not created: %v", err)
}
// Empty buffer should produce empty file
if string(content) != "" {
t.Errorf("expected empty file, got %q", string(content))
}
})
t.Run("writes buffer with empty lines", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{"line 1", "", "line 3", ""}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
content, _ := os.ReadFile(filename)
expected := "line 1\n\nline 3\n\n"
if string(content) != expected {
t.Errorf("got %q, want %q", string(content), expected)
}
})
t.Run("writes buffer with special characters", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "special.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{
"hello\tworld",
"unicode: \u00e9\u00e8\u00ea",
"symbols: !@#$%^&*()",
}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
content, _ := os.ReadFile(filename)
expected := "hello\tworld\nunicode: \u00e9\u00e8\u00ea\nsymbols: !@#$%^&*()\n"
if string(content) != expected {
t.Errorf("got %q, want %q", string(content), expected)
}
})
t.Run("writes buffer with unicode content", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "unicode.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{
"Japanese: \u3053\u3093\u306b\u3061\u306f",
"Chinese: \u4f60\u597d",
"Emoji: \U0001F600\U0001F389\U0001F680",
"Russian: \u041f\u0440\u0438\u0432\u0435\u0442",
}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
content, _ := os.ReadFile(filename)
lines := strings.Split(strings.TrimSuffix(string(content), "\n"), "\n")
if len(lines) != 4 {
t.Errorf("expected 4 lines, got %d", len(lines))
}
if !strings.Contains(lines[0], "\u3053\u3093\u306b\u3061\u306f") {
t.Errorf("Japanese content not preserved: %q", lines[0])
}
})
t.Run("sets correct output message", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{"line 1", "line 2"}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandOutput == "" {
t.Error("expected output message")
}
// Should contain filename and line count
if !strings.Contains(m.commandOutput, filename) {
t.Errorf("output should contain filename: %q", m.commandOutput)
}
if !strings.Contains(m.commandOutput, "2L") {
t.Errorf("output should contain line count: %q", m.commandOutput)
}
})
t.Run("writes large file", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "large.txt")
// Create buffer with many lines
lines := make([]string, 10000)
for i := range lines {
lines[i] = strings.Repeat("x", 100)
}
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines(lines).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
info, err := os.Stat(filename)
if err != nil {
t.Fatalf("file not created: %v", err)
}
// Each line is 100 chars + newline = 101 bytes * 10000 lines
expectedSize := int64(101 * 10000)
if info.Size() != expectedSize {
t.Errorf("expected size %d, got %d", expectedSize, info.Size())
}
})
t.Run("writes to file with spaces in path", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "file with spaces.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if _, err := os.Stat(filename); os.IsNotExist(err) {
t.Error("file with spaces not created")
}
})
t.Run("creates parent directories via argument", func(t *testing.T) {
tmpDir := t.TempDir()
// Note: cmdWrite doesn't create parent dirs, so this should fail
filename := filepath.Join(tmpDir, "nonexistent", "nested", "file.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("original.txt").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{filename}, false)
// Current implementation doesn't create parent dirs, so expect error
if m.commandError == nil {
t.Error("expected error when parent directories don't exist")
}
})
t.Run("clears modified flag after successful write", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{"content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
if !m.ActiveBuffer().Modified {
t.Fatal("precondition: buffer should be modified before write")
}
cmdWrite(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if m.ActiveBuffer().Modified {
t.Error("buffer should not be modified after successful write")
}
})
t.Run("clears modified flag even when writing to different file", func(t *testing.T) {
tmpDir := t.TempDir()
originalFile := filepath.Join(tmpDir, "original.txt")
newFile := filepath.Join(tmpDir, "copy.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(originalFile).
WithLines([]string{"content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{newFile}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
// Modified flag is cleared even when writing to a different file
if m.ActiveBuffer().Modified {
t.Error("buffer modified flag should be cleared after write")
}
// Buffer filename should NOT change (vim behavior)
if m.ActiveBuffer().Filename != originalFile {
t.Errorf("buffer filename should remain %q, got %q",
originalFile, m.ActiveBuffer().Filename)
}
})
t.Run("does not clear modified flag on write error", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("/invalid/nonexistent/path/file.txt").
WithLines([]string{"content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmdWrite(m, []string{}, false)
if m.commandError == nil {
t.Fatal("expected error for invalid path")
}
// Modified flag should remain true on error
if !m.ActiveBuffer().Modified {
t.Error("buffer should still be modified after failed write")
}
})
}
// ==================================================
// cmdEdit Tests
// ==================================================
func TestCmdEdit(t *testing.T) {
t.Run("errors when no argument provided", func(t *testing.T) {
m := newMockModel()
cmdEdit(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error when no argument")
}
if !strings.Contains(m.commandError.Error(), "requires an argument") {
t.Errorf("error should mention requires argument: %v", m.commandError)
}
})
t.Run("creates new buffer for nonexistent file", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "newfile.txt")
m := newMockModel()
initialBufferCount := len(m.buffers)
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
// Should have added a new buffer
if len(m.buffers) != initialBufferCount+1 {
t.Errorf("expected %d buffers, got %d", initialBufferCount+1, len(m.buffers))
}
// Active window should point to new buffer
if m.activeWindow.Buffer.Filename != filename {
t.Errorf("active buffer filename = %q, want %q",
m.activeWindow.Buffer.Filename, filename)
}
// New buffer should be empty
if m.activeWindow.Buffer.LineCount() != 1 || m.activeWindow.Buffer.Line(0) != "" {
t.Errorf("new buffer should have one empty line")
}
// Buffer should be FileBuffer type
if m.activeWindow.Buffer.Type != core.FileBuffer {
t.Errorf("buffer type = %v, want FileBuffer", m.activeWindow.Buffer.Type)
}
})
t.Run("loads existing file content", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "existing.txt")
// Create file with content
content := "line 1\nline 2\nline 3\n"
os.WriteFile(filename, []byte(content), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
buf := m.activeWindow.Buffer
if buf.LineCount() != 3 {
t.Errorf("expected 3 lines, got %d", buf.LineCount())
}
if buf.Line(0) != "line 1" {
t.Errorf("line 0 = %q, want %q", buf.Line(0), "line 1")
}
if buf.Line(1) != "line 2" {
t.Errorf("line 1 = %q, want %q", buf.Line(1), "line 2")
}
if buf.Line(2) != "line 3" {
t.Errorf("line 2 = %q, want %q", buf.Line(2), "line 3")
}
})
t.Run("loads file without trailing newline", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "notrailing.txt")
// File without trailing newline
content := "line 1\nline 2"
os.WriteFile(filename, []byte(content), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
buf := m.activeWindow.Buffer
if buf.LineCount() != 2 {
t.Errorf("expected 2 lines, got %d", buf.LineCount())
}
})
t.Run("loads empty file", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "empty.txt")
os.WriteFile(filename, []byte(""), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
buf := m.activeWindow.Buffer
// Empty file should have 0 lines (scanner produces no lines)
if buf.LineCount() != 0 {
t.Errorf("expected 0 lines for empty file, got %d", buf.LineCount())
}
})
t.Run("sets correct filetype from extension", func(t *testing.T) {
tmpDir := t.TempDir()
tests := []struct {
filename string
expected string
}{
{"test.go", ".go"},
{"test.py", ".py"},
{"test.js", ".js"},
{"test.txt", ".txt"},
{"Makefile", ""},
{"test.tar.gz", ".gz"},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
filename := filepath.Join(tmpDir, tt.filename)
os.WriteFile(filename, []byte("content"), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if m.activeWindow.Buffer.Filetype != tt.expected {
t.Errorf("filetype = %q, want %q", m.activeWindow.Buffer.Filetype, tt.expected)
}
})
}
})
t.Run("converts tabs to spaces based on tabstop", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "tabs.txt")
// File with tabs
content := "\tindented\n\t\tdouble"
os.WriteFile(filename, []byte(content), 0644)
m := newMockModel()
m.settings.TabStop = 4
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
buf := m.activeWindow.Buffer
// Tab should be converted to 4 spaces
expected0 := " indented"
if buf.Line(0) != expected0 {
t.Errorf("line 0 = %q, want %q", buf.Line(0), expected0)
}
expected1 := " double"
if buf.Line(1) != expected1 {
t.Errorf("line 1 = %q, want %q", buf.Line(1), expected1)
}
})
t.Run("handles different tabstop values", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "tabs.txt")
content := "\ttest"
os.WriteFile(filename, []byte(content), 0644)
tests := []struct {
tabstop int
expected string
}{
{2, " test"},
{4, " test"},
{8, " test"},
}
for _, tt := range tests {
t.Run(string(rune('0'+tt.tabstop)), func(t *testing.T) {
m := newMockModel()
m.settings.TabStop = tt.tabstop
cmdEdit(m, []string{filename}, false)
if m.activeWindow.Buffer.Line(0) != tt.expected {
t.Errorf("with tabstop=%d: got %q, want %q",
tt.tabstop, m.activeWindow.Buffer.Line(0), tt.expected)
}
})
}
})
t.Run("loads file with unicode content", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "unicode.txt")
content := "Hello \u4e16\u754c\n\u3053\u3093\u306b\u3061\u306f\nEmoji: \U0001F600\n"
os.WriteFile(filename, []byte(content), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
buf := m.activeWindow.Buffer
if buf.LineCount() != 3 {
t.Errorf("expected 3 lines, got %d", buf.LineCount())
}
if !strings.Contains(buf.Line(0), "\u4e16\u754c") {
t.Errorf("unicode not preserved in line 0: %q", buf.Line(0))
}
})
t.Run("loads large file", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "large.txt")
// Create large file
var content strings.Builder
for range 10000 {
content.WriteString(strings.Repeat("x", 100))
content.WriteString("\n")
}
os.WriteFile(filename, []byte(content.String()), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
buf := m.activeWindow.Buffer
if buf.LineCount() != 10000 {
t.Errorf("expected 10000 lines, got %d", buf.LineCount())
}
})
t.Run("sets buffer as loaded and listed", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
os.WriteFile(filename, []byte("content"), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
buf := m.activeWindow.Buffer
if !buf.Loaded {
t.Error("buffer should be marked as loaded")
}
if !buf.Listed {
t.Error("buffer should be marked as listed")
}
})
t.Run("new file buffer is loaded and listed", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "newfile.txt")
m := newMockModel()
cmdEdit(m, []string{filename}, false)
buf := m.activeWindow.Buffer
if !buf.Loaded {
t.Error("new buffer should be marked as loaded")
}
if !buf.Listed {
t.Error("new buffer should be marked as listed")
}
})
t.Run("errors on permission denied", func(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "noperm.txt")
// Create file then remove read permission
os.WriteFile(filename, []byte("content"), 0644)
os.Chmod(filename, 0000)
defer os.Chmod(filename, 0644) // Cleanup
m := newMockModel()
cmdEdit(m, []string{filename}, false)
if m.commandError == nil {
t.Error("expected error for permission denied")
}
})
t.Run("handles file with long lines", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "longlines.txt")
// Use 50000 chars - within bufio.Scanner's default 64KB limit
longLine := strings.Repeat("x", 50000)
content := longLine + "\nshort\n" + longLine
os.WriteFile(filename, []byte(content), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
buf := m.activeWindow.Buffer
if len(buf.Line(0)) != 50000 {
t.Errorf("long line not preserved: got %d chars", len(buf.Line(0)))
}
})
t.Run("bufio.Scanner limitation with extremely long lines", func(t *testing.T) {
// NOTE: This documents a known limitation
// bufio.Scanner has a default max token size of ~64KB
// Lines longer than this will cause scanner.Scan() to return false
// with scanner.Err() returning bufio.ErrTooLong
//
// A future improvement would be to use bufio.Reader.ReadString
// or increase scanner buffer size with scanner.Buffer()
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "verylonglines.txt")
// Create line exceeding 64KB limit
veryLongLine := strings.Repeat("x", 100000)
os.WriteFile(filename, []byte(veryLongLine+"\n"), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
// Currently this will result in 0 lines loaded due to scanner limitation
// This test documents the current behavior
buf := m.activeWindow.Buffer
if buf.LineCount() > 0 && len(buf.Line(0)) == 100000 {
t.Log("Scanner handled very long line - limitation may be fixed")
} else {
t.Log("Known limitation: bufio.Scanner cannot handle lines > 64KB")
}
})
t.Run("strips carriage return from CRLF line endings", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "crlf.txt")
// Windows-style line endings
content := "line 1\r\nline 2\r\nline 3\r\n"
os.WriteFile(filename, []byte(content), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
buf := m.activeWindow.Buffer
if buf.LineCount() != 3 {
t.Errorf("expected 3 lines, got %d", buf.LineCount())
}
// Verify \r is stripped from all lines
for i := 0; i < buf.LineCount(); i++ {
line := buf.Line(i)
if strings.HasSuffix(line, "\r") {
t.Errorf("line %d has trailing \\r: %q", i, line)
}
if strings.Contains(line, "\r") {
t.Errorf("line %d contains \\r: %q", i, line)
}
}
// Verify content is correct
if buf.Line(0) != "line 1" {
t.Errorf("line 0 = %q, want %q", buf.Line(0), "line 1")
}
if buf.Line(1) != "line 2" {
t.Errorf("line 1 = %q, want %q", buf.Line(1), "line 2")
}
})
t.Run("handles mixed line endings", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "mixed.txt")
// Mix of Unix (\n) and Windows (\r\n) line endings
content := "unix line\nwindows line\r\nunix again\n"
os.WriteFile(filename, []byte(content), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
buf := m.activeWindow.Buffer
if buf.LineCount() != 3 {
t.Errorf("expected 3 lines, got %d", buf.LineCount())
}
// All lines should be clean (no \r)
for i := 0; i < buf.LineCount(); i++ {
if strings.Contains(buf.Line(i), "\r") {
t.Errorf("line %d contains \\r: %q", i, buf.Line(i))
}
}
})
t.Run("does not panic when editing nonexistent file", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "does_not_exist.txt")
m := newMockModel()
// This should NOT panic (tests the nil pointer fix)
cmdEdit(m, []string{filename}, false)
// Should succeed - creates new buffer
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
// Buffer should be created with the filename
if m.activeWindow.Buffer.Filename != filename {
t.Errorf("buffer filename = %q, want %q",
m.activeWindow.Buffer.Filename, filename)
}
})
t.Run("adds buffer to buffer list", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
os.WriteFile(filename, []byte("content"), 0644)
m := newMockModel()
initialCount := len(m.buffers)
cmdEdit(m, []string{filename}, false)
if len(m.buffers) != initialCount+1 {
t.Errorf("buffer not added: expected %d, got %d", initialCount+1, len(m.buffers))
}
// Find the new buffer in the list
found := false
for _, buf := range m.buffers {
if buf.Filename == filename {
found = true
break
}
}
if !found {
t.Error("new buffer not found in buffer list")
}
})
t.Run("handles path with spaces", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "file with spaces.txt")
os.WriteFile(filename, []byte("content"), 0644)
m := newMockModel()
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if m.activeWindow.Buffer.Filename != filename {
t.Errorf("filename = %q, want %q", m.activeWindow.Buffer.Filename, filename)
}
})
t.Run("switches to existing buffer instead of creating duplicate", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "existing.txt")
os.WriteFile(filename, []byte("original content"), 0644)
m := newMockModel()
initialBufferCount := len(m.buffers)
// First edit - loads the file
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error on first edit: %v", m.commandError)
}
if len(m.buffers) != initialBufferCount+1 {
t.Errorf("expected %d buffers after first edit, got %d", initialBufferCount+1, len(m.buffers))
}
firstBuffer := m.activeWindow.Buffer
if firstBuffer.Filename != filename {
t.Fatalf("first buffer filename = %q, want %q", firstBuffer.Filename, filename)
}
// Modify the buffer to verify we get the same instance
firstBuffer.SetLine(0, "modified content")
bufferId := firstBuffer.Id
// Second edit - should switch to existing buffer, not create new one
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error on second edit: %v", m.commandError)
}
// Should not have created a new buffer
if len(m.buffers) != initialBufferCount+1 {
t.Errorf("expected buffer count to remain %d, got %d (created duplicate buffer)",
initialBufferCount+1, len(m.buffers))
}
// Should be the same buffer instance
if m.activeWindow.Buffer.Id != bufferId {
t.Errorf("expected to switch to existing buffer (ID %d), got different buffer (ID %d)",
bufferId, m.activeWindow.Buffer.Id)
}
// Should have our modifications, not reload from disk
if m.activeWindow.Buffer.Line(0) != "modified content" {
t.Errorf("expected modified buffer content %q, got %q (buffer was reloaded from disk)",
"modified content", m.activeWindow.Buffer.Line(0))
}
})
t.Run("switches to existing buffer even if file was modified on disk", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "file.txt")
os.WriteFile(filename, []byte("version 1"), 0644)
m := newMockModel()
// First edit
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
bufferId := m.activeWindow.Buffer.Id
originalLine := m.activeWindow.Buffer.Line(0)
// Modify file on disk
os.WriteFile(filename, []byte("version 2 - changed on disk"), 0644)
// Second edit - should switch to buffer, not reload from disk
cmdEdit(m, []string{filename}, false)
// Should still be the same buffer
if m.activeWindow.Buffer.Id != bufferId {
t.Errorf("expected same buffer ID %d, got %d", bufferId, m.activeWindow.Buffer.Id)
}
// Should have old content, not reload
if m.activeWindow.Buffer.Line(0) != originalLine {
t.Errorf("buffer was reloaded from disk, expected %q, got %q",
originalLine, m.activeWindow.Buffer.Line(0))
}
// Note: Real vim would warn about file change, but for now we just switch to buffer
})
t.Run("creates new buffer for different file even if similar name", func(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "test.txt")
file2 := filepath.Join(tmpDir, "test2.txt")
os.WriteFile(file1, []byte("file 1"), 0644)
os.WriteFile(file2, []byte("file 2"), 0644)
m := newMockModel()
cmdEdit(m, []string{file1}, false)
bufferCount := len(m.buffers)
cmdEdit(m, []string{file2}, false)
// Should have created a new buffer for different file
if len(m.buffers) != bufferCount+1 {
t.Errorf("expected new buffer for different file, buffer count = %d", len(m.buffers))
}
if m.activeWindow.Buffer.Filename != file2 {
t.Errorf("active buffer filename = %q, want %q", m.activeWindow.Buffer.Filename, file2)
}
})
t.Run("matches buffer by absolute path", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "file.txt")
os.WriteFile(filename, []byte("content"), 0644)
m := newMockModel()
// First edit with absolute path
cmdEdit(m, []string{filename}, false)
bufferId := m.activeWindow.Buffer.Id
bufferCount := len(m.buffers)
// Second edit with same absolute path - should match
cmdEdit(m, []string{filename}, false)
if len(m.buffers) != bufferCount {
t.Error("created duplicate buffer for same absolute path")
}
if m.activeWindow.Buffer.Id != bufferId {
t.Error("did not switch to existing buffer")
}
})
}
// ==================================================
// Round-trip Tests (Edit then Write)
// ==================================================
func TestEditWriteRoundTrip(t *testing.T) {
t.Run("edit then write preserves content", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "roundtrip.txt")
original := "line 1\nline 2\nline 3\n"
os.WriteFile(filename, []byte(original), 0644)
m := newMockModel()
// Edit
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("edit error: %v", m.commandError)
}
// Write
cmdWrite(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("write error: %v", m.commandError)
}
// Read back
content, _ := os.ReadFile(filename)
if string(content) != original {
t.Errorf("content not preserved:\ngot: %q\nwant: %q", string(content), original)
}
})
t.Run("edit new file then write creates file", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "newfile.txt")
m := newMockModel()
// Edit (creates new buffer)
cmdEdit(m, []string{filename}, false)
if m.commandError != nil {
t.Fatalf("edit error: %v", m.commandError)
}
// Add content to buffer
m.activeWindow.Buffer.InsertLine(0, "hello world")
// Write
cmdWrite(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("write error: %v", m.commandError)
}
// Verify file exists
content, err := os.ReadFile(filename)
if err != nil {
t.Fatalf("file not created: %v", err)
}
expected := "hello world\n\n" // Original empty line + inserted line
if string(content) != expected {
t.Errorf("got %q, want %q", string(content), expected)
}
})
t.Run("edit modifies write to different file", func(t *testing.T) {
tmpDir := t.TempDir()
original := filepath.Join(tmpDir, "original.txt")
newFile := filepath.Join(tmpDir, "copy.txt")
os.WriteFile(original, []byte("content\n"), 0644)
m := newMockModel()
// Edit original
cmdEdit(m, []string{original}, false)
if m.commandError != nil {
t.Fatalf("edit error: %v", m.commandError)
}
// Modify
m.activeWindow.Buffer.SetLine(0, "modified content")
// Write to new file
cmdWrite(m, []string{newFile}, false)
if m.commandError != nil {
t.Fatalf("write error: %v", m.commandError)
}
// Verify new file has modified content
content, _ := os.ReadFile(newFile)
if string(content) != "modified content\n" {
t.Errorf("new file content = %q", string(content))
}
// Verify original is unchanged
origContent, _ := os.ReadFile(original)
if string(origContent) != "content\n" {
t.Errorf("original was modified: %q", string(origContent))
}
})
}
// ==================================================
// cmdQuit Tests
// ==================================================
//
// Expected Vim behavior for quit commands with readonly buffers:
//
// :quit / :q
// - Quits if no buffers are modified (readonly or not)
// - Errors if any buffer is modified (readonly or not)
//
// :quitall / :qa
// - Same as :quit but for all windows
//
// :write / :w
// - Errors if buffer is readonly (even if not modified)
//
// :wall / :wa
// - Only writes MODIFIED buffers
// - Skips unmodified buffers (including readonly ones)
// - Errors if any MODIFIED buffer is readonly
//
// :wq
// - Writes current buffer and quits
// - Errors if buffer is readonly AND modified
// - If buffer is NOT modified, just quits (no write needed)
//
// :wqall / :wqa
// - Writes all MODIFIED buffers and quits
// - Skips unmodified buffers (including readonly ones)
// - Errors if any MODIFIED buffer is readonly
//
// Force variants (with !) override readonly protection
// ==================================================
func TestCmdQuit(t *testing.T) {
t.Run("quits when no buffers are modified", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithFilename("test.txt").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdQuit(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command, got nil")
}
// Execute the command and verify it returns quit message
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("errors when buffer is modified", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithFilename("unsaved.txt").
WithLines([]string{"unsaved content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdQuit(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for modified buffer")
}
if cmd != nil {
t.Error("should not return quit command when buffer is modified")
}
})
t.Run("error message includes filename of modified buffer", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithFilename("important_file.txt").
WithLines([]string{"content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmdQuit(m, []string{}, false)
if m.commandError == nil {
t.Fatal("expected error")
}
if !strings.Contains(m.commandError.Error(), "important_file.txt") {
t.Errorf("error should mention filename: %v", m.commandError)
}
})
t.Run("errors when any buffer in list is modified", func(t *testing.T) {
// Active buffer is not modified
activeBuf := core.NewBufferBuilder().
WithFilename("active.txt").
WithLines([]string{"content"}).
Build()
activeBuf.Modified = false
// Another buffer IS modified
modifiedBuf := core.NewBufferBuilder().
WithFilename("modified.txt").
WithLines([]string{"unsaved"}).
Build()
modifiedBuf.Modified = true
m := newMockModelWithBuffer(&activeBuf)
m.buffers = append(m.buffers, &modifiedBuf)
cmd := cmdQuit(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error when any buffer is modified")
}
if cmd != nil {
t.Error("should not return quit command")
}
})
t.Run("switches to modified buffer when quitting fails", func(t *testing.T) {
activeBuf := core.NewBufferBuilder().
WithFilename("active.txt").
WithLines([]string{"content"}).
Build()
activeBuf.Modified = false
modifiedBuf := core.NewBufferBuilder().
WithFilename("modified.txt").
WithLines([]string{"unsaved"}).
Build()
modifiedBuf.Modified = true
m := newMockModelWithBuffer(&activeBuf)
m.buffers = append(m.buffers, &modifiedBuf)
cmdQuit(m, []string{}, false)
// Should switch active window to show the modified buffer
if m.activeWindow.Buffer.Filename != "modified.txt" {
t.Errorf("should switch to modified buffer, got %q",
m.activeWindow.Buffer.Filename)
}
})
t.Run("handles buffer with no filename", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithFilename("").
WithLines([]string{"unsaved content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmdQuit(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for modified buffer without filename")
}
// Error message should still be meaningful
if !strings.Contains(m.commandError.Error(), "unsaved") {
t.Logf("Note: error message for unnamed buffer: %v", m.commandError)
}
})
t.Run("quits with multiple unmodified buffers", func(t *testing.T) {
buf1 := core.NewBufferBuilder().
WithFilename("file1.txt").
WithLines([]string{"content"}).
Build()
buf1.Modified = false
buf2 := core.NewBufferBuilder().
WithFilename("file2.txt").
WithLines([]string{"content"}).
Build()
buf2.Modified = false
buf3 := core.NewBufferBuilder().
WithFilename("file3.txt").
WithLines([]string{"content"}).
Build()
buf3.Modified = false
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2, &buf3)
cmd := cmdQuit(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Error("expected quit command")
}
})
}
// ==================================================
// cmdQuitAll Tests
// ==================================================
func TestCmdQuitAll(t *testing.T) {
t.Run("quits when no buffers are modified", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithFilename("test.txt").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdQuitAll(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command, got nil")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("errors when any buffer is modified", func(t *testing.T) {
buf1 := core.NewBufferBuilder().
WithFilename("saved.txt").
WithLines([]string{"content"}).
Build()
buf1.Modified = false
buf2 := core.NewBufferBuilder().
WithFilename("unsaved.txt").
WithLines([]string{"modified content"}).
Build()
buf2.Modified = true
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmd := cmdQuitAll(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error when any buffer is modified")
}
if cmd != nil {
t.Error("should not return quit command")
}
})
t.Run("quits with multiple unmodified buffers", func(t *testing.T) {
buf1 := core.NewBufferBuilder().
WithFilename("file1.txt").
Build()
buf1.Modified = false
buf2 := core.NewBufferBuilder().
WithFilename("file2.txt").
Build()
buf2.Modified = false
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmd := cmdQuitAll(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Error("expected quit command")
}
})
t.Run("error mentions which buffer is modified", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithFilename("important_document.txt").
WithLines([]string{"content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmdQuitAll(m, []string{}, false)
if m.commandError == nil {
t.Fatal("expected error")
}
if !strings.Contains(m.commandError.Error(), "important_document.txt") {
t.Errorf("error should mention filename: %v", m.commandError)
}
})
t.Run("reports first modified buffer found", func(t *testing.T) {
buf1 := core.NewBufferBuilder().
WithFilename("first_modified.txt").
Build()
buf1.Modified = true
buf2 := core.NewBufferBuilder().
WithFilename("second_modified.txt").
Build()
buf2.Modified = true
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmdQuitAll(m, []string{}, false)
if m.commandError == nil {
t.Fatal("expected error")
}
// Should report first modified buffer
if !strings.Contains(m.commandError.Error(), "first_modified.txt") {
t.Errorf("should report first modified buffer: %v", m.commandError)
}
})
}
// ==================================================
// cmdQuitForce Tests (q!)
// ==================================================
func TestCmdQuitForce(t *testing.T) {
t.Run("quits with modified buffer", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithFilename("unsaved.txt").
WithLines([]string{"unsaved content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdQuit(m, []string{}, true)
// Should NOT error
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
// Should return quit command
if cmd == nil {
t.Fatal("expected quit command, got nil")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with readonly modified buffer", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithFilename("readonly.txt").
WithLines([]string{"modified content"}).
ReadOnly().
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdQuit(m, []string{}, true)
// Force quit should work even with readonly modified buffer
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with multiple modified buffers", func(t *testing.T) {
buf1 := core.NewBufferBuilder().
WithFilename("file1.txt").
WithLines([]string{"unsaved 1"}).
Modified().
Build()
buf2 := core.NewBufferBuilder().
WithFilename("file2.txt").
WithLines([]string{"unsaved 2"}).
Modified().
Build()
buf3 := core.NewBufferBuilder().
WithFilename("file3.txt").
WithLines([]string{"unsaved 3"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2, &buf3)
cmd := cmdQuit(m, []string{}, true)
// Should quit regardless of multiple modified buffers
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with unmodified buffers", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithFilename("test.txt").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdQuit(m, []string{}, false)
// Should work with unmodified buffers too
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with buffer without filename", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithFilename("").
WithLines([]string{"unsaved content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdQuit(m, []string{}, true)
// Force quit works even without filename
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with scratch buffer", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.ScatchBuffer).
WithFilename("scratch").
WithLines([]string{"content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdQuit(m, []string{}, true)
// Force quit works with scratch buffers
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with mix of modified and unmodified buffers", func(t *testing.T) {
modifiedBuf := core.NewBufferBuilder().
WithFilename("modified.txt").
WithLines([]string{"unsaved"}).
Modified().
Build()
unmodifiedBuf := core.NewBufferBuilder().
WithFilename("unmodified.txt").
WithLines([]string{"saved"}).
Build()
readonlyModifiedBuf := core.NewBufferBuilder().
WithFilename("readonly.txt").
WithLines([]string{"readonly unsaved"}).
ReadOnly().
Modified().
Build()
m := newMockModelWithBuffer(&modifiedBuf)
m.buffers = append(m.buffers, &unmodifiedBuf, &readonlyModifiedBuf)
cmd := cmdQuit(m, []string{}, true)
// Should quit regardless of buffer states
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("does not switch buffers", func(t *testing.T) {
activeBuf := core.NewBufferBuilder().
WithFilename("active.txt").
WithLines([]string{"active"}).
Build()
modifiedBuf := core.NewBufferBuilder().
WithFilename("modified.txt").
WithLines([]string{"unsaved"}).
Modified().
Build()
m := newMockModelWithBuffer(&activeBuf)
m.buffers = append(m.buffers, &modifiedBuf)
// Remember active buffer
activeFilename := m.activeWindow.Buffer.Filename
cmdQuit(m, []string{}, true)
// Should NOT switch to modified buffer (unlike regular quit)
if m.activeWindow.Buffer.Filename != activeFilename {
t.Errorf("should not switch buffers, got %q", m.activeWindow.Buffer.Filename)
}
})
}
// ==================================================
// cmdQuitAllForce Tests (qall! / qa!)
// ==================================================
func TestCmdQuitAllForce(t *testing.T) {
t.Run("quits with all buffers modified", func(t *testing.T) {
buf1 := core.NewBufferBuilder().
WithFilename("file1.txt").
WithLines([]string{"unsaved 1"}).
Modified().
Build()
buf2 := core.NewBufferBuilder().
WithFilename("file2.txt").
WithLines([]string{"unsaved 2"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmd := cmdQuitAll(m, []string{}, true)
// Should quit even with all buffers modified
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command, got nil")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with mix of modified and unmodified", func(t *testing.T) {
buf1 := core.NewBufferBuilder().
WithFilename("saved.txt").
WithLines([]string{"content"}).
Build()
buf2 := core.NewBufferBuilder().
WithFilename("unsaved.txt").
WithLines([]string{"modified content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmd := cmdQuitAll(m, []string{}, true)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with readonly buffers", func(t *testing.T) {
buf1 := core.NewBufferBuilder().
WithFilename("readonly1.txt").
WithLines([]string{"content"}).
ReadOnly().
Modified().
Build()
buf2 := core.NewBufferBuilder().
WithFilename("readonly2.txt").
WithLines([]string{"content"}).
ReadOnly().
Build()
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmd := cmdQuitAll(m, []string{}, true)
// Force quit should work with readonly buffers
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with scratch buffers", func(t *testing.T) {
buf1 := core.NewBufferBuilder().
WithType(core.ScatchBuffer).
WithFilename("scratch1").
WithLines([]string{"content"}).
Modified().
Build()
buf2 := core.NewBufferBuilder().
WithType(core.ScatchBuffer).
WithFilename("scratch2").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmd := cmdQuitAll(m, []string{}, true)
// Should quit with scratch buffers
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with buffers without filenames", func(t *testing.T) {
buf1 := core.NewBufferBuilder().
WithFilename("").
WithLines([]string{"unsaved 1"}).
Modified().
Build()
buf2 := core.NewBufferBuilder().
WithFilename("").
WithLines([]string{"unsaved 2"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmd := cmdQuitAll(m, []string{}, true)
// Force quit works even without filenames
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with no buffers", func(t *testing.T) {
m := newMockModel()
// Clear buffers
m.buffers = []*core.Buffer{}
cmd := cmdQuitAll(m, []string{}, true)
// Should quit even with no buffers
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with complex buffer states", func(t *testing.T) {
// Mix of all different buffer states
buf1 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("file.txt").
WithLines([]string{"modified file"}).
Modified().
Build()
buf2 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("readonly.txt").
WithLines([]string{"readonly modified"}).
ReadOnly().
Modified().
Build()
buf3 := core.NewBufferBuilder().
WithType(core.ScatchBuffer).
WithFilename("scratch").
WithLines([]string{"scratch modified"}).
Modified().
Build()
buf4 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("").
WithLines([]string{"no name"}).
Modified().
Build()
buf5 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("saved.txt").
WithLines([]string{"saved content"}).
Build()
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2, &buf3, &buf4, &buf5)
cmd := cmdQuitAll(m, []string{}, true)
// Force quit should work regardless of any buffer state
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("quits with all buffers unmodified", func(t *testing.T) {
buf1 := core.NewBufferBuilder().
WithFilename("file1.txt").
WithLines([]string{"content"}).
Build()
buf2 := core.NewBufferBuilder().
WithFilename("file2.txt").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmd := cmdQuitAll(m, []string{}, true)
// Should work with unmodified buffers too
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
}
// ==================================================
// cmdWriteAll Tests
// ==================================================
func TestCmdWriteAll(t *testing.T) {
t.Run("writes all modified buffers", func(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "file1.txt")
file2 := filepath.Join(tmpDir, "file2.txt")
buf1 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(file1).
WithLines([]string{"content 1"}).
Build()
buf1.Modified = true
buf2 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(file2).
WithLines([]string{"content 2"}).
Build()
buf2.Modified = true
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmdWriteAll(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
// Verify both files were written
content1, err := os.ReadFile(file1)
if err != nil {
t.Errorf("file1 not written: %v", err)
} else if string(content1) != "content 1\n" {
t.Errorf("file1 content = %q", string(content1))
}
content2, err := os.ReadFile(file2)
if err != nil {
t.Errorf("file2 not written: %v", err)
} else if string(content2) != "content 2\n" {
t.Errorf("file2 content = %q", string(content2))
}
})
t.Run("skips unmodified buffers", func(t *testing.T) {
tmpDir := t.TempDir()
modifiedFile := filepath.Join(tmpDir, "modified.txt")
unmodifiedFile := filepath.Join(tmpDir, "unmodified.txt")
modifiedBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(modifiedFile).
WithLines([]string{"new content"}).
Build()
modifiedBuf.Modified = true
unmodifiedBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(unmodifiedFile).
WithLines([]string{"old content"}).
Build()
unmodifiedBuf.Modified = false
m := newMockModelWithBuffer(&modifiedBuf)
m.buffers = append(m.buffers, &unmodifiedBuf)
cmdWriteAll(m, []string{}, false)
// Modified file should be written
if _, err := os.Stat(modifiedFile); os.IsNotExist(err) {
t.Error("modified file should be written")
}
// Unmodified file should NOT be written (doesn't exist)
if _, err := os.Stat(unmodifiedFile); !os.IsNotExist(err) {
t.Error("unmodified file should not be written")
}
})
t.Run("clears modified flag on written buffers", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{"content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmdWriteAll(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if buf.Modified {
t.Error("modified flag should be cleared after write")
}
})
t.Run("errors on modified readonly buffer", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("readonly.txt").
WithLines([]string{"content"}).
ReadOnly().
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmdWriteAll(m, []string{}, false)
if m.commandError == nil {
t.Fatal("expected error for modified readonly buffer")
}
if !strings.Contains(m.commandError.Error(), "readonly") {
t.Errorf("error should mention readonly: %v", m.commandError)
}
})
t.Run("skips unmodified readonly buffer", func(t *testing.T) {
tmpDir := t.TempDir()
writableFile := filepath.Join(tmpDir, "writable.txt")
writableBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(writableFile).
WithLines([]string{"writable content"}).
Modified().
Build()
// Readonly but NOT modified - should be skipped
readonlyBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("readonly.txt").
WithLines([]string{"readonly content"}).
ReadOnly().
Build()
// NOT modified!
m := newMockModelWithBuffer(&writableBuf)
m.buffers = append(m.buffers, &readonlyBuf)
cmdWriteAll(m, []string{}, false)
// Should succeed - readonly buffer not modified, so skip it
if m.commandError != nil {
t.Fatalf("should not error for unmodified readonly buffer: %v", m.commandError)
}
// Writable file should be written
content, _ := os.ReadFile(writableFile)
if string(content) != "writable content\n" {
t.Errorf("writable buffer not written: %q", string(content))
}
})
t.Run("errors on scratch buffer", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.ScatchBuffer).
WithFilename("scratch").
WithLines([]string{"content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmdWriteAll(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for scratch buffer")
}
})
t.Run("errors on buffer with no filename", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("").
WithLines([]string{"content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmdWriteAll(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for buffer without filename")
}
if !strings.Contains(m.commandError.Error(), "no file name") ||
!strings.Contains(strings.ToLower(m.commandError.Error()), "name") {
t.Logf("error message: %v", m.commandError)
}
})
t.Run("succeeds with no modified buffers", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("unchanged.txt").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmdWriteAll(m, []string{}, false)
// Should succeed even with nothing to write
if m.commandError != nil {
t.Errorf("unexpected error: %v", m.commandError)
}
})
t.Run("reports number of buffers written", func(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "file1.txt")
file2 := filepath.Join(tmpDir, "file2.txt")
buf1 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(file1).
WithLines([]string{"content"}).
Build()
buf1.Modified = true
buf2 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(file2).
WithLines([]string{"content"}).
Build()
buf2.Modified = true
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmdWriteAll(m, []string{}, false)
// Should have some output indicating what was written
if m.commandOutput == "" {
t.Log("Note: cmdWriteAll doesn't set output message")
} else if !strings.Contains(m.commandOutput, "2") {
t.Logf("Output message: %q", m.commandOutput)
}
})
t.Run("stops on first error", func(t *testing.T) {
tmpDir := t.TempDir()
validFile := filepath.Join(tmpDir, "valid.txt")
// First buffer - valid
validBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(validFile).
WithLines([]string{"content"}).
Build()
validBuf.Modified = true
// Second buffer - invalid (no filename)
invalidBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("").
WithLines([]string{"content"}).
Build()
invalidBuf.Modified = true
m := newMockModelWithBuffer(&invalidBuf) // Invalid first
m.buffers = append(m.buffers, &validBuf)
cmdWriteAll(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error")
}
// Valid file might or might not be written depending on implementation
// (stops on first error vs continues and reports all errors)
})
}
// ==================================================
// cmdWriteQuit Tests
// ==================================================
func TestCmdWriteQuit(t *testing.T) {
t.Run("writes buffer and quits", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{"content to save"}).
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdWriteQuit(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
// Verify file was written
content, err := os.ReadFile(filename)
if err != nil {
t.Errorf("file not written: %v", err)
} else if string(content) != "content to save\n" {
t.Errorf("file content = %q", string(content))
}
// Verify quit command returned
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("errors on readonly buffer", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("readonly.txt").
WithLines([]string{"content"}).
ReadOnly().
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdWriteQuit(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for readonly buffer")
}
if cmd != nil {
t.Error("should not return quit command on error")
}
})
t.Run("errors on scratch buffer", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.ScatchBuffer).
WithFilename("scratch").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdWriteQuit(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for scratch buffer")
}
if cmd != nil {
t.Error("should not return quit command on error")
}
})
t.Run("errors on buffer with no filename", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdWriteQuit(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for buffer without filename")
}
if cmd != nil {
t.Error("should not return quit command on error")
}
})
t.Run("errors on invalid path", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("/invalid/nonexistent/path/file.txt").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdWriteQuit(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for invalid path")
}
if cmd != nil {
t.Error("should not return quit command on error")
}
})
t.Run("writes to argument filename if provided", func(t *testing.T) {
tmpDir := t.TempDir()
originalFile := filepath.Join(tmpDir, "original.txt")
newFile := filepath.Join(tmpDir, "new.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(originalFile).
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdWriteQuit(m, []string{newFile}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
// New file should exist
if _, err := os.Stat(newFile); os.IsNotExist(err) {
t.Error("new file not created")
}
// Original should not exist
if _, err := os.Stat(originalFile); !os.IsNotExist(err) {
t.Error("original file should not exist")
}
if cmd == nil {
t.Error("expected quit command")
}
})
t.Run("clears modified flag before quit", func(t *testing.T) {
tmpDir := t.TempDir()
filename := filepath.Join(tmpDir, "test.txt")
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(filename).
WithLines([]string{"content"}).
Modified().
Build()
m := newMockModelWithBuffer(&buf)
cmdWriteQuit(m, []string{}, false)
if buf.Modified {
t.Error("modified flag should be cleared")
}
})
}
// ==================================================
// cmdWriteQuitAll Tests (wqall / wqa / xa)
// ==================================================
func TestCmdWriteQuitAll(t *testing.T) {
// Note: This command may not be implemented yet
// These tests define expected behavior
t.Run("writes all buffers and quits", func(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "file1.txt")
file2 := filepath.Join(tmpDir, "file2.txt")
buf1 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(file1).
WithLines([]string{"content 1"}).
Build()
buf1.Modified = true
buf2 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(file2).
WithLines([]string{"content 2"}).
Build()
buf2.Modified = true
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmd := cmdWriteQuitAll(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
// Verify files were written
content1, _ := os.ReadFile(file1)
if string(content1) != "content 1\n" {
t.Errorf("file1 content = %q", string(content1))
}
content2, _ := os.ReadFile(file2)
if string(content2) != "content 2\n" {
t.Errorf("file2 content = %q", string(content2))
}
// Verify quit command
if cmd == nil {
t.Fatal("expected quit command")
}
msg := cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Errorf("expected tea.QuitMsg, got %T", msg)
}
})
t.Run("skips unmodified buffers", func(t *testing.T) {
tmpDir := t.TempDir()
modifiedFile := filepath.Join(tmpDir, "modified.txt")
unmodifiedFile := filepath.Join(tmpDir, "unmodified.txt")
modifiedBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(modifiedFile).
WithLines([]string{"new content"}).
Build()
modifiedBuf.Modified = true
unmodifiedBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(unmodifiedFile).
WithLines([]string{"unchanged"}).
Build()
unmodifiedBuf.Modified = false
m := newMockModelWithBuffer(&modifiedBuf)
m.buffers = append(m.buffers, &unmodifiedBuf)
cmd := cmdWriteQuitAll(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
// Should still quit even if some buffers unmodified
if cmd == nil {
t.Error("expected quit command")
}
})
t.Run("errors if readonly buffer is modified", func(t *testing.T) {
tmpDir := t.TempDir()
validFile := filepath.Join(tmpDir, "valid.txt")
validBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(validFile).
WithLines([]string{"content"}).
Modified().
Build()
// Readonly buffer that IS modified - should cause error
readonlyBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("readonly.txt").
WithLines([]string{"content"}).
ReadOnly().
Modified().
Build()
m := newMockModelWithBuffer(&validBuf)
m.buffers = append(m.buffers, &readonlyBuf)
cmd := cmdWriteQuitAll(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for modified readonly buffer")
}
if !strings.Contains(m.commandError.Error(), "readonly") {
t.Errorf("error should mention readonly: %v", m.commandError)
}
if cmd != nil {
t.Error("should not quit when readonly buffer has unsaved changes")
}
})
t.Run("quits with readonly buffer that is NOT modified", func(t *testing.T) {
tmpDir := t.TempDir()
writableFile := filepath.Join(tmpDir, "writable.txt")
writableBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(writableFile).
WithLines([]string{"modified content"}).
Modified().
Build()
// Readonly buffer that is NOT modified - should be skipped, not prevent quit
readonlyBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("readonly.txt").
WithLines([]string{"unchanged content"}).
ReadOnly().
Build()
// NOT modified - this is the key
m := newMockModelWithBuffer(&writableBuf)
m.buffers = append(m.buffers, &readonlyBuf)
cmd := cmdWriteQuitAll(m, []string{}, false)
if m.commandError != nil {
t.Fatalf("should not error for unmodified readonly buffer: %v", m.commandError)
}
// Should write the modified writable buffer
content, _ := os.ReadFile(writableFile)
if string(content) != "modified content\n" {
t.Errorf("writable buffer not written: %q", string(content))
}
// Should return quit command
if cmd == nil {
t.Error("expected quit command (readonly buffer not modified)")
}
})
t.Run("errors if any buffer has no filename", func(t *testing.T) {
noNameBuf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("").
WithLines([]string{"content"}).
Build()
noNameBuf.Modified = true
m := newMockModelWithBuffer(&noNameBuf)
cmd := cmdWriteQuitAll(m, []string{}, false)
if m.commandError == nil {
t.Error("expected error for buffer without filename")
}
if cmd != nil {
t.Error("should not quit when write fails")
}
})
t.Run("quits with no modified buffers", func(t *testing.T) {
buf := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename("unchanged.txt").
WithLines([]string{"content"}).
Build()
m := newMockModelWithBuffer(&buf)
cmd := cmdWriteQuitAll(m, []string{}, false)
// Should still quit even if nothing to write
if m.commandError != nil {
t.Fatalf("unexpected error: %v", m.commandError)
}
if cmd == nil {
t.Error("expected quit command")
}
})
t.Run("clears modified flags on all buffers", func(t *testing.T) {
tmpDir := t.TempDir()
file1 := filepath.Join(tmpDir, "file1.txt")
file2 := filepath.Join(tmpDir, "file2.txt")
buf1 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(file1).
WithLines([]string{"content"}).
Build()
buf1.Modified = true
buf2 := core.NewBufferBuilder().
WithType(core.FileBuffer).
WithFilename(file2).
WithLines([]string{"content"}).
Build()
buf2.Modified = true
m := newMockModelWithBuffer(&buf1)
m.buffers = append(m.buffers, &buf2)
cmdWriteQuitAll(m, []string{}, false)
if buf1.Modified {
t.Error("buf1 modified flag should be cleared")
}
if buf2.Modified {
t.Error("buf2 modified flag should be cleared")
}
})
}