diff --git a/internal/editor/buffer.go b/internal/editor/buffer.go index 6a7bd90..86d7905 100644 --- a/internal/editor/buffer.go +++ b/internal/editor/buffer.go @@ -12,9 +12,6 @@ type Buffer struct { func NewBuffer(content string) *Buffer { lines := strings.Split(content, "\n") - if len(lines) == 0 { - lines = []string{""} - } return &Buffer{lines: lines} } diff --git a/internal/editor/buffer_test.go b/internal/editor/buffer_test.go index af2a365..4ceb761 100644 --- a/internal/editor/buffer_test.go +++ b/internal/editor/buffer_test.go @@ -107,3 +107,118 @@ func TestBuffer_Modified(t *testing.T) { t.Fatal("buffer should be modified after insert") } } + +func TestBuffer_GetLine_OutOfBounds(t *testing.T) { + b := NewBuffer("hello") + if got := b.GetLine(-1); got != "" { + t.Fatalf("expected empty string for negative row, got %q", got) + } + if got := b.GetLine(5); got != "" { + t.Fatalf("expected empty string for row beyond end, got %q", got) + } +} + +func TestBuffer_LineLen_OutOfBounds(t *testing.T) { + b := NewBuffer("hello") + if got := b.LineLen(-1); got != 0 { + t.Fatalf("expected 0 for negative row, got %d", got) + } + if got := b.LineLen(5); got != 0 { + t.Fatalf("expected 0 for row beyond end, got %d", got) + } +} + +func TestBuffer_InsertChar_OutOfBounds(t *testing.T) { + b := NewBuffer("hello") + // Invalid row — should be no-op + b.InsertChar(-1, 0, 'x') + b.InsertChar(5, 0, 'x') + if got := b.GetLine(0); got != "hello" { + t.Fatalf("expected unchanged line, got %q", got) + } + // Col beyond line length — should clamp + b.InsertChar(0, 100, '!') + if got := b.GetLine(0); got != "hello!" { + t.Fatalf("expected 'hello!' with clamped col, got %q", got) + } +} + +func TestBuffer_DeleteChar_OutOfBounds(t *testing.T) { + b := NewBuffer("hello") + // Invalid row + b.DeleteChar(-1, 0) + b.DeleteChar(5, 0) + // Invalid col + b.DeleteChar(0, -1) + b.DeleteChar(0, 10) + if got := b.GetLine(0); got != "hello" { + t.Fatalf("expected unchanged line, got %q", got) + } +} + +func TestBuffer_Backspace_AtStart(t *testing.T) { + b := NewBuffer("hello") + b.Backspace(0, 0) // at start of line — should be no-op + if got := b.GetLine(0); got != "hello" { + t.Fatalf("expected unchanged line, got %q", got) + } +} + +func TestBuffer_InsertNewline_OutOfBounds(t *testing.T) { + b := NewBuffer("hello") + b.InsertNewline(-1, 0) + b.InsertNewline(5, 0) + if b.LineCount() != 1 { + t.Fatalf("expected 1 line after out-of-bounds newline inserts, got %d", b.LineCount()) + } +} + +func TestBuffer_InsertNewline_ColClamp(t *testing.T) { + b := NewBuffer("hello") + b.InsertNewline(0, 100) // col beyond length, should clamp + if b.LineCount() != 2 { + t.Fatalf("expected 2 lines, got %d", b.LineCount()) + } + if got := b.GetLine(0); got != "hello" { + t.Fatalf("expected 'hello', got %q", got) + } + if got := b.GetLine(1); got != "" { + t.Fatalf("expected empty second line, got %q", got) + } +} + +func TestBuffer_JoinLines_OutOfBounds(t *testing.T) { + b := NewBuffer("hello\nworld") + // row 0 — can't join with row above + col := b.JoinLines(0) + if col != 0 { + t.Fatalf("expected 0 for out-of-bounds join, got %d", col) + } + // row beyond end + col = b.JoinLines(5) + if col != 0 { + t.Fatalf("expected 0 for out-of-bounds join, got %d", col) + } + if b.LineCount() != 2 { + t.Fatalf("expected unchanged line count 2, got %d", b.LineCount()) + } +} + +func TestBuffer_LineLen_Unicode(t *testing.T) { + b := NewBuffer("héllo 世界") + if got := b.LineLen(0); got != 8 { + t.Fatalf("expected 8 runes, got %d", got) + } +} + +func TestBuffer_ResetModified(t *testing.T) { + b := NewBuffer("hello") + b.InsertChar(0, 0, 'x') + if !b.Modified() { + t.Fatal("expected modified after insert") + } + b.ResetModified() + if b.Modified() { + t.Fatal("expected not modified after reset") + } +} diff --git a/internal/editor/editor.go b/internal/editor/editor.go index e80c0a1..558f098 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -125,6 +125,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if m.cursorRow > 0 { m.cursorRow-- m.cursorCol = m.buffer.LineLen(m.cursorRow) + m.scrollToCursor() } return m, nil @@ -135,6 +136,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if m.cursorRow < m.buffer.LineCount()-1 { m.cursorRow++ m.cursorCol = 0 + m.scrollToCursor() } return m, nil diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index eb336e0..9fc671d 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -189,3 +189,435 @@ func TestEditorModel_QuitUnmodified(t *testing.T) { t.Fatal("should quit immediately when no unsaved changes") } } + +func TestEditorModel_Init(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + if cmd := m.Init(); cmd != nil { + t.Fatal("Init should return nil") + } +} + +func TestEditorModel_WindowSizeMsg(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := updated.(Model) + if model.width != 120 || model.height != 40 { + t.Fatalf("expected 120x40, got %dx%d", model.width, model.height) + } + if model.editWidth != 60 { + t.Fatalf("expected editWidth 60, got %d", model.editWidth) + } +} + +func TestEditorModel_LeftWrap(t *testing.T) { + m := NewModel("hello\nworld", "/tmp/test.md") + m.width = 80 + m.height = 24 + m.cursorRow = 1 + m.cursorCol = 0 + + // Press Left at beginning of line 1 — should wrap to end of line 0 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model := updated.(Model) + if model.cursorRow != 0 { + t.Fatalf("expected row 0, got %d", model.cursorRow) + } + if model.cursorCol != 5 { + t.Fatalf("expected col 5, got %d", model.cursorCol) + } +} + +func TestEditorModel_RightWrap(t *testing.T) { + m := NewModel("hello\nworld", "/tmp/test.md") + m.width = 80 + m.height = 24 + m.cursorCol = 5 // end of "hello" + + // Press Right at end of line 0 — should wrap to start of line 1 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) + model := updated.(Model) + if model.cursorRow != 1 { + t.Fatalf("expected row 1, got %d", model.cursorRow) + } + if model.cursorCol != 0 { + t.Fatalf("expected col 0, got %d", model.cursorCol) + } +} + +func TestEditorModel_HomeEnd(t *testing.T) { + m := NewModel("hello world", "/tmp/test.md") + m.width = 80 + m.height = 24 + m.cursorCol = 5 + + // End key + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnd}) + model := updated.(Model) + if model.cursorCol != 11 { + t.Fatalf("expected col 11 after End, got %d", model.cursorCol) + } + + // Home key + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyHome}) + model = updated.(Model) + if model.cursorCol != 0 { + t.Fatalf("expected col 0 after Home, got %d", model.cursorCol) + } +} + +func TestEditorModel_Tab(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + model := updated.(Model) + if got := model.buffer.GetLine(0); got != "\thello" { + t.Fatalf("expected tab at start, got %q", got) + } + if model.cursorCol != 1 { + t.Fatalf("expected col 1, got %d", model.cursorCol) + } +} + +func TestEditorModel_Delete(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + + // Delete at start of line + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDelete}) + model := updated.(Model) + if got := model.buffer.GetLine(0); got != "ello" { + t.Fatalf("expected 'ello', got %q", got) + } +} + +func TestEditorModel_DeleteJoinsLines(t *testing.T) { + m := NewModel("hello\nworld", "/tmp/test.md") + m.width = 80 + m.height = 24 + m.cursorCol = 5 // end of "hello" + + // Delete at end of line should join with next + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDelete}) + model := updated.(Model) + if model.buffer.LineCount() != 1 { + t.Fatalf("expected 1 line, got %d", model.buffer.LineCount()) + } + if got := model.buffer.GetLine(0); got != "helloworld" { + t.Fatalf("expected 'helloworld', got %q", got) + } +} + +func TestEditorModel_BackspaceJoinsLines(t *testing.T) { + m := NewModel("hello\nworld", "/tmp/test.md") + m.width = 80 + m.height = 24 + m.cursorRow = 1 + m.cursorCol = 0 + + // Backspace at start of line 1 joins with line 0 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model := updated.(Model) + if model.buffer.LineCount() != 1 { + t.Fatalf("expected 1 line, got %d", model.buffer.LineCount()) + } + if model.cursorRow != 0 { + t.Fatalf("expected cursor on row 0, got %d", model.cursorRow) + } + if model.cursorCol != 5 { + t.Fatalf("expected cursor at col 5, got %d", model.cursorCol) + } +} + +func TestEditorModel_CtrlH_ToggleHelp(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + m.editWidth = 40 + + // Toggle help on + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + model := updated.(Model) + if !model.showHelp { + t.Fatal("expected showHelp true") + } + view := model.View() + if !strings.Contains(view, "Editor Help") { + t.Fatal("expected help text in view") + } + + // Toggle help off + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + model = updated.(Model) + if model.showHelp { + t.Fatal("expected showHelp false") + } +} + +func TestEditorModel_ConfirmQuit_Escape(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + + // Type to set modified + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}) + model := updated.(Model) + + // Ctrl+C to get confirmation + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + model = updated.(Model) + + // Escape to cancel + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEscape}) + model = updated.(Model) + if cmd != nil { + t.Fatal("escape should not quit") + } + if model.confirmQuit { + t.Fatal("confirmQuit should be false after escape") + } +} + +func TestEditorModel_ConfirmQuit_UnknownKey(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + + // Type to set modified + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}) + model := updated.(Model) + + // Ctrl+C to get confirmation + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + model = updated.(Model) + + // Press an unrelated key — should stay in confirmation mode + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + model = updated.(Model) + if cmd != nil { + t.Fatal("unknown key should not quit") + } + if !model.confirmQuit { + t.Fatal("confirmQuit should still be true") + } +} + +func TestEditorModel_DownClampCol(t *testing.T) { + m := NewModel("hello world\nhi", "/tmp/test.md") + m.width = 80 + m.height = 24 + m.cursorCol = 10 // somewhere in "hello world" + + // Move down to shorter line — col should clamp + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + model := updated.(Model) + if model.cursorCol != 2 { + t.Fatalf("expected col clamped to 2, got %d", model.cursorCol) + } +} + +func TestEditorModel_UpClampCol(t *testing.T) { + m := NewModel("hi\nhello world", "/tmp/test.md") + m.width = 80 + m.height = 24 + m.cursorRow = 1 + m.cursorCol = 10 + + // Move up to shorter line — col should clamp + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + model := updated.(Model) + if model.cursorCol != 2 { + t.Fatalf("expected col clamped to 2, got %d", model.cursorCol) + } +} + +func TestEditorModel_View_Loading(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + // width=0, height=0 + view := m.View() + if view != "Loading..." { + t.Fatalf("expected 'Loading...', got %q", view) + } +} + +func TestEditorModel_View_ModifiedIndicator(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + m.editWidth = 40 + + // Type to set modified + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}) + model := updated.(Model) + view := model.View() + if !strings.Contains(view, "[+]") { + t.Fatal("expected [+] modified indicator in view") + } +} + +func TestEditorModel_ClearTransientMessages(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "test.md") + os.WriteFile(path, []byte("hello"), 0644) + + m, _ := NewModelFromFile(path) + m.width = 80 + m.height = 24 + + // Save to get save message + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + model := updated.(Model) + if model.saveMsg != "Saved!" { + t.Fatal("expected save message") + } + + // Any non-save key should clear transient messages + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model = updated.(Model) + if model.saveMsg != "" { + t.Fatal("expected save message to be cleared") + } +} + +func TestEditorModel_NewModelFromFile_NotFound(t *testing.T) { + _, err := NewModelFromFile("/nonexistent/path/file.md") + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} + +func TestEditorModel_ScrollToCursor(t *testing.T) { + // Create content with many lines + lines := make([]string, 50) + for i := range lines { + lines[i] = "line" + } + content := strings.Join(lines, "\n") + m := NewModel(content, "/tmp/test.md") + m.width = 80 + m.height = 10 + + // Move cursor to bottom + for i := 0; i < 20; i++ { + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(Model) + } + if m.offsetRow == 0 { + t.Fatal("expected offsetRow to increase with scrolling") + } +} + +func TestEditorModel_UpAtTop(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + + // Up at row 0 — should be no-op + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + model := updated.(Model) + if model.cursorRow != 0 { + t.Fatalf("expected row 0, got %d", model.cursorRow) + } +} + +func TestEditorModel_DownAtBottom(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + + // Down at last line — should be no-op + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + model := updated.(Model) + if model.cursorRow != 0 { + t.Fatalf("expected row 0, got %d", model.cursorRow) + } +} + +func TestEditorModel_LeftAtStart(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + + // Left at row 0, col 0 — should be no-op + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model := updated.(Model) + if model.cursorRow != 0 || model.cursorCol != 0 { + t.Fatal("expected no movement at start") + } +} + +func TestEditorModel_RightAtEnd(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + m.cursorCol = 5 + + // Right at end of only line — should be no-op + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) + model := updated.(Model) + if model.cursorRow != 0 || model.cursorCol != 5 { + t.Fatal("expected no movement at end") + } +} + +func TestEditorModel_BackspaceInLine(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + m.width = 80 + m.height = 24 + m.cursorCol = 3 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model := updated.(Model) + if got := model.buffer.GetLine(0); got != "helo" { + t.Fatalf("expected 'helo', got %q", got) + } + if model.cursorCol != 2 { + t.Fatalf("expected col 2, got %d", model.cursorCol) + } +} + +func TestEditorModel_TypeMultipleRunes(t *testing.T) { + m := NewModel("", "/tmp/test.md") + m.width = 80 + m.height = 24 + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a', 'b', 'c'}} + updated, _ := m.Update(msg) + model := updated.(Model) + if got := model.buffer.GetLine(0); got != "abc" { + t.Fatalf("expected 'abc', got %q", got) + } + if model.cursorCol != 3 { + t.Fatalf("expected col 3, got %d", model.cursorCol) + } +} + +func TestEditorModel_UnknownMsg(t *testing.T) { + m := NewModel("hello", "/tmp/test.md") + // Pass an unhandled message type + updated, cmd := m.Update("unhandled string message") + model := updated.(Model) + if cmd != nil { + t.Fatal("expected nil cmd for unknown msg") + } + if model.cursorRow != 0 || model.cursorCol != 0 { + t.Fatal("expected no state change for unknown msg") + } +} + +func TestTruncate(t *testing.T) { + if got := truncate("hello world", 5); got != "hello" { + t.Fatalf("expected 'hello', got %q", got) + } + if got := truncate("hi", 10); got != "hi" { + t.Fatalf("expected 'hi', got %q", got) + } + if got := truncate("hello", 0); got != "" { + t.Fatalf("expected empty, got %q", got) + } + if got := truncate("hello", -1); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} diff --git a/internal/linter/linter_test.go b/internal/linter/linter_test.go index f575d4a..f220bc7 100644 --- a/internal/linter/linter_test.go +++ b/internal/linter/linter_test.go @@ -58,3 +58,35 @@ func TestLintReader(t *testing.T) { t.Fatal("expected issues from reader input") } } + +func TestLint_Sorted(t *testing.T) { + // Issues from multiple rules should be sorted by line number + input := []byte("# Title \n\n### Skipped\n\n# Title\n\n[empty]()\n") + issues := Lint(input) + for i := 1; i < len(issues); i++ { + if issues[i].Line < issues[i-1].Line { + t.Fatalf("issues not sorted: line %d before line %d", issues[i-1].Line, issues[i].Line) + } + } +} + +func TestLintFile_CleanFile(t *testing.T) { + issues, err := LintFile("../../testdata/simple.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(issues) != 0 { + t.Fatalf("expected no issues for simple.md, got %d", len(issues)) + } +} + +func TestLintReader_Clean(t *testing.T) { + r := strings.NewReader("# Title\n\nContent.\n") + issues, err := LintReader(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(issues) != 0 { + t.Fatalf("expected no issues for clean content, got %d", len(issues)) + } +} diff --git a/internal/linter/rules_test.go b/internal/linter/rules_test.go index 122ffc8..a513ad7 100644 --- a/internal/linter/rules_test.go +++ b/internal/linter/rules_test.go @@ -1,6 +1,7 @@ package linter import ( + "strings" "testing" ) @@ -109,3 +110,79 @@ func TestEmptySections_EndOfDocument(t *testing.T) { } } +func TestIssue_String(t *testing.T) { + issue := Issue{ + Rule: "test-rule", + Message: "test message", + Line: 42, + Severity: SeverityError, + } + got := issue.String() + expected := "line 42: [error] test message (test-rule)" + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestIssue_String_Warning(t *testing.T) { + issue := Issue{ + Rule: "trailing-whitespace", + Message: "line has trailing whitespace", + Line: 1, + Severity: SeverityWarning, + } + got := issue.String() + if !strings.Contains(got, "[warning]") { + t.Fatalf("expected [warning] in string, got %q", got) + } +} + +func TestCheckHeadingHierarchy_MultipleSkips(t *testing.T) { + input := []byte("# Title\n\n### Skip1\n\n##### Skip2\n") + issues := checkHeadingHierarchy(input) + if len(issues) != 2 { + t.Fatalf("expected 2 heading skip issues, got %d", len(issues)) + } +} + +func TestCheckEmptyLinks_MultipleLinks(t *testing.T) { + input := []byte("[a]() and [b](https://example.com) and [c]()\n") + issues := checkEmptyLinks(input) + if len(issues) != 2 { + t.Fatalf("expected 2 empty link issues, got %d", len(issues)) + } +} + +func TestCheckTrailingWhitespace_Tabs(t *testing.T) { + input := []byte("hello\t\nworld\n") + issues := checkTrailingWhitespace(input) + if len(issues) != 1 { + t.Fatalf("expected 1 trailing whitespace issue, got %d", len(issues)) + } +} + +func TestCheckEmptySections_SingleHeadingWithContent(t *testing.T) { + input := []byte("# Title\n\nSome content here.\n") + issues := checkEmptySections(input) + if len(issues) != 0 { + t.Fatalf("expected no issues, got %d", len(issues)) + } +} + +func TestCheckDuplicateHeadings_DifferentLevels(t *testing.T) { + // Same text but different levels should not be flagged + input := []byte("# Title\n\n## Title\n") + issues := checkDuplicateHeadings(input) + if len(issues) != 0 { + t.Fatalf("expected no issues for different heading levels, got %d", len(issues)) + } +} + +func TestLineNumber(t *testing.T) { + input := []byte("# Title\n\nParagraph\n") + doc := parseAST(input) + line := lineNumber(input, doc.FirstChild()) + if line < 1 { + t.Fatalf("expected positive line number, got %d", line) + } +} diff --git a/internal/markdown/render_test.go b/internal/markdown/render_test.go index 4adef72..e84ac5e 100644 --- a/internal/markdown/render_test.go +++ b/internal/markdown/render_test.go @@ -3,6 +3,7 @@ package markdown import ( "os" "testing" + "testing/iotest" ) func TestRender_SimpleMarkdown(t *testing.T) { @@ -58,3 +59,31 @@ func TestRenderFromReader(t *testing.T) { t.Fatal("expected non-empty rendered output") } } + +func TestRenderFromReader_Error(t *testing.T) { + _, err := RenderFromReader(iotest.ErrReader(os.ErrClosed)) + if err == nil { + t.Fatal("expected error from bad reader") + } +} + +func TestRenderFile_ComplexFixture(t *testing.T) { + result, err := RenderFile("../../testdata/complex.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) == 0 { + t.Fatal("expected non-empty output for complex.md") + } +} + +func TestRender_WithCodeBlock(t *testing.T) { + input := "# Code\n\n```go\nfmt.Println(\"hello\")\n```\n" + result, err := Render(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) == 0 { + t.Fatal("expected non-empty output with code block") + } +} diff --git a/internal/viewer/viewer.go b/internal/viewer/viewer.go index 9240211..f87eaa2 100644 --- a/internal/viewer/viewer.go +++ b/internal/viewer/viewer.go @@ -85,7 +85,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyBackspace: if len(m.searchInput) > 0 { - m.searchInput = m.searchInput[:len(m.searchInput)-1] + runes := []rune(m.searchInput) + m.searchInput = string(runes[:len(runes)-1]) } return m, nil case tea.KeyRunes: diff --git a/internal/viewer/viewer_test.go b/internal/viewer/viewer_test.go index fadfc21..e110540 100644 --- a/internal/viewer/viewer_test.go +++ b/internal/viewer/viewer_test.go @@ -131,21 +131,16 @@ func TestModel_SearchNavigate(t *testing.T) { m.height = 10 m.width = 80 - // Enter search, type, and execute - m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) - model := Model{ - content: m.content, - lines: m.lines, - height: m.height, - width: m.width, - searching: true, - matchIndex: -1, - } + // Enter search mode + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model := updated.(Model) + + // Type query and execute for _, r := range "match" { - updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) model = updated.(Model) } - updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(Model) if len(model.matchLines) != 3 { @@ -245,3 +240,308 @@ func TestModel_SearchViewStatus(t *testing.T) { t.Fatal("expected match count in view") } } + +func TestModel_Init(t *testing.T) { + m := NewModel("hello") + if cmd := m.Init(); cmd != nil { + t.Fatal("Init should return nil") + } +} + +func TestModel_WindowSizeMsg(t *testing.T) { + m := NewModel("hello") + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model := updated.(Model) + if model.width != 120 || model.height != 40 { + t.Fatalf("expected 120x40, got %dx%d", model.width, model.height) + } +} + +func TestModel_CtrlC(t *testing.T) { + m := NewModel("hello") + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + if cmd == nil { + t.Fatal("expected quit command for Ctrl+C") + } +} + +func TestModel_PageDown(t *testing.T) { + lines := make([]string, 30) + for i := range lines { + lines[i] = "line" + } + m := NewModel(strings.Join(lines, "\n")) + m.height = 10 + + // Press 'd' for half-page down + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + model := updated.(Model) + if model.offset == 0 { + t.Fatal("expected offset to increase after page down") + } +} + +func TestModel_PageUp(t *testing.T) { + lines := make([]string, 30) + for i := range lines { + lines[i] = "line" + } + m := NewModel(strings.Join(lines, "\n")) + m.height = 10 + m.offset = 15 + + // Press 'u' for half-page up + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}}) + model := updated.(Model) + if model.offset >= 15 { + t.Fatal("expected offset to decrease after page up") + } +} + +func TestModel_PageUpAtTop(t *testing.T) { + m := NewModel("line 1\nline 2\nline 3") + m.height = 10 + m.offset = 0 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}}) + model := updated.(Model) + if model.offset != 0 { + t.Fatal("expected offset to stay at 0") + } +} + +func TestModel_PageDownClamp(t *testing.T) { + lines := make([]string, 10) + for i := range lines { + lines[i] = "line" + } + m := NewModel(strings.Join(lines, "\n")) + m.height = 10 + m.offset = 5 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + model := updated.(Model) + if model.offset > model.maxOffset() { + t.Fatalf("offset %d should not exceed maxOffset %d", model.offset, model.maxOffset()) + } +} + +func TestModel_GoToTop(t *testing.T) { + lines := make([]string, 20) + for i := range lines { + lines[i] = "line" + } + m := NewModel(strings.Join(lines, "\n")) + m.height = 10 + m.offset = 10 + + // Press 'g' to go to top + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + model := updated.(Model) + if model.offset != 0 { + t.Fatalf("expected offset 0 after g, got %d", model.offset) + } +} + +func TestModel_ScrollDownAtBottom(t *testing.T) { + m := NewModel("one\ntwo") + m.height = 10 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + model := updated.(Model) + if model.offset != 0 { + t.Fatal("expected offset 0 when content fits") + } +} + +func TestModel_ScrollUpAtTop(t *testing.T) { + m := NewModel("one\ntwo") + m.height = 10 + m.offset = 0 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + model := updated.(Model) + if model.offset != 0 { + t.Fatal("expected offset 0 when already at top") + } +} + +func TestModel_SearchBackspace(t *testing.T) { + m := NewModel("hello") + m.height = 10 + m.searching = true + m.searchInput = "tes" + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model := updated.(Model) + if model.searchInput != "te" { + t.Fatalf("expected 'te', got %q", model.searchInput) + } +} + +func TestModel_SearchBackspaceUnicode(t *testing.T) { + m := NewModel("hello") + m.height = 10 + m.searching = true + m.searchInput = "hé" + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model := updated.(Model) + if model.searchInput != "h" { + t.Fatalf("expected 'h' after unicode backspace, got %q", model.searchInput) + } +} + +func TestModel_SearchBackspaceEmpty(t *testing.T) { + m := NewModel("hello") + m.height = 10 + m.searching = true + m.searchInput = "" + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model := updated.(Model) + if model.searchInput != "" { + t.Fatalf("expected empty, got %q", model.searchInput) + } +} + +func TestModel_SearchNoMatch(t *testing.T) { + m := NewModel("hello\nworld") + m.height = 10 + m.width = 80 + m.searching = true + m.searchInput = "zzz" + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model := updated.(Model) + if len(model.matchLines) != 0 { + t.Fatalf("expected 0 matches, got %d", len(model.matchLines)) + } + + // View should show [no matches] + view := model.View() + if !strings.Contains(view, "[no matches]") { + t.Fatal("expected [no matches] in view") + } +} + +func TestModel_SearchUnhandledKey(t *testing.T) { + m := NewModel("hello") + m.height = 10 + m.searching = true + + // Send an unhandled key type while searching + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + model := updated.(Model) + if !model.searching { + t.Fatal("expected still in search mode") + } +} + +func TestModel_SearchNextWrap(t *testing.T) { + m := NewModel("match\nother\nmatch") + m.height = 10 + m.width = 80 + m.searchQuery = "match" + m.matchLines = []int{0, 2} + m.matchIndex = 1 + + // Press 'n' to wrap to first match + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model := updated.(Model) + if model.matchIndex != 0 { + t.Fatalf("expected match index 0 after wrap, got %d", model.matchIndex) + } +} + +func TestModel_NNoMatches(t *testing.T) { + m := NewModel("hello") + m.height = 10 + + // Press 'n' with no matches — should be no-op + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + model := updated.(Model) + if model.matchIndex != -1 { + t.Fatal("expected matchIndex to remain -1") + } +} + +func TestModel_ViewSmallHeight(t *testing.T) { + m := NewModel("hello\nworld") + m.height = 1 + view := m.View() + if view != m.content { + t.Fatal("expected raw content for height <= 1") + } +} + +func TestModel_ViewNormal(t *testing.T) { + m := NewModel("line 1\nline 2\nline 3") + m.height = 5 + m.width = 80 + view := m.View() + if !strings.Contains(view, "line 1") { + t.Fatal("expected line 1 in view") + } + if !strings.Contains(view, "scroll") { + t.Fatal("expected help status in view") + } +} + +func TestModel_UnknownMsg(t *testing.T) { + m := NewModel("hello") + updated, cmd := m.Update("unhandled") + model := updated.(Model) + if cmd != nil { + t.Fatal("expected nil cmd for unknown msg") + } + if model.offset != 0 { + t.Fatal("expected no state change") + } +} + +func TestModel_VimKeys(t *testing.T) { + lines := make([]string, 30) + for i := range lines { + lines[i] = "line" + } + m := NewModel(strings.Join(lines, "\n")) + m.height = 10 + + // 'j' for scroll down + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + model := updated.(Model) + if model.offset != 1 { + t.Fatalf("expected offset 1 after j, got %d", model.offset) + } + + // 'k' for scroll up + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + model = updated.(Model) + if model.offset != 0 { + t.Fatalf("expected offset 0 after k, got %d", model.offset) + } +} + +func TestModel_PgDownPgUp(t *testing.T) { + lines := make([]string, 30) + for i := range lines { + lines[i] = "line" + } + m := NewModel(strings.Join(lines, "\n")) + m.height = 10 + + // PgDown key + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + model := updated.(Model) + if model.offset == 0 { + t.Fatal("expected offset to increase after PgDown") + } + + // PgUp key + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + model = updated.(Model) + if model.offset != 0 { + t.Fatalf("expected offset 0 after PgUp, got %d", model.offset) + } +}