diff --git a/.gitignore b/.gitignore index e0786aa..be2e872 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ buoy -dist/ \ No newline at end of file +dist/ + +cover.out \ No newline at end of file diff --git a/Makefile b/Makefile index afab973..5f73cc2 100644 --- a/Makefile +++ b/Makefile @@ -23,5 +23,5 @@ GOLANGCI_LINT_ARGS ?= lint: $(GOLANGCI_LINT) $(GOLANGCI_LINT) run $(GOLANGCI_LINT_ARGS) unit: - go test ./... + go test ./... -coverprofile=cover.out -covermode=atomic diff --git a/go.mod b/go.mod index c38d856..0388323 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be github.com/sahilm/fuzzy v0.1.0 github.com/spf13/cobra v1.7.0 + github.com/stretchr/testify v1.8.2 github.com/tidwall/gjson v1.17.0 k8s.io/api v0.28.1 k8s.io/apimachinery v0.28.1 @@ -21,6 +22,7 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect ) diff --git a/pkg/charm/models/dashboard_test.go b/pkg/charm/models/dashboard_test.go new file mode 100644 index 0000000..1e04f74 --- /dev/null +++ b/pkg/charm/models/dashboard_test.go @@ -0,0 +1,36 @@ +package models + +import ( + "testing" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/models/panels" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/everettraven/buoy/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestDashboardUpdate(t *testing.T) { + panels := []tea.Model{ + panels.NewItem(types.Item{ + PanelBase: types.PanelBase{ + Name: "test", + }, + }, viewport.New(10, 10), styles.Theme{}), + } + + d := NewDashboard(DefaultDashboardKeys, styles.Theme{}, panels...) + + t.Log("WindowSizeUpdate") + d.Update(tea.WindowSizeMsg{Width: 50, Height: 50}) + assert.Equal(t, 50, d.width) + + t.Log("toggle detailed help") + d.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ctrl+h")}) + assert.True(t, d.help.ShowAll) + + t.Log("quit the program") + _, cmd := d.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + assert.Equal(t, cmd(), tea.Quit()) +} diff --git a/pkg/charm/models/panels/item_test.go b/pkg/charm/models/panels/item_test.go new file mode 100644 index 0000000..dad0c2c --- /dev/null +++ b/pkg/charm/models/panels/item_test.go @@ -0,0 +1,32 @@ +package panels + +import ( + "errors" + "testing" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/everettraven/buoy/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestItemUpdate(t *testing.T) { + item := NewItem(types.Item{}, viewport.New(10, 10), styles.Theme{}) + item.Update(tea.WindowSizeMsg{Width: 50, Height: 50}) + assert.Equal(t, 50, item.viewport.Width) + assert.Equal(t, 25, item.viewport.Height) +} + +func TestItemViewWithError(t *testing.T) { + item := NewItem(types.Item{}, viewport.New(10, 10), styles.Theme{}) + err := errors.New("some error") + item.SetError(err) + assert.Equal(t, err.Error(), item.View()) +} + +func TestViewWithContent(t *testing.T) { + item := NewItem(types.Item{}, viewport.New(50, 50), styles.Theme{}) + item.SetContent("some content") + assert.Contains(t, item.View(), "some content") +} diff --git a/pkg/charm/models/panels/logs_test.go b/pkg/charm/models/panels/logs_test.go new file mode 100644 index 0000000..721f221 --- /dev/null +++ b/pkg/charm/models/panels/logs_test.go @@ -0,0 +1,89 @@ +package panels + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/stretchr/testify/assert" +) + +func TestEnterSearchMode(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + assert.Equal(t, logs.mode, modeSearching) +} + +func TestExecuteSearch(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + logs.Update(tea.KeyMsg{Type: tea.KeyEnter}) + assert.Equal(t, logs.mode, modeSearched) +} + +func TestExitSearchMode(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.mode = modeSearching + logs.searchbar.Focus() + logs.Update(tea.KeyMsg{Type: tea.KeyEsc}) + assert.Equal(t, logs.mode, modeLogs) + assert.False(t, logs.searchbar.Focused()) + + logs.mode = modeSearched + logs.searchbar.Focus() + logs.Update(tea.KeyMsg{Type: tea.KeyEsc}) + assert.Equal(t, logs.mode, modeLogs) + assert.False(t, logs.searchbar.Focused()) +} + +func TestSearchModeToggle(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + logs.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + assert.True(t, logs.strictSearch) + logs.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + assert.False(t, logs.strictSearch) +} + +func TestSearchLogs(t *testing.T) { + t.Log("strict search") + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.strictSearch = true + logs.content = "some log line\nlog line with a search term\n" + logs.viewport.Width = 50 + logs.searchbar.SetValue("search") + match := logs.searchLogs() + assert.Equal(t, "log line with a search term\n", match) + + t.Log("fuzzy search") + logs.searchbar.SetValue("sll") + logs.strictSearch = false + match = logs.searchLogs() + assert.Equal(t, "some log line\n", match) +} + +func TestLogsWindowSizeUpdate(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.Update(tea.WindowSizeMsg{Width: 100, Height: 100}) + assert.Equal(t, logs.viewport.Width, 100) + assert.Equal(t, logs.viewport.Height, 50) +} + +func TestLogsAddContent(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + logs.AddContent("some log line\n") + assert.Equal(t, "\nsome log line\n", logs.content) + assert.True(t, logs.contentUpdated) + + logs.Update(tea.KeyMsg{Type: tea.KeyDown}) + assert.False(t, logs.contentUpdated) +} + +func TestLogsViewWithError(t *testing.T) { + logs := NewLogs(DefaultLogsKeys, nil, styles.Theme{}) + err := errors.New("some error") + logs.SetError(err) + assert.Equal(t, err, logs.err) + assert.Equal(t, err.Error(), logs.View()) +} diff --git a/pkg/charm/models/panels/table.go b/pkg/charm/models/panels/table.go index 65c744d..f0e7163 100644 --- a/pkg/charm/models/panels/table.go +++ b/pkg/charm/models/panels/table.go @@ -155,7 +155,7 @@ func (m *Table) View() string { case modeView: return m.viewport.View() default: - return "?" + return fmt.Sprintf("unknown table state. table.mode=%q", m.mode) } } @@ -234,6 +234,10 @@ func (m *Table) FetchContentForIndex(index int) (string, error) { } } + if rowInfo == nil { + return "", fmt.Errorf("no row data found for selected row %d", index) + } + name := rowInfo.Identifier.String() if m.scope == meta.RESTScopeNameRoot { name = rowInfo.Identifier.Name diff --git a/pkg/charm/models/panels/table_test.go b/pkg/charm/models/panels/table_test.go new file mode 100644 index 0000000..74d27dd --- /dev/null +++ b/pkg/charm/models/panels/table_test.go @@ -0,0 +1,105 @@ +package panels + +import ( + "errors" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/styles" + buoytypes "github.com/everettraven/buoy/pkg/types" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +func TestTableUpdate(t *testing.T) { + t.Log("WindowSizeUpdate") + table := NewTable(DefaultTableKeys, &buoytypes.Table{}, styles.Theme{}) + table.Update(tea.WindowSizeMsg{Width: 50, Height: 50}) + assert.Equal(t, 50, table.viewport.Width) + assert.Equal(t, 25, table.viewport.Height) + + t.Log("toggle view mode on") + table.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("v")}) + assert.Equal(t, modeView, table.mode) + + t.Log("toggle view mode off") + table.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("v")}) + assert.Equal(t, modeTable, table.mode) +} + +func TestAddOrUpdate(t *testing.T) { + table := NewTable(DefaultTableKeys, &buoytypes.Table{ + Columns: []buoytypes.Column{ + {Header: "Name", Width: 10, Path: "metadata.name"}, + }, + }, styles.Theme{}) + + t.Log("add a row") + u := &unstructured.Unstructured{} + u.SetName("test") + u.SetNamespace("test-ns") + u.SetUID(types.UID("test")) + table.AddOrUpdate(u) + assert.Len(t, table.rows, 1) + assert.NotNil(t, table.rows[types.UID("test")].Row) + assert.Equal(t, &types.NamespacedName{Namespace: "test-ns", Name: "test"}, table.rows[types.UID("test")].Identifier) + + t.Log("update a row") + u.SetName("test2") + table.AddOrUpdate(u) + assert.Len(t, table.rows, 1) + assert.NotNil(t, table.rows[types.UID("test")].Row) + assert.Equal(t, &types.NamespacedName{Namespace: "test-ns", Name: "test2"}, table.rows[types.UID("test")].Identifier) +} + +func TestDeleteRow(t *testing.T) { + table := NewTable(DefaultTableKeys, &buoytypes.Table{ + Columns: []buoytypes.Column{ + {Header: "Name", Width: 10, Path: "metadata.name"}, + }, + }, styles.Theme{}) + + t.Log("add a row") + u := &unstructured.Unstructured{} + u.SetName("test") + u.SetNamespace("test-ns") + u.SetUID(types.UID("test")) + table.AddOrUpdate(u) + assert.Len(t, table.rows, 1) + + t.Log("delete a row") + table.DeleteRow(types.UID("test")) + assert.Len(t, table.rows, 0) +} + +func TestTableView(t *testing.T) { + table := NewTable(DefaultTableKeys, &buoytypes.Table{}, styles.Theme{}) + + t.Log("view with error state") + err := errors.New("some error") + table.SetError(err) + assert.Equal(t, err.Error(), table.View()) + + t.Log("view with table mode") + table.SetError(nil) + table.mode = modeTable + assert.Equal(t, table.tableModel.View(), table.View()) + + t.Log("view with view mode toggled on") + table.mode = modeView + table.viewport.SetContent("floof") + assert.Equal(t, table.viewport.View(), table.View()) +} + +func TestGetDotNotationValueNonExistentValue(t *testing.T) { + obj := map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "baz", + }, + } + + val, err := getDotNotationValue(obj, "foo.baz") + assert.NoError(t, err) + assert.Equal(t, "n/a", val) +} diff --git a/pkg/charm/models/tabber_test.go b/pkg/charm/models/tabber_test.go new file mode 100644 index 0000000..2b0f01e --- /dev/null +++ b/pkg/charm/models/tabber_test.go @@ -0,0 +1,29 @@ +package models + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/stretchr/testify/assert" +) + +func TestTabberUpdate(t *testing.T) { + tabber := NewTabber(DefaultTabberKeys, styles.Theme{}, Tab{Name: "test", Model: nil}, Tab{Name: "test2", Model: nil}) + + t.Log("navigate to next tab") + tabber.Update(tea.KeyMsg{Type: tea.KeyTab}) + assert.Equal(t, 1, tabber.selected) + + t.Log("navigate to previous tab") + tabber.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("shift+tab")}) + assert.Equal(t, 0, tabber.selected) + + t.Log("navigate to previous tab (out of bounds -> last tab)") + tabber.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("shift+tab")}) + assert.Equal(t, 1, tabber.selected) + + t.Log("navigate to next tab (out of bounds -> first tab)") + tabber.Update(tea.KeyMsg{Type: tea.KeyTab}) + assert.Equal(t, 0, tabber.selected) +} diff --git a/pkg/factories/panel/panelfactory_test.go b/pkg/factories/panel/panelfactory_test.go new file mode 100644 index 0000000..923699d --- /dev/null +++ b/pkg/factories/panel/panelfactory_test.go @@ -0,0 +1,103 @@ +package panel + +import ( + "testing" + + "github.com/everettraven/buoy/pkg/charm/models/panels" + "github.com/everettraven/buoy/pkg/charm/styles" + "github.com/everettraven/buoy/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestUnknownPanelType(t *testing.T) { + panelFactory := NewPanelFactory(styles.Theme{}) + _, err := panelFactory.ModelForPanel(types.Panel{ + PanelBase: types.PanelBase{ + Name: "test", + Type: "unknown", + }, + }) + assert.Error(t, err) +} + +func TestTablePanel(t *testing.T) { + panelJSON := `{ + "name": "Deployments", + "group": "apps", + "version": "v1", + "kind": "Deployment", + "type": "table", + "columns": [ + { + "header": "Namespace", + "path": "metadata.namespace" + }, + { + "header": "Name", + "path": "metadata.name" + }, + { + "header": "Replicas", + "path": "status.replicas" + } + ] + }` + + panel := &types.Panel{} + err := panel.UnmarshalJSON([]byte(panelJSON)) + assert.NoError(t, err) + + panelFactory := NewPanelFactory(styles.Theme{}) + tbl, err := panelFactory.ModelForPanel(*panel) + assert.NoError(t, err) + assert.NotNil(t, tbl) + assert.IsType(t, &panels.Table{}, tbl) +} + +func TestItemPanel(t *testing.T) { + panelJSON := `{ + "name": "Kube API Server", + "group": "", + "version": "v1", + "kind": "Pod", + "type": "item", + "key": { + "namespace": "kube-system", + "name": "kube-apiserver-kind-control-plane" + } + }` + + panel := &types.Panel{} + err := panel.UnmarshalJSON([]byte(panelJSON)) + assert.NoError(t, err) + + panelFactory := NewPanelFactory(styles.Theme{}) + item, err := panelFactory.ModelForPanel(*panel) + assert.NoError(t, err) + assert.NotNil(t, item) + assert.IsType(t, &panels.Item{}, item) +} + +func TestLogPanel(t *testing.T) { + panelJSON := `{ + "name": "Kube API Server Logs", + "group": "", + "version": "v1", + "kind": "Pod", + "type": "logs", + "key": { + "namespace": "kube-system", + "name": "kube-apiserver-kind-control-plane" + } + }` + + panel := &types.Panel{} + err := panel.UnmarshalJSON([]byte(panelJSON)) + assert.NoError(t, err) + + panelFactory := NewPanelFactory(styles.Theme{}) + log, err := panelFactory.ModelForPanel(*panel) + assert.NoError(t, err) + assert.NotNil(t, log) + assert.IsType(t, &panels.Logs{}, log) +}