Something went wrong. Try again.
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm leaflet readability golang
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912package ui
import ( "bytes" "context" "errors" "fmt" "strings" "testing" "time"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea")
type MockDataRecord struct { fields map[string]any}
func (m MockDataRecord) GetID() int64 { return 1 }func (m MockDataRecord) SetID(id int64) {}func (m MockDataRecord) GetTableName() string { return "mock_records" }func (m MockDataRecord) GetCreatedAt() time.Time { return time.Time{} }func (m MockDataRecord) SetCreatedAt(t time.Time) {}func (m MockDataRecord) GetUpdatedAt() time.Time { return time.Time{} }func (m MockDataRecord) SetUpdatedAt(t time.Time) {}func (m MockDataRecord) GetField(name string) any { return m.fields[name] }
func NewMockRecord(id int64, fields map[string]any) MockDataRecord { return MockDataRecord{fields: fields}}
type MockDataSource struct { records []DataRecord loadError error countError error}
func (m *MockDataSource) Load(ctx context.Context, opts DataOptions) ([]DataRecord, error) { if m.loadError != nil { return nil, m.loadError }
filtered := make([]DataRecord, 0) for _, record := range m.records { include := true for filterField, filterValue := range opts.Filters { if record.GetField(filterField) != filterValue { include = false break } } if include { filtered = append(filtered, record) } }
if opts.Limit > 0 && len(filtered) > opts.Limit { filtered = filtered[:opts.Limit] }
return filtered, nil}
func (m *MockDataSource) Count(ctx context.Context, opts DataOptions) (int, error) { if m.countError != nil { return 0, m.countError }
count := 0 for _, record := range m.records { include := true for filterField, filterValue := range opts.Filters { if record.GetField(filterField) != filterValue { include = false break } } if include { count++ } }
return count, nil}
func createMockRecords() []DataRecord { return []DataRecord{ NewMockRecord(1, map[string]any{ "name": "John Doe", "status": "active", "priority": "high", "project": "alpha", }), NewMockRecord(2, map[string]any{ "name": "Jane Smith", "status": "pending", "priority": "medium", "project": "beta", }), NewMockRecord(3, map[string]any{ "name": "Bob Johnson", "status": "completed", "priority": "low", "project": "alpha", }), }}
func createTestFields() []Field { return []Field{ {Name: "name", Title: "Name", Width: 20}, {Name: "status", Title: "Status", Width: 12}, {Name: "priority", Title: "Priority", Width: 10, Formatter: func(v any) string { return strings.ToUpper(fmt.Sprintf("%v", v)) }}, {Name: "project", Title: "Project", Width: 15}, }}
func testViewHandler(record DataRecord) string { return fmt.Sprintf("Viewing: %v", record.GetField("name"))}
func TestDataTable(t *testing.T) { t.Run("TestDataTableOptions", func(t *testing.T) { t.Run("default options", func(t *testing.T) { source := &MockDataSource{records: createMockRecords()} opts := DataTableOptions{ Fields: createTestFields(), }
table := NewDataTable(source, opts) if table.opts.Output == nil { t.Error("Output should default to os.Stdout") } if table.opts.Input == nil { t.Error("Input should default to os.Stdin") } if table.opts.Title != "Data" { t.Error("Title should default to 'Data'") } })
t.Run("custom options", func(t *testing.T) { var buf bytes.Buffer source := &MockDataSource{records: createMockRecords()} opts := DataTableOptions{ Output: &buf, Static: true, Title: "Test Table", Fields: createTestFields(), ViewHandler: testViewHandler, }
table := NewDataTable(source, opts) if table.opts.Output != &buf { t.Error("Custom output not set") } if !table.opts.Static { t.Error("Static mode not set") } if table.opts.Title != "Test Table" { t.Error("Custom title not set") } }) })
t.Run("Static Mode", func(t *testing.T) { t.Run("successful static display", func(t *testing.T) { var buf bytes.Buffer source := &MockDataSource{records: createMockRecords()}
table := NewDataTable(source, DataTableOptions{ Output: &buf, Static: true, Title: "Test Table", Fields: createTestFields(), })
err := table.Browse(context.Background()) if err != nil { t.Fatalf("Browse failed: %v", err) }
output := buf.String() if !strings.Contains(output, "Test Table") { t.Error("Title not displayed") } if !strings.Contains(output, "John Doe") { t.Error("First record not displayed") } if !strings.Contains(output, "Jane Smith") { t.Error("Second record not displayed") } if !strings.Contains(output, "Name") { t.Error("Header not displayed") } })
t.Run("static display with no records", func(t *testing.T) { var buf bytes.Buffer source := &MockDataSource{records: []DataRecord{}}
table := NewDataTable(source, DataTableOptions{ Output: &buf, Static: true, Fields: createTestFields(), })
err := table.Browse(context.Background()) if err != nil { t.Fatalf("Browse failed: %v", err) }
output := buf.String() if !strings.Contains(output, "No records found") { t.Error("No records message not displayed") } })
t.Run("static display with load error", func(t *testing.T) { var buf bytes.Buffer source := &MockDataSource{ loadError: errors.New("database error"), }
table := NewDataTable(source, DataTableOptions{ Output: &buf, Static: true, Fields: createTestFields(), })
err := table.Browse(context.Background()) if err == nil { t.Fatal("Expected error, got nil") }
output := buf.String() if !strings.Contains(output, "Error: database error") { t.Error("Error message not displayed") } })
t.Run("static display with filters", func(t *testing.T) { var buf bytes.Buffer source := &MockDataSource{records: createMockRecords()}
table := NewDataTable(source, DataTableOptions{ Output: &buf, Static: true, Fields: createTestFields(), })
opts := DataOptions{ Filters: map[string]any{ "status": "active", }, }
err := table.BrowseWithOptions(context.Background(), opts) if err != nil { t.Fatalf("Browse failed: %v", err) }
output := buf.String() if !strings.Contains(output, "John Doe") { t.Error("Active record not displayed") } if strings.Contains(output, "Jane Smith") { t.Error("Pending record should be filtered out") } }) })
t.Run("Model", func(t *testing.T) { t.Run("initial model state", func(t *testing.T) { model := dataTableModel{ opts: DataTableOptions{ Fields: createTestFields(), }, loading: true, }
if model.selected != 0 { t.Error("Initial selected should be 0") } if model.viewing { t.Error("Initial viewing should be false") } if !model.loading { t.Error("Initial loading should be true") } })
t.Run("load data command", func(t *testing.T) { source := &MockDataSource{records: createMockRecords()}
model := dataTableModel{ source: source, keys: DefaultDataTableKeys(), dataOpts: DataOptions{}, }
cmd := model.loadData() if cmd == nil { t.Fatal("loadData should return a command") }
msg := cmd() switch msg := msg.(type) { case dataLoadedMsg: records := []DataRecord(msg) if len(records) != 3 { t.Errorf("Expected 3 records, got %d", len(records)) } case dataErrorMsg: t.Fatalf("Unexpected error: %v", error(msg)) default: t.Fatalf("Unexpected message type: %T", msg) } })
t.Run("load data with error", func(t *testing.T) { source := &MockDataSource{ loadError: errors.New("connection failed"), }
model := dataTableModel{ source: source, dataOpts: DataOptions{}, }
cmd := model.loadData() msg := cmd()
switch msg := msg.(type) { case dataErrorMsg: err := error(msg) if !strings.Contains(err.Error(), "connection failed") { t.Errorf("Expected connection error, got: %v", err) } default: t.Fatalf("Expected dataErrorMsg, got: %T", msg) } })
t.Run("load count command", func(t *testing.T) { source := &MockDataSource{records: createMockRecords()}
model := dataTableModel{ source: source, dataOpts: DataOptions{}, }
cmd := model.loadCount() msg := cmd()
switch msg := msg.(type) { case dataCountMsg: count := int(msg) if count != 3 { t.Errorf("Expected count 3, got %d", count) } default: t.Fatalf("Expected dataCountMsg, got: %T", msg) } })
t.Run("load count with error", func(t *testing.T) { source := &MockDataSource{ records: createMockRecords(), countError: errors.New("count failed"), }
model := dataTableModel{ source: source, dataOpts: DataOptions{}, }
cmd := model.loadCount() msg := cmd()
switch msg := msg.(type) { case dataCountMsg: count := int(msg) if count != 0 { t.Errorf("Expected count 0 on error, got %d", count) } default: t.Fatalf("Expected dataCountMsg even on error, got: %T", msg) } })
t.Run("view record command", func(t *testing.T) { model := dataTableModel{ opts: DataTableOptions{ ViewHandler: testViewHandler, Fields: createTestFields(), }, }
record := createMockRecords()[0] cmd := model.viewRecord(record) msg := cmd()
switch msg := msg.(type) { case dataViewMsg: content := string(msg) if !strings.Contains(content, "Viewing: John Doe") { t.Error("View content not formatted correctly") } default: t.Fatalf("Expected dataViewMsg, got: %T", msg) } }) })
t.Run("Key Handling", func(t *testing.T) { source := &MockDataSource{records: createMockRecords()}
t.Run("navigation keys", func(t *testing.T) { model := dataTableModel{ source: source, records: createMockRecords(), selected: 1, keys: DefaultDataTableKeys(), opts: DataTableOptions{Fields: createTestFields()}, }
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) if m, ok := newModel.(dataTableModel); ok { if m.selected != 0 { t.Errorf("Up key should move selection to 0, got %d", m.selected) } }
model.selected = 1 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) if m, ok := newModel.(dataTableModel); ok { if m.selected != 2 { t.Errorf("Down key should move selection to 2, got %d", m.selected) } } })
t.Run("boundary conditions", func(t *testing.T) { model := dataTableModel{ source: source, records: createMockRecords(), selected: 0, keys: DefaultDataTableKeys(), opts: DataTableOptions{Fields: createTestFields()}, }
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) if m, ok := newModel.(dataTableModel); ok { if m.selected != 0 { t.Error("Up key at top should not change selection") } }
model.selected = 2 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) if m, ok := newModel.(dataTableModel); ok { if m.selected != 2 { t.Error("Down key at bottom should not change selection") } } })
t.Run("number shortcuts", func(t *testing.T) { model := dataTableModel{ source: source, records: createMockRecords(), keys: DefaultDataTableKeys(), opts: DataTableOptions{Fields: createTestFields()}, }
for i := 1; i <= 3; i++ { key := fmt.Sprintf("%d", i) newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) if m, ok := newModel.(dataTableModel); ok { expectedIndex := i - 1 if m.selected != expectedIndex { t.Errorf("Number key %s should select index %d, got %d", key, expectedIndex, m.selected) } } } })
t.Run("view key with handler", func(t *testing.T) { viewHandler := func(record DataRecord) string { return "test view" }
model := dataTableModel{ source: source, records: createMockRecords(), keys: DefaultDataTableKeys(), opts: DataTableOptions{ Fields: createTestFields(), ViewHandler: viewHandler, }, }
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("v")}) if cmd == nil { t.Error("View key should return command when handler is set") } })
t.Run("view key without handler", func(t *testing.T) { model := dataTableModel{ source: source, records: createMockRecords(), keys: DefaultDataTableKeys(), opts: DataTableOptions{Fields: createTestFields()}, }
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("v")}) if cmd != nil { t.Error("View key should not return command when no handler is set") } })
t.Run("quit key", func(t *testing.T) { model := dataTableModel{ keys: DefaultDataTableKeys(), opts: DataTableOptions{Fields: createTestFields()}, }
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) if cmd == nil { t.Error("Quit key should return quit command") } })
t.Run("refresh key", func(t *testing.T) { model := dataTableModel{ source: source, keys: DefaultDataTableKeys(), opts: DataTableOptions{Fields: createTestFields()}, }
newModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) if cmd == nil { t.Error("Refresh key should return command") } if m, ok := newModel.(dataTableModel); ok { if !m.loading { t.Error("Refresh should set loading to true") } } })
t.Run("help mode", func(t *testing.T) { model := dataTableModel{ keys: DefaultDataTableKeys(), showingHelp: true, opts: DataTableOptions{Fields: createTestFields()}, }
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) if m, ok := newModel.(dataTableModel); ok { if m.selected != 0 { t.Error("Navigation should be ignored in help mode") } }
newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) if m, ok := newModel.(dataTableModel); ok { if m.showingHelp { t.Error("Help key should exit help mode") } } })
t.Run("viewing mode", func(t *testing.T) { model := dataTableModel{ keys: DefaultDataTableKeys(), viewing: true, viewContent: "test content", opts: DataTableOptions{Fields: createTestFields()}, }
newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) if m, ok := newModel.(dataTableModel); ok { if m.viewing { t.Error("Quit should exit viewing mode") } if m.viewContent != "" { t.Error("Quit should clear view content") } } }) })
t.Run("View", func(t *testing.T) { source := &MockDataSource{records: createMockRecords()}
t.Run("normal view", func(t *testing.T) { model := dataTableModel{ source: source, records: createMockRecords(), keys: DefaultDataTableKeys(), help: help.New(), opts: DataTableOptions{Title: "Test", Fields: createTestFields()}, }
view := model.View() if !strings.Contains(view, "Test") { t.Error("Title not displayed") } if !strings.Contains(view, "John Doe") { t.Error("Record data not displayed") } if !strings.Contains(view, "Name") { t.Error("Headers not displayed") } if !strings.Contains(view, " > ") { t.Error("Selection indicator not displayed") } })
t.Run("loading view", func(t *testing.T) { model := dataTableModel{ loading: true, opts: DataTableOptions{Title: "Test", Fields: createTestFields()}, }
view := model.View() if !strings.Contains(view, "Loading...") { t.Error("Loading message not displayed") } })
t.Run("error view", func(t *testing.T) { model := dataTableModel{ err: errors.New("test error"), opts: DataTableOptions{Title: "Test", Fields: createTestFields()}, }
view := model.View() if !strings.Contains(view, "Error: test error") { t.Error("Error message not displayed") } })
t.Run("empty records view", func(t *testing.T) { model := dataTableModel{ records: []DataRecord{}, opts: DataTableOptions{Title: "Test", Fields: createTestFields()}, }
view := model.View() if !strings.Contains(view, "No records found") { t.Error("Empty message not displayed") } })
t.Run("viewing mode", func(t *testing.T) { model := dataTableModel{ viewing: true, viewContent: "# Test Content\nDetails here", opts: DataTableOptions{Fields: createTestFields()}, }
view := model.View() if !strings.Contains(view, "# Test Content") { t.Error("View content not displayed") } if !strings.Contains(view, "Press q/esc/backspace to return") { t.Error("Return instructions not displayed") } })
t.Run("help mode", func(t *testing.T) { model := dataTableModel{ showingHelp: true, keys: DefaultDataTableKeys(), help: help.New(), opts: DataTableOptions{Fields: createTestFields()}, }
view := model.View() if view == "" { t.Error("Help view should not be empty") } })
t.Run("field formatters", func(t *testing.T) { fields := []Field{ {Name: "priority", Title: "Priority", Width: 10, Formatter: func(v any) string { return strings.ToUpper(fmt.Sprintf("%v", v)) }}, }
model := dataTableModel{ records: createMockRecords(), opts: DataTableOptions{Fields: fields}, }
view := model.View() if !strings.Contains(view, "HIGH") { t.Error("Field formatter not applied") } })
t.Run("long field truncation", func(t *testing.T) { longRecord := NewMockRecord(1, map[string]any{ "name": "This is a very long name that should be truncated", })
fields := []Field{ {Name: "name", Title: "Name", Width: 10}, }
model := dataTableModel{ records: []DataRecord{longRecord}, opts: DataTableOptions{Fields: fields}, }
view := model.View() if !strings.Contains(view, "...") { t.Error("Long field should be truncated with ellipsis") } }) })
t.Run("Update", func(t *testing.T) { source := &MockDataSource{records: createMockRecords()}
t.Run("data loaded message", func(t *testing.T) { model := dataTableModel{ source: source, loading: true, opts: DataTableOptions{Fields: createTestFields()}, }
records := createMockRecords()[:2] newModel, _ := model.Update(dataLoadedMsg(records))
if m, ok := newModel.(dataTableModel); ok { if len(m.records) != 2 { t.Errorf("Expected 2 records, got %d", len(m.records)) } if m.loading { t.Error("Loading should be set to false") } } })
t.Run("selected index adjustment", func(t *testing.T) { model := dataTableModel{ selected: 5, opts: DataTableOptions{Fields: createTestFields()}, }
records := createMockRecords()[:2] newModel, _ := model.Update(dataLoadedMsg(records))
if m, ok := newModel.(dataTableModel); ok { if m.selected != 1 { t.Errorf("Selected should be adjusted to 1, got %d", m.selected) } } })
t.Run("data view message", func(t *testing.T) { model := dataTableModel{ opts: DataTableOptions{Fields: createTestFields()}, }
content := "Test view content" newModel, _ := model.Update(dataViewMsg(content))
if m, ok := newModel.(dataTableModel); ok { if !m.viewing { t.Error("Viewing mode should be activated") } if m.viewContent != content { t.Error("View content not set correctly") } } })
t.Run("data error message", func(t *testing.T) { model := dataTableModel{ loading: true, opts: DataTableOptions{Fields: createTestFields()}, }
testErr := errors.New("test error") newModel, _ := model.Update(dataErrorMsg(testErr))
if m, ok := newModel.(dataTableModel); ok { if m.err == nil { t.Error("Error should be set") } if m.err.Error() != "test error" { t.Errorf("Expected 'test error', got %v", m.err) } if m.loading { t.Error("Loading should be set to false on error") } } })
t.Run("data count message", func(t *testing.T) { model := dataTableModel{ opts: DataTableOptions{Fields: createTestFields()}, }
count := 42 newModel, _ := model.Update(dataCountMsg(count))
if m, ok := newModel.(dataTableModel); ok { if m.totalCount != count { t.Errorf("Expected count %d, got %d", count, m.totalCount) } } }) })
t.Run("Default Keys", func(t *testing.T) { keys := DefaultDataTableKeys()
if len(keys.Numbers) != 9 { t.Errorf("Expected 9 number bindings, got %d", len(keys.Numbers)) }
if keys.Actions == nil { t.Error("Actions map should be initialized") } })
t.Run("Actions", func(t *testing.T) { t.Run("action key handling", func(t *testing.T) { actionCalled := false action := Action{ Key: "d", Description: "delete", Handler: func(record DataRecord) tea.Cmd { actionCalled = true return nil }, }
keys := DefaultDataTableKeys() keys.Actions["d"] = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete"))
model := dataTableModel{ source: &MockDataSource{records: createMockRecords()}, records: createMockRecords(), keys: keys, opts: DataTableOptions{ Fields: createTestFields(), Actions: []Action{action}, }, }
_, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) if cmd != nil { cmd() }
if !actionCalled { t.Error("Action handler should be called") } }) })
t.Run("Field", func(t *testing.T) { t.Run("field without formatter", func(t *testing.T) { field := Field{Name: "test"}
record := NewMockRecord(1, map[string]any{ "test": "value", })
value := record.GetField(field.Name) displayValue := fmt.Sprintf("%v", value)
if displayValue != "value" { t.Errorf("Expected 'value', got '%s'", displayValue) } })
t.Run("field with formatter", func(t *testing.T) { field := Field{ Name: "test", Formatter: func(v any) string { return strings.ToUpper(fmt.Sprintf("%v", v)) }, }
record := NewMockRecord(1, map[string]any{ "test": "value", })
value := record.GetField(field.Name) displayValue := field.Formatter(value)
if displayValue != "VALUE" { t.Errorf("Expected 'VALUE', got '%s'", displayValue) } }) })}