This commit is contained in:
harinder takhar 2026-08-04 18:41:33 +04:00 committed by GitHub
commit d59fb2a9e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1615 additions and 67 deletions

98
tests/e2e_test.go Normal file
View file

@ -0,0 +1,98 @@
package tests
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
var glowBin string
func TestMain(m *testing.M) {
tmp, err := os.MkdirTemp("", "glow-e2e-*")
if err != nil {
panic("failed to create temp dir: " + err.Error())
}
defer os.RemoveAll(tmp)
glowBin = filepath.Join(tmp, "glow-test")
cmd := exec.Command("go", "build", "-o", glowBin, "..")
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
panic("failed to build glow: " + err.Error())
}
os.Exit(m.Run())
}
func TestRenderMarkdownFile(t *testing.T) {
out, err := exec.Command(glowBin, "testdata/test.md").CombinedOutput()
if err != nil {
t.Fatalf("glow testdata/test.md failed: %v\n%s", err, out)
}
if len(out) == 0 {
t.Error("expected non-empty output")
}
}
func TestRenderWithStyle(t *testing.T) {
out, err := exec.Command(glowBin, "-s", "dark", "testdata/test.md").CombinedOutput()
if err != nil {
t.Fatalf("glow -s dark failed: %v\n%s", err, out)
}
if len(out) == 0 {
t.Error("expected non-empty output")
}
}
func TestRenderWithWidth(t *testing.T) {
out, err := exec.Command(glowBin, "-w", "40", "testdata/test.md").CombinedOutput()
if err != nil {
t.Fatalf("glow -w 40 failed: %v\n%s", err, out)
}
if len(out) == 0 {
t.Error("expected non-empty output")
}
}
func TestRenderWithLineNumbers(t *testing.T) {
out, err := exec.Command(glowBin, "-l", "testdata/test.md").CombinedOutput()
if err != nil {
t.Fatalf("glow -l failed: %v\n%s", err, out)
}
if len(out) == 0 {
t.Error("expected non-empty output")
}
}
func TestStdinPipe(t *testing.T) {
cmd := exec.Command(glowBin)
cmd.Stdin = strings.NewReader("# Hello\n\nWorld")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("echo | glow failed: %v\n%s", err, out)
}
if !strings.Contains(string(out), "Hello") {
t.Errorf("expected output to contain 'Hello', got: %s", out)
}
}
func TestInvalidFile(t *testing.T) {
err := exec.Command(glowBin, "nonexistent.md").Run()
if err == nil {
t.Error("expected non-zero exit for nonexistent file")
}
}
func TestHelpFlag(t *testing.T) {
out, err := exec.Command(glowBin, "--help").CombinedOutput()
if err != nil {
t.Fatalf("glow --help failed: %v\n%s", err, out)
}
output := string(out)
if !strings.Contains(strings.ToLower(output), "glow") {
t.Errorf("expected help output to contain 'glow', got: %s", output)
}
}

23
tests/testdata/test.md vendored Normal file
View file

@ -0,0 +1,23 @@
# Test Document
This is a **bold** and *italic* test document.
## Features
- Item one
- Item two
- Item three
## Code Example
```go
package main
import "fmt"
func main() {
fmt.Println("Hello, world!")
}
```
That's all folks.

114
ui/markdown_test.go Normal file
View file

@ -0,0 +1,114 @@
package ui
import (
"testing"
"time"
)
func TestNormalize(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{"diacritics cafe", "café", "cafe", false},
{"diacritics naive", "naïve", "naive", false},
{"diacritics Munchen", "München", "Munchen", false},
{"ASCII unchanged", "hello world", "hello world", false},
{"empty string", "", "", false},
{"mixed diacritics", "résumé.md", "resume.md", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalize(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("normalize() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("normalize(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestRelativeTime(t *testing.T) {
now := time.Now()
tests := []struct {
name string
when time.Time
want string
}{
{
name: "just now",
when: now.Add(-10 * time.Second),
want: "just now",
},
{
name: "minutes ago",
when: now.Add(-5 * time.Minute),
want: "5 minutes ago",
},
{
name: "hours ago",
when: now.Add(-3 * time.Hour),
want: "3 hours ago",
},
{
name: "days ago",
when: now.Add(-2 * 24 * time.Hour),
want: "2 days ago",
},
{
name: "old date uses formatted date",
when: time.Date(2020, 1, 15, 10, 30, 0, 0, time.UTC),
want: "15 Jan 2020 10:30 UTC",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := relativeTime(tt.when)
if got != tt.want {
t.Errorf("relativeTime() = %q, want %q", got, tt.want)
}
})
}
}
func TestBuildFilterValue(t *testing.T) {
tests := []struct {
name string
note string
want string
}{
{
name: "plain text",
note: "readme",
want: "readme",
},
{
name: "diacritics stripped",
note: "café résumé",
want: "cafe resume",
},
{
name: "empty note",
note: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
md := &markdown{Note: tt.note}
md.buildFilterValue()
if md.filterValue != tt.want {
t.Errorf("buildFilterValue() filterValue = %q, want %q", md.filterValue, tt.want)
}
})
}
}

View file

@ -4,10 +4,12 @@ import (
"fmt"
"math"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
@ -87,6 +89,8 @@ type pagerState int
const (
pagerStateBrowse pagerState = iota
pagerStateStatusMessage
pagerStateSearch
pagerStateJumpToLine
)
type pagerModel struct {
@ -102,6 +106,15 @@ type pagerModel struct {
// it here so we can re-render it on resize.
currentDocument markdown
// Search
searchInput textinput.Model
searchQuery string // active search term (persists after input is confirmed)
searchMatches []int // line numbers with matches (0-indexed into raw Body lines)
searchIndex int // current match index (-1 = none)
// Jump to line
lineInput textinput.Model
watcher *fsnotify.Watcher
}
@ -111,10 +124,23 @@ func newPagerModel(common *commonModel) pagerModel {
vp.YPosition = 0
vp.HighPerformanceRendering = config.HighPerformancePager
si := textinput.New()
si.Prompt = "/"
si.PromptStyle = lipgloss.NewStyle().Foreground(yellowGreen)
si.Focus()
li := textinput.New()
li.Prompt = ":"
li.PromptStyle = lipgloss.NewStyle().Foreground(yellowGreen)
li.Focus()
m := pagerModel{
common: common,
state: pagerStateBrowse,
viewport: vp,
common: common,
state: pagerStateBrowse,
viewport: vp,
searchInput: si,
searchIndex: -1,
lineInput: li,
}
m.initWatcher()
return m
@ -164,6 +190,21 @@ func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
return waitForStatusMessageTimeout(pagerContext, m.statusMessageTimer)
}
// inInputMode returns true when the pager is in a state that consumes
// arbitrary key input (search prompt, jump prompt) or has active search
// results that esc should clear before unloading the document.
func (m pagerModel) inInputMode() bool {
return m.state == pagerStateSearch ||
m.state == pagerStateJumpToLine ||
m.searchQuery != ""
}
func (m *pagerModel) clearSearch() {
m.searchQuery = ""
m.searchMatches = nil
m.searchIndex = -1
}
func (m *pagerModel) unload() {
log.Debug("unload")
if m.showHelp {
@ -173,11 +214,26 @@ func (m *pagerModel) unload() {
m.statusMessageTimer.Stop()
}
m.state = pagerStateBrowse
m.clearSearch()
m.viewport.SetContent("")
m.viewport.YOffset = 0
m.unwatchFile()
}
// findMatches finds all line numbers in content that contain the query
// (case-insensitive). Returns 0-indexed line numbers.
func findMatches(content string, query string) []int {
lines := strings.Split(content, "\n")
lowerQuery := strings.ToLower(query)
var matches []int
for i, line := range lines {
if strings.Contains(strings.ToLower(line), lowerQuery) {
matches = append(matches, i)
}
}
return matches
}
func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
var (
cmd tea.Cmd
@ -186,62 +242,22 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", keyEsc:
if m.state != pagerStateBrowse {
m.state = pagerStateBrowse
return m, nil
}
case "home", "g":
m.viewport.GotoTop()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "end", "G":
m.viewport.GotoBottom()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
switch m.state {
case pagerStateSearch:
cmds = append(cmds, m.handleSearchInput(msg))
return m, tea.Batch(cmds...)
case "d":
m.viewport.HalfViewDown()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case pagerStateJumpToLine:
cmds = append(cmds, m.handleJumpInput(msg))
return m, tea.Batch(cmds...)
case "u":
m.viewport.HalfViewUp()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case pagerStateStatusMessage:
// Any key returns to browse
m.state = pagerStateBrowse
return m, nil
case "e":
lineno := int(math.RoundToEven(float64(m.viewport.TotalLineCount()) * m.viewport.ScrollPercent()))
if m.viewport.AtTop() {
lineno = 0
}
log.Info(
"opening editor",
"file", m.currentDocument.localPath,
"line", fmt.Sprintf("%d/%d", lineno, m.viewport.TotalLineCount()),
)
return m, openEditor(m.currentDocument.localPath, lineno)
case "c":
// Copy using OSC 52
termenv.Copy(m.currentDocument.Body)
// Copy using native system clipboard
_ = clipboard.WriteAll(m.currentDocument.Body)
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false}))
case "r":
return m, loadLocalMarkdown(&m.currentDocument)
case "?":
m.toggleHelp()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case pagerStateBrowse:
cmds = append(cmds, m.handleBrowseKeys(msg))
}
// Glow has rendered the content
@ -270,7 +286,11 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
return m, renderWithGlamour(m, m.currentDocument.Body)
case statusMessageTimeoutMsg:
m.state = pagerStateBrowse
// Only transition to browse if we're actually showing a status message.
// Ignore if in search/jump input mode.
if m.state == pagerStateStatusMessage {
m.state = pagerStateBrowse
}
}
m.viewport, cmd = m.viewport.Update(msg)
@ -279,6 +299,207 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *pagerModel) handleBrowseKeys(msg tea.KeyMsg) tea.Cmd {
var cmds []tea.Cmd
switch msg.String() {
case keyEsc:
// If search results are active, clear them
if m.searchQuery != "" {
m.clearSearch()
return nil
}
case "home", "g":
m.viewport.GotoTop()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "end", "G":
m.viewport.GotoBottom()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "d":
m.viewport.HalfViewDown()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "u":
m.viewport.HalfViewUp()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "right":
m.viewport.ViewDown()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "left":
m.viewport.ViewUp()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "e":
lineno := int(math.RoundToEven(float64(m.viewport.TotalLineCount()) * m.viewport.ScrollPercent()))
if m.viewport.AtTop() {
lineno = 0
}
log.Info(
"opening editor",
"file", m.currentDocument.localPath,
"line", fmt.Sprintf("%d/%d", lineno, m.viewport.TotalLineCount()),
)
return openEditor(m.currentDocument.localPath, lineno)
case "c":
// Copy using OSC 52
termenv.Copy(m.currentDocument.Body)
// Copy using native system clipboard
_ = clipboard.WriteAll(m.currentDocument.Body)
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false}))
case "r":
return loadLocalMarkdown(&m.currentDocument)
case "?":
m.toggleHelp()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "/":
m.state = pagerStateSearch
m.searchInput.SetValue("")
m.searchInput.Focus()
return textinput.Blink
case ":":
m.state = pagerStateJumpToLine
m.lineInput.SetValue("")
m.lineInput.Focus()
return textinput.Blink
case "n":
if m.searchQuery != "" && len(m.searchMatches) > 0 {
m.searchIndex++
if m.searchIndex >= len(m.searchMatches) {
m.searchIndex = 0
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"search wrapped", false}))
}
m.viewport.SetYOffset(m.searchMatches[m.searchIndex])
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
}
case "N":
if m.searchQuery != "" && len(m.searchMatches) > 0 {
m.searchIndex--
if m.searchIndex < 0 {
m.searchIndex = len(m.searchMatches) - 1
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"search wrapped", false}))
}
m.viewport.SetYOffset(m.searchMatches[m.searchIndex])
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
}
}
return tea.Batch(cmds...)
}
func (m *pagerModel) handleSearchInput(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case keyEnter:
query := m.searchInput.Value()
if query == "" {
m.state = pagerStateBrowse
return m.showStatusMessage(pagerStatusMessage{"no pattern", false})
}
m.searchQuery = query
m.searchMatches = findMatches(m.currentDocument.Body, query)
if len(m.searchMatches) == 0 {
m.searchQuery = ""
m.state = pagerStateBrowse
return m.showStatusMessage(pagerStatusMessage{"no matches", false})
}
m.searchIndex = 0
m.viewport.SetYOffset(m.searchMatches[0])
m.state = pagerStateBrowse
if m.viewport.HighPerformanceRendering {
return viewport.Sync(m.viewport)
}
return nil
case keyEsc:
m.clearSearch()
m.state = pagerStateBrowse
return nil
}
// Delegate to the text input
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
return cmd
}
func (m *pagerModel) handleJumpInput(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case keyEnter:
input := m.lineInput.Value()
m.state = pagerStateBrowse
if input == "" {
return nil
}
// Check for percentage jump (e.g., "50%")
if strings.HasSuffix(input, "%") {
pct, err := strconv.Atoi(strings.TrimSuffix(input, "%"))
if err != nil {
return m.showStatusMessage(pagerStatusMessage{"invalid number", false})
}
pct = max(0, min(100, pct))
if pct == 0 {
m.viewport.GotoTop()
} else if pct == 100 {
m.viewport.GotoBottom()
} else {
totalLines := m.viewport.TotalLineCount()
target := int(math.Round(float64(totalLines) * float64(pct) / 100))
m.viewport.SetYOffset(target)
}
} else {
n, err := strconv.Atoi(input)
if err != nil {
return m.showStatusMessage(pagerStatusMessage{"invalid line number", false})
}
n = max(1, min(n, m.viewport.TotalLineCount()))
m.viewport.SetYOffset(n - 1) // convert 1-indexed to 0-indexed
}
if m.viewport.HighPerformanceRendering {
return viewport.Sync(m.viewport)
}
return nil
case keyEsc:
m.state = pagerStateBrowse
return nil
}
// Delegate to the text input
var cmd tea.Cmd
m.lineInput, cmd = m.lineInput.Update(msg)
return cmd
}
func (m pagerModel) View() string {
var b strings.Builder
fmt.Fprint(&b, m.viewport.View()+"\n")
@ -294,6 +515,26 @@ func (m pagerModel) View() string {
}
func (m pagerModel) statusBarView(b *strings.Builder) {
// When in search or jump input mode, replace the entire status bar
// with the input prompt (like less/vim).
if m.state == pagerStateSearch {
fmt.Fprint(b, m.searchInput.View())
// Pad to full width
inputWidth := ansi.PrintableRuneWidth(m.searchInput.View())
if pad := m.common.width - inputWidth; pad > 0 {
fmt.Fprint(b, statusBarNoteStyle(strings.Repeat(" ", pad)))
}
return
}
if m.state == pagerStateJumpToLine {
fmt.Fprint(b, m.lineInput.View())
inputWidth := ansi.PrintableRuneWidth(m.lineInput.View())
if pad := m.common.width - inputWidth; pad > 0 {
fmt.Fprint(b, statusBarNoteStyle(strings.Repeat(" ", pad)))
}
return
}
const (
minPercent float64 = 0.0
maxPercent float64 = 1.0
@ -305,6 +546,32 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
// Logo
logo := glowLogoView()
// Page indicator
var pageIndicator string
viewHeight := max(1, m.viewport.Height)
currentPage := m.viewport.YOffset/viewHeight + 1
totalPages := (m.viewport.TotalLineCount() + viewHeight - 1) / viewHeight
currentPage = min(currentPage, totalPages)
if totalPages > 1 {
pageIndicator = fmt.Sprintf(" pg %d/%d ", currentPage, totalPages)
}
if showStatusMessage {
pageIndicator = statusBarMessageScrollPosStyle(pageIndicator)
} else {
pageIndicator = statusBarScrollPosStyle(pageIndicator)
}
// Match counter (when search results are active)
var matchCounter string
if m.searchQuery != "" && len(m.searchMatches) > 0 {
matchCounter = fmt.Sprintf(" %d/%d ", m.searchIndex+1, len(m.searchMatches))
}
if showStatusMessage {
matchCounter = statusBarMessageScrollPosStyle(matchCounter)
} else {
matchCounter = statusBarScrollPosStyle(matchCounter)
}
// Scroll percent
percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
scrollPercent := fmt.Sprintf(" %3.f%% ", percent*percentToStringMagnitude)
@ -332,6 +599,8 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
note = truncate.StringWithTail(" "+note+" ", uint(max(0, //nolint:gosec
m.common.width-
ansi.PrintableRuneWidth(logo)-
ansi.PrintableRuneWidth(matchCounter)-
ansi.PrintableRuneWidth(pageIndicator)-
ansi.PrintableRuneWidth(scrollPercent)-
ansi.PrintableRuneWidth(helpNote),
)), ellipsis)
@ -346,6 +615,8 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
m.common.width-
ansi.PrintableRuneWidth(logo)-
ansi.PrintableRuneWidth(note)-
ansi.PrintableRuneWidth(matchCounter)-
ansi.PrintableRuneWidth(pageIndicator)-
ansi.PrintableRuneWidth(scrollPercent)-
ansi.PrintableRuneWidth(helpNote),
)
@ -356,10 +627,12 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
emptySpace = statusBarNoteStyle(emptySpace)
}
fmt.Fprintf(b, "%s%s%s%s%s",
fmt.Fprintf(b, "%s%s%s%s%s%s%s",
logo,
note,
emptySpace,
matchCounter,
pageIndicator,
scrollPercent,
helpNote,
)
@ -369,6 +642,9 @@ func (m pagerModel) helpView() (s string) {
col1 := []string{
"g/home go to top",
"G/end go to bottom",
"/ search",
"n/N next/prev match",
": jump to line/pct",
"c copy contents",
"e edit this document",
"r reload this document",
@ -381,11 +657,14 @@ func (m pagerModel) helpView() (s string) {
s += "j/↓ down " + col1[1] + "\n"
s += "b/pgup page up " + col1[2] + "\n"
s += "f/pgdn page down " + col1[3] + "\n"
s += "u ½ page up " + col1[4] + "\n"
s += "d ½ page down "
s += "←/→ page back/fwd " + col1[4] + "\n"
s += "u ½ page up " + col1[5] + "\n"
s += "d ½ page down " + col1[6] + "\n"
s += " " + col1[7] + "\n"
s += " " + col1[8]
if len(col1) > 5 {
s += col1[5]
if len(col1) > 9 {
s += "\n " + col1[9]
}
s = indent(s, 2)

453
ui/pager_test.go Normal file
View file

@ -0,0 +1,453 @@
package ui
import (
"strings"
"testing"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
func testPagerModel(width, height int, cfg Config) pagerModel {
config = cfg
common := &commonModel{cfg: cfg, width: width, height: height}
vp := viewport.New(width, height)
return pagerModel{
common: common,
viewport: vp,
state: pagerStateBrowse,
}
}
func TestGlamourRender(t *testing.T) {
savedConfig := config
t.Cleanup(func() { config = savedConfig })
t.Run("GlamourEnabled false returns raw markdown", func(t *testing.T) {
cfg := Config{GlamourEnabled: false}
m := testPagerModel(80, 24, cfg)
input := "# Hello\n\nWorld"
got, err := glamourRender(m, input)
if err != nil {
t.Fatalf("glamourRender() error: %v", err)
}
if got != input {
t.Errorf("glamourRender() = %q, want %q", got, input)
}
})
t.Run("markdown file renders non-empty", func(t *testing.T) {
cfg := Config{
GlamourEnabled: true,
GlamourStyle: "dark",
GlamourMaxWidth: 80,
}
m := testPagerModel(80, 24, cfg)
m.currentDocument = markdown{Note: "test.md"}
got, err := glamourRender(m, "# Hello\n\nWorld")
if err != nil {
t.Fatalf("glamourRender() error: %v", err)
}
if got == "" {
t.Error("glamourRender() returned empty string for markdown")
}
})
t.Run("code file wraps in code block", func(t *testing.T) {
cfg := Config{
GlamourEnabled: true,
GlamourStyle: "dark",
GlamourMaxWidth: 80,
}
m := testPagerModel(80, 24, cfg)
m.currentDocument = markdown{Note: "main.go"}
got, err := glamourRender(m, "package main\n")
if err != nil {
t.Fatalf("glamourRender() error: %v", err)
}
if got == "" {
t.Error("glamourRender() returned empty for code file")
}
})
t.Run("ShowLineNumbers adds prefixes", func(t *testing.T) {
cfg := Config{
GlamourEnabled: true,
GlamourStyle: "dark",
GlamourMaxWidth: 80,
ShowLineNumbers: true,
}
m := testPagerModel(80, 24, cfg)
m.currentDocument = markdown{Note: "test.md"}
got, err := glamourRender(m, "# Hello")
if err != nil {
t.Fatalf("glamourRender() error: %v", err)
}
// Line numbers should be present - look for the number 1
if !strings.Contains(got, "1") {
t.Errorf("glamourRender() with ShowLineNumbers should contain line numbers, got: %q", got)
}
})
t.Run("code files always get line numbers", func(t *testing.T) {
cfg := Config{
GlamourEnabled: true,
GlamourStyle: "dark",
GlamourMaxWidth: 80,
ShowLineNumbers: false, // explicitly false
}
m := testPagerModel(80, 24, cfg)
m.currentDocument = markdown{Note: "main.go"}
got, err := glamourRender(m, "package main\n")
if err != nil {
t.Fatalf("glamourRender() error: %v", err)
}
// Code files always get line numbers regardless of ShowLineNumbers
if !strings.Contains(got, "1") {
t.Errorf("glamourRender() code file should have line numbers, got: %q", got)
}
})
}
func TestStatusBarView(t *testing.T) {
savedConfig := config
t.Cleanup(func() { config = savedConfig })
t.Run("browse state shows Note", func(t *testing.T) {
cfg := Config{}
m := testPagerModel(80, 24, cfg)
m.currentDocument = markdown{Note: "myfile.md"}
m.state = pagerStateBrowse
var b strings.Builder
m.statusBarView(&b)
got := b.String()
if !strings.Contains(got, "myfile.md") {
t.Errorf("statusBarView() in browse should contain Note, got: %q", got)
}
})
t.Run("status message state shows message", func(t *testing.T) {
cfg := Config{}
m := testPagerModel(80, 24, cfg)
m.state = pagerStateStatusMessage
m.statusMessage = "Copied contents"
var b strings.Builder
m.statusBarView(&b)
got := b.String()
if !strings.Contains(got, "Copied contents") {
t.Errorf("statusBarView() should contain status message, got: %q", got)
}
})
t.Run("narrow width no panic", func(t *testing.T) {
cfg := Config{}
m := testPagerModel(10, 5, cfg)
m.currentDocument = markdown{Note: "a-very-long-filename-that-exceeds-width.md"}
m.state = pagerStateBrowse
var b strings.Builder
// Should not panic
m.statusBarView(&b)
})
t.Run("zero width no panic", func(t *testing.T) {
cfg := Config{}
m := testPagerModel(0, 0, cfg)
m.state = pagerStateBrowse
var b strings.Builder
m.statusBarView(&b)
})
}
func TestHelpView(t *testing.T) {
savedConfig := config
t.Cleanup(func() { config = savedConfig })
cfg := Config{}
m := testPagerModel(80, 24, cfg)
got := m.helpView()
if got == "" {
t.Error("helpView() returned empty string")
}
expectedBindings := []string{"g/home", "G/end", "esc"}
for _, binding := range expectedBindings {
if !strings.Contains(got, binding) {
t.Errorf("helpView() should contain %q", binding)
}
}
}
func TestSetSize(t *testing.T) {
savedConfig := config
t.Cleanup(func() { config = savedConfig })
t.Run("viewport dimensions correct", func(t *testing.T) {
cfg := Config{}
m := testPagerModel(80, 24, cfg)
m.setSize(100, 30)
if m.viewport.Width != 100 {
t.Errorf("viewport.Width = %d, want 100", m.viewport.Width)
}
wantHeight := 30 - statusBarHeight
if m.viewport.Height != wantHeight {
t.Errorf("viewport.Height = %d, want %d", m.viewport.Height, wantHeight)
}
})
t.Run("showHelp reduces height", func(t *testing.T) {
cfg := Config{}
m := testPagerModel(80, 24, cfg)
m.setSize(80, 40)
heightWithoutHelp := m.viewport.Height
m.showHelp = true
pagerHelpHeight = 0 // reset so it recalculates
m.setSize(80, 40)
heightWithHelp := m.viewport.Height
if heightWithHelp >= heightWithoutHelp {
t.Errorf("showHelp should reduce viewport height: withHelp=%d, withoutHelp=%d",
heightWithHelp, heightWithoutHelp)
}
})
}
func TestLocalDir(t *testing.T) {
m := pagerModel{
currentDocument: markdown{localPath: "/home/user/docs/readme.md"},
}
got := m.localDir()
want := "/home/user/docs"
if got != want {
t.Errorf("localDir() = %q, want %q", got, want)
}
}
func TestFindMatches(t *testing.T) {
content := "Hello World\nfoo bar\nHello again\nbaz\nhello lower"
t.Run("basic match", func(t *testing.T) {
matches := findMatches(content, "Hello")
// Case-insensitive: should match lines 0, 2, 4
if len(matches) != 3 {
t.Errorf("findMatches() returned %d matches, want 3", len(matches))
}
if matches[0] != 0 || matches[1] != 2 || matches[2] != 4 {
t.Errorf("findMatches() = %v, want [0, 2, 4]", matches)
}
})
t.Run("case insensitive", func(t *testing.T) {
matches := findMatches(content, "hello")
if len(matches) != 3 {
t.Errorf("findMatches() returned %d matches, want 3", len(matches))
}
})
t.Run("no matches", func(t *testing.T) {
matches := findMatches(content, "xyz")
if len(matches) != 0 {
t.Errorf("findMatches() returned %d matches, want 0", len(matches))
}
})
t.Run("empty query matches all lines", func(t *testing.T) {
matches := findMatches(content, "")
if len(matches) != 5 {
t.Errorf("findMatches() returned %d matches, want 5", len(matches))
}
})
t.Run("single line match", func(t *testing.T) {
matches := findMatches(content, "baz")
if len(matches) != 1 {
t.Errorf("findMatches() returned %d matches, want 1", len(matches))
}
if matches[0] != 3 {
t.Errorf("findMatches()[0] = %d, want 3", matches[0])
}
})
}
func TestInInputMode(t *testing.T) {
t.Run("browse state no query", func(t *testing.T) {
m := pagerModel{state: pagerStateBrowse}
if m.inInputMode() {
t.Error("inInputMode() should be false in browse with no query")
}
})
t.Run("search state", func(t *testing.T) {
m := pagerModel{state: pagerStateSearch}
if !m.inInputMode() {
t.Error("inInputMode() should be true in search state")
}
})
t.Run("jump state", func(t *testing.T) {
m := pagerModel{state: pagerStateJumpToLine}
if !m.inInputMode() {
t.Error("inInputMode() should be true in jump state")
}
})
t.Run("browse with active search query", func(t *testing.T) {
m := pagerModel{state: pagerStateBrowse, searchQuery: "foo"}
if !m.inInputMode() {
t.Error("inInputMode() should be true when searchQuery is set")
}
})
t.Run("status message no query", func(t *testing.T) {
m := pagerModel{state: pagerStateStatusMessage}
if m.inInputMode() {
t.Error("inInputMode() should be false in status message state with no query")
}
})
}
func TestPageIndicator(t *testing.T) {
savedConfig := config
t.Cleanup(func() { config = savedConfig })
t.Run("multi-page document shows indicator", func(t *testing.T) {
cfg := Config{}
m := testPagerModel(80, 10, cfg)
// Set content with many lines
lines := strings.Repeat("line\n", 50)
m.viewport.SetContent(lines)
var b strings.Builder
m.statusBarView(&b)
got := b.String()
if !strings.Contains(got, "pg") {
t.Errorf("statusBarView() should contain page indicator for multi-page doc, got: %q", got)
}
})
t.Run("single-page document no indicator", func(t *testing.T) {
cfg := Config{}
m := testPagerModel(80, 50, cfg)
m.viewport.SetContent("short content")
var b strings.Builder
m.statusBarView(&b)
got := b.String()
// "pg" should not appear for single-page content
if strings.Contains(got, " pg ") {
t.Errorf("statusBarView() should not contain page indicator for single-page doc")
}
})
}
func TestSearchMatchCounter(t *testing.T) {
savedConfig := config
t.Cleanup(func() { config = savedConfig })
cfg := Config{}
m := testPagerModel(80, 24, cfg)
m.searchQuery = "test"
m.searchMatches = []int{0, 5, 10}
m.searchIndex = 1
var b strings.Builder
m.statusBarView(&b)
got := b.String()
if !strings.Contains(got, "2/3") {
t.Errorf("statusBarView() should contain match counter '2/3', got: %q", got)
}
}
func TestClearSearch(t *testing.T) {
m := pagerModel{
searchQuery: "test",
searchMatches: []int{1, 2, 3},
searchIndex: 1,
}
m.clearSearch()
if m.searchQuery != "" {
t.Errorf("clearSearch() should clear searchQuery, got %q", m.searchQuery)
}
if m.searchMatches != nil {
t.Errorf("clearSearch() should clear searchMatches, got %v", m.searchMatches)
}
if m.searchIndex != -1 {
t.Errorf("clearSearch() should set searchIndex to -1, got %d", m.searchIndex)
}
}
func TestHelpViewContainsSearchAndJump(t *testing.T) {
savedConfig := config
t.Cleanup(func() { config = savedConfig })
cfg := Config{}
m := testPagerModel(80, 24, cfg)
got := m.helpView()
expectedBindings := []string{"/", "n/N", ":", "←/→"}
for _, binding := range expectedBindings {
if !strings.Contains(got, binding) {
t.Errorf("helpView() should contain %q", binding)
}
}
}
func TestArrowKeyPaging(t *testing.T) {
savedConfig := config
t.Cleanup(func() { config = savedConfig })
cfg := Config{}
m := testPagerModel(80, 10, cfg)
// 50 lines of content, viewport height 10 → multiple pages
m.viewport.SetContent(strings.Repeat("line\n", 50))
t.Run("right arrow pages forward", func(t *testing.T) {
m.viewport.GotoTop()
before := m.viewport.YOffset
m.handleBrowseKeys(tea.KeyMsg{Type: tea.KeyRight})
if m.viewport.YOffset <= before {
t.Errorf("right arrow should page forward: before=%d, after=%d", before, m.viewport.YOffset)
}
})
t.Run("left arrow pages backward", func(t *testing.T) {
// Start partway down
m.viewport.SetYOffset(20)
before := m.viewport.YOffset
m.handleBrowseKeys(tea.KeyMsg{Type: tea.KeyLeft})
if m.viewport.YOffset >= before {
t.Errorf("left arrow should page backward: before=%d, after=%d", before, m.viewport.YOffset)
}
})
t.Run("left arrow at top stays at top", func(t *testing.T) {
m.viewport.GotoTop()
m.handleBrowseKeys(tea.KeyMsg{Type: tea.KeyLeft})
if m.viewport.YOffset != 0 {
t.Errorf("left arrow at top should stay at 0, got %d", m.viewport.YOffset)
}
})
}

61
ui/sort_test.go Normal file
View file

@ -0,0 +1,61 @@
package ui
import (
"testing"
)
func TestSortMarkdowns(t *testing.T) {
tests := []struct {
name string
notes []string
wantNotes []string
}{
{
name: "alphabetical sort",
notes: []string{"cherry", "apple", "banana"},
wantNotes: []string{"apple", "banana", "cherry"},
},
{
name: "empty slice",
notes: []string{},
wantNotes: []string{},
},
{
name: "single item",
notes: []string{"only"},
wantNotes: []string{"only"},
},
{
name: "already sorted",
notes: []string{"a", "b", "c"},
wantNotes: []string{"a", "b", "c"},
},
{
name: "reverse order",
notes: []string{"c", "b", "a"},
wantNotes: []string{"a", "b", "c"},
},
{
name: "duplicate notes stable",
notes: []string{"b", "a", "b", "a"},
wantNotes: []string{"a", "a", "b", "b"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mds := make([]*markdown, len(tt.notes))
for i, n := range tt.notes {
mds[i] = &markdown{Note: n}
}
sortMarkdowns(mds)
for i, md := range mds {
if md.Note != tt.wantNotes[i] {
t.Errorf("index %d: got Note=%q, want %q", i, md.Note, tt.wantNotes[i])
}
}
})
}
}

311
ui/stash_test.go Normal file
View file

@ -0,0 +1,311 @@
package ui
import (
"testing"
"github.com/charmbracelet/bubbles/textinput"
)
func testStashModel(numMarkdowns, perPage int) stashModel {
initSections()
common := &commonModel{
cfg: Config{},
width: 80,
height: 40,
}
si := textinput.New()
si.Prompt = "Find:"
s := []section{
sections[documentsSection],
}
// Set PerPage so we control pagination
s[0].paginator.PerPage = perPage
mds := make([]*markdown, numMarkdowns)
for i := range mds {
mds[i] = &markdown{
Note: string(rune('a' + i%26)),
}
mds[i].buildFilterValue()
}
m := stashModel{
common: common,
filterInput: si,
sections: s,
markdowns: mds,
}
// Set total pages based on markdowns
if numMarkdowns > 0 {
m.paginator().SetTotalPages(numMarkdowns)
} else {
m.paginator().SetTotalPages(1)
}
return m
}
func TestMoveCursorUp(t *testing.T) {
t.Run("at top of first page stays 0", func(t *testing.T) {
m := testStashModel(10, 5)
m.setCursor(0)
m.paginator().Page = 0
m.moveCursorUp()
if m.cursor() != 0 {
t.Errorf("cursor = %d, want 0", m.cursor())
}
})
t.Run("middle decrements", func(t *testing.T) {
m := testStashModel(10, 5)
m.setCursor(3)
m.moveCursorUp()
if m.cursor() != 2 {
t.Errorf("cursor = %d, want 2", m.cursor())
}
})
t.Run("top of page 2 goes to prev page", func(t *testing.T) {
m := testStashModel(10, 5)
m.paginator().Page = 1
m.setCursor(0)
m.moveCursorUp()
if m.paginator().Page != 0 {
t.Errorf("page = %d, want 0", m.paginator().Page)
}
// Cursor should be at last item of previous page
if m.cursor() < 0 {
t.Errorf("cursor = %d, should be >= 0", m.cursor())
}
})
}
func TestMoveCursorDown(t *testing.T) {
t.Run("middle increments", func(t *testing.T) {
m := testStashModel(10, 5)
m.setCursor(2)
m.moveCursorDown()
if m.cursor() != 3 {
t.Errorf("cursor = %d, want 3", m.cursor())
}
})
t.Run("bottom of non-last page goes to next page", func(t *testing.T) {
m := testStashModel(10, 5)
m.setCursor(4) // last item on page (0-indexed, perPage=5)
m.moveCursorDown()
if m.paginator().Page != 1 {
t.Errorf("page = %d, want 1", m.paginator().Page)
}
if m.cursor() != 0 {
t.Errorf("cursor = %d, want 0", m.cursor())
}
})
t.Run("bottom of last page stays", func(t *testing.T) {
m := testStashModel(5, 5)
m.setCursor(4) // last item, only one page
m.moveCursorDown()
if m.cursor() != 4 {
t.Errorf("cursor = %d, want 4", m.cursor())
}
})
}
func TestUpdatePagination(t *testing.T) {
t.Run("correct page count", func(t *testing.T) {
m := testStashModel(10, 5)
m.updatePagination()
if m.paginator().TotalPages < 1 {
t.Errorf("TotalPages = %d, want >= 1", m.paginator().TotalPages)
}
})
t.Run("empty markdowns gives 1 page", func(t *testing.T) {
m := testStashModel(0, 5)
m.updatePagination()
if m.paginator().TotalPages != 1 {
t.Errorf("TotalPages = %d, want 1", m.paginator().TotalPages)
}
})
}
func TestFilterMarkdowns(t *testing.T) {
t.Run("no filter returns all", func(t *testing.T) {
m := testStashModel(5, 5)
m.filterState = unfiltered
cmd := filterMarkdowns(m)
msg := cmd()
filtered, ok := msg.(filteredMarkdownMsg)
if !ok {
t.Fatalf("expected filteredMarkdownMsg, got %T", msg)
}
if len(filtered) != 5 {
t.Errorf("len(filtered) = %d, want 5", len(filtered))
}
})
t.Run("fuzzy match", func(t *testing.T) {
initSections()
common := &commonModel{cfg: Config{}, width: 80, height: 40}
si := textinput.New()
si.Prompt = "Find:"
si.SetValue("a")
mds := []*markdown{
{Note: "apple", filterValue: "apple"},
{Note: "banana", filterValue: "banana"},
{Note: "avocado", filterValue: "avocado"},
}
m := stashModel{
common: common,
filterInput: si,
filterState: filtering,
markdowns: mds,
sections: []section{sections[documentsSection]},
}
cmd := filterMarkdowns(m)
msg := cmd()
filtered, ok := msg.(filteredMarkdownMsg)
if !ok {
t.Fatalf("expected filteredMarkdownMsg, got %T", msg)
}
if len(filtered) == 0 {
t.Error("expected some fuzzy matches, got 0")
}
})
t.Run("no matches returns empty", func(t *testing.T) {
initSections()
common := &commonModel{cfg: Config{}, width: 80, height: 40}
si := textinput.New()
si.Prompt = "Find:"
si.SetValue("zzzzzzz")
mds := []*markdown{
{Note: "apple", filterValue: "apple"},
{Note: "banana", filterValue: "banana"},
}
m := stashModel{
common: common,
filterInput: si,
filterState: filtering,
markdowns: mds,
sections: []section{sections[documentsSection]},
}
cmd := filterMarkdowns(m)
msg := cmd()
filtered, ok := msg.(filteredMarkdownMsg)
if !ok {
t.Fatalf("expected filteredMarkdownMsg, got %T", msg)
}
if len(filtered) != 0 {
t.Errorf("len(filtered) = %d, want 0", len(filtered))
}
})
}
func TestSelectedMarkdown(t *testing.T) {
t.Run("valid cursor returns correct markdown", func(t *testing.T) {
m := testStashModel(5, 5)
m.setCursor(2)
md := m.selectedMarkdown()
if md == nil {
t.Fatal("selectedMarkdown() returned nil")
}
if md != m.markdowns[2] {
t.Error("selectedMarkdown() returned wrong markdown")
}
})
t.Run("empty list returns nil", func(t *testing.T) {
m := testStashModel(0, 5)
md := m.selectedMarkdown()
if md != nil {
t.Errorf("selectedMarkdown() = %v, want nil", md)
}
})
}
func TestGetVisibleMarkdowns(t *testing.T) {
t.Run("not filtering returns markdowns", func(t *testing.T) {
m := testStashModel(5, 5)
m.filterState = unfiltered
got := m.getVisibleMarkdowns()
if len(got) != 5 {
t.Errorf("len(getVisibleMarkdowns()) = %d, want 5", len(got))
}
})
t.Run("filtering returns filteredMarkdowns", func(t *testing.T) {
m := testStashModel(5, 5)
m.filterState = filtering
m.filteredMarkdowns = []*markdown{
{Note: "filtered1"},
{Note: "filtered2"},
}
got := m.getVisibleMarkdowns()
if len(got) != 2 {
t.Errorf("len(getVisibleMarkdowns()) = %d, want 2", len(got))
}
})
}
func TestMarkdownIndex(t *testing.T) {
tests := []struct {
name string
page int
cursor int
perPage int
want int
}{
{"page 0 cursor 2 perPage 5", 0, 2, 5, 2},
{"page 1 cursor 3 perPage 5", 1, 3, 5, 8},
{"page 0 cursor 0 perPage 10", 0, 0, 10, 0},
{"page 2 cursor 1 perPage 3", 2, 1, 3, 7},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := testStashModel(20, tt.perPage)
m.paginator().Page = tt.page
m.paginator().PerPage = tt.perPage
m.setCursor(tt.cursor)
got := m.markdownIndex()
if got != tt.want {
t.Errorf("markdownIndex() = %d, want %d", got, tt.want)
}
})
}
}

View file

@ -216,6 +216,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
if m.state == stateShowDocument && m.pager.inInputMode() {
break // let pager handle (cancel search/jump, or clear results)
}
if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument {
batch := m.unloadDocument()
return m, tea.Batch(batch...)
@ -233,6 +236,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "q":
if m.state == stateShowDocument && m.pager.inInputMode() {
break // let pager handle (typing 'q' in search input, or clearing search)
}
var cmd tea.Cmd
switch m.state { //nolint:exhaustive
@ -246,7 +253,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
case "left", "h", "delete":
case "h", "delete":
if m.state == stateShowDocument && m.pager.inInputMode() {
break // let pager textinput handle cursor/delete
}
if m.state == stateShowDocument {
cmds = append(cmds, m.unloadDocument()...)
return m, tea.Batch(cmds...)

199
utils/utils_test.go Normal file
View file

@ -0,0 +1,199 @@
package utils
import (
"os"
"path/filepath"
"testing"
)
func TestRemoveFrontmatter(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "YAML frontmatter stripped",
input: "---\ntitle: hello\n---\n# Body",
want: "# Body",
},
{
name: "no frontmatter unchanged",
input: "# Just a heading\nSome text",
want: "# Just a heading\nSome text",
},
{
name: "empty input",
input: "",
want: "",
},
{
name: "single delimiter not stripped",
input: "---\nno closing delimiter",
want: "---\nno closing delimiter",
},
{
name: "frontmatter only at position 0",
input: "some text\n---\ntitle: hello\n---\nbody",
want: "some text\n---\ntitle: hello\n---\nbody",
},
{
name: "frontmatter with blank line",
input: "---\n\ntitle: hello\n---\n# Body",
want: "# Body",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := string(RemoveFrontmatter([]byte(tt.input)))
if got != tt.want {
t.Errorf("RemoveFrontmatter() = %q, want %q", got, tt.want)
}
})
}
}
func TestIsMarkdownFile(t *testing.T) {
tests := []struct {
name string
filename string
want bool
}{
{"md extension", "README.md", true},
{"mdown extension", "file.mdown", true},
{"mkdn extension", "file.mkdn", true},
{"mkd extension", "file.mkd", true},
{"markdown extension", "file.markdown", true},
{"go extension", "main.go", false},
{"txt extension", "notes.txt", false},
{"rs extension", "lib.rs", false},
{"no extension", "Makefile", true},
{"case insensitive MD", "README.MD", true},
{"case insensitive Md", "file.Md", true},
{"multi-dot md", "file.tar.md", true},
{"multi-dot go", "file.test.go", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsMarkdownFile(tt.filename)
if got != tt.want {
t.Errorf("IsMarkdownFile(%q) = %v, want %v", tt.filename, got, tt.want)
}
})
}
}
func TestWrapCodeBlock(t *testing.T) {
tests := []struct {
name string
s string
language string
want string
}{
{
name: "normal wrap",
s: "fmt.Println(\"hello\")\n",
language: "go",
want: "```go\nfmt.Println(\"hello\")\n```",
},
{
name: "empty string",
s: "",
language: "go",
want: "```go\n```",
},
{
name: "empty language",
s: "some code\n",
language: "",
want: "```\nsome code\n```",
},
{
name: "multiline",
s: "line1\nline2\nline3\n",
language: "python",
want: "```python\nline1\nline2\nline3\n```",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := WrapCodeBlock(tt.s, tt.language)
if got != tt.want {
t.Errorf("WrapCodeBlock() = %q, want %q", got, tt.want)
}
})
}
}
func TestExpandPath(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
path string
want string
}{
{
name: "tilde expansion",
path: "~/foo",
want: filepath.Join(home, "foo"),
},
{
name: "absolute unchanged",
path: "/usr/local/bin",
want: "/usr/local/bin",
},
{
name: "empty string",
path: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExpandPath(tt.path)
if got != tt.want {
t.Errorf("ExpandPath(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
t.Run("env var expansion", func(t *testing.T) {
t.Setenv("GLOW_TEST_DIR", "/tmp/glowtest")
got := ExpandPath("$GLOW_TEST_DIR/foo")
want := "/tmp/glowtest/foo"
if got != want {
t.Errorf("ExpandPath($GLOW_TEST_DIR/foo) = %q, want %q", got, want)
}
})
}
func TestGlamourStyle(t *testing.T) {
tests := []struct {
name string
style string
isCode bool
}{
{"dark style", "dark", false},
{"light style", "light", false},
{"notty style", "notty", false},
{"dark style isCode", "dark", true},
{"light style isCode", "light", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opt := GlamourStyle(tt.style, tt.isCode)
if opt == nil {
t.Errorf("GlamourStyle(%q, %v) returned nil", tt.style, tt.isCode)
}
})
}
}