From 776c1f9436fd306b836af78a1d714b827274b34f Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Wed, 27 Nov 2019 23:07:56 -0500 Subject: [PATCH] add tests --- .circleci/config.yml | 2 +- .github/workflows/pipeline.yml | 2 +- Makefile | 2 +- runtime/ui/app.go | 4 + runtime/ui/layout/manager.go | 12 +- runtime/ui/layout/manager_test.go | 381 ++++++++++++++++++++++++++++++ runtime/ui/view/debug.go | 122 ++++++++++ runtime/ui/view/details.go | 4 +- runtime/ui/view/filetree.go | 5 +- runtime/ui/view/filter.go | 4 +- runtime/ui/view/layer.go | 4 +- runtime/ui/view/status.go | 4 +- runtime/ui/view/views.go | 15 +- 13 files changed, 537 insertions(+), 24 deletions(-) create mode 100644 runtime/ui/layout/manager_test.go create mode 100644 runtime/ui/view/debug.go diff --git a/.circleci/config.yml b/.circleci/config.yml index 120a605..e22c437 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,7 +22,7 @@ jobs: - "/go/pkg/mod" - run: name: run static analysis - command: make ci-static-analyses + command: make ci-static-analysis run-tests: parameters: diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 1434f90..af9f49f 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -64,7 +64,7 @@ jobs: run: go get ./... - name: Linting, formatting, and other static code analyses - run: make ci-static-analyses + run: make ci-static-analysis - name: Build snapshot artifacts run: make ci-build-snapshot-packages diff --git a/Makefile b/Makefile index df98836..93fc623 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ all: clean build ci-unit-test: go test -cover -v -race ./... -ci-static-analyses: +ci-static-analysis: grep -R 'const allowTestDataCapture = false' runtime/ui/viewmodel go vet ./... @! gofmt -s -l . 2>&1 | grep -vE '^\.git/' | grep -vE '^\.cache/' diff --git a/runtime/ui/app.go b/runtime/ui/app.go index f4bc03d..c1b48a7 100644 --- a/runtime/ui/app.go +++ b/runtime/ui/app.go @@ -44,6 +44,10 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Compa lm.Add(compound.NewLayerDetailsCompoundLayout(controller.views.Layer, controller.views.Details), layout.LocationColumn) lm.Add(controller.views.Tree, layout.LocationColumn) + // todo: access this more programmatically + if debug { + lm.Add(controller.views.Debug, layout.LocationColumn) + } gui.Cursor = false //g.Mouse = true gui.SetManagerFunc(lm.Layout) diff --git a/runtime/ui/layout/manager.go b/runtime/ui/layout/manager.go index 5ff09e4..ddccfe9 100644 --- a/runtime/ui/layout/manager.go +++ b/runtime/ui/layout/manager.go @@ -113,7 +113,7 @@ func (lm *Manager) planAndLayoutColumns(g *gocui.Gui, area Area) (Area, error) { } } - defaultWidth := int(availableWidth / variableColumns) + defaultWidth := availableWidth / variableColumns // second pass: layout columns left to right (based off predetermined widths) for idx, element := range elements { @@ -177,6 +177,11 @@ func (lm *Manager) notifyLayoutChange() error { return nil } +func (lm *Manager) Layout(g *gocui.Gui) error { + curMaxX, curMaxY := g.Size() + return lm.layout(g, curMaxX, curMaxY) +} + // layout defines the definition of the window pane size and placement relations to one another. This // is invoked at application start and whenever the screen dimensions change. // A few things to note: @@ -184,11 +189,10 @@ func (lm *Manager) notifyLayoutChange() error { // needed (but there are comments!). // 2. since there are borders, in order for it to appear as if there aren't any spaces for borders, the views must // overlap. To prevent screen artifacts, all elements must be layedout from the top of the screen to the bottom. -func (lm *Manager) Layout(g *gocui.Gui) error { +func (lm *Manager) layout(g *gocui.Gui, curMaxX, curMaxY int) error { var headerAreaChanged, footerAreaChanged, columnAreaChanged bool - // grab the latest screen size and compare with the last layout - curMaxX, curMaxY := g.Size() + // compare current screen size with the last known size at time of layout area := Area{ minX: -1, minY: -1, diff --git a/runtime/ui/layout/manager_test.go b/runtime/ui/layout/manager_test.go new file mode 100644 index 0000000..da61dcb --- /dev/null +++ b/runtime/ui/layout/manager_test.go @@ -0,0 +1,381 @@ +package layout + +import ( + "github.com/jroimartin/gocui" + "testing" +) + +type testElement struct { + t *testing.T + size int + layoutArea Area + location Location +} + +func newTestElement(t *testing.T, size int, layoutArea Area, location Location) *testElement { + return &testElement{ + t: t, + size: size, + layoutArea: layoutArea, + location: location, + } +} + +func (te *testElement) Name() string { + return "dont care" +} +func (te *testElement) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { + actualLayoutArea := Area{ + minX: minX, + minY: minY, + maxX: maxX, + maxY: maxY, + } + + if te.layoutArea != actualLayoutArea { + te.t.Errorf("expected layout area '%+v', got '%+v'", te.layoutArea, actualLayoutArea) + } + return nil +} +func (te *testElement) RequestedSize(available int) *int { + if te.size == -1 { + return nil + } + return &te.size +} +func (te *testElement) IsVisible() bool { + return true +} +func (te *testElement) OnLayoutChange() error { + return nil +} + +type layoutReturn struct { + area Area + err error +} + +func Test_planAndLayoutHeaders(t *testing.T) { + + table := map[string]struct { + headers []*testElement + expected layoutReturn + }{ + "single header": { + headers: []*testElement{newTestElement(t, 1, Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 0, + }, LocationHeader)}, + expected: layoutReturn{ + area: Area{ + minX: -1, + minY: 0, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + "two headers": { + headers: []*testElement{ + newTestElement(t, 1, Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 0, + }, LocationHeader), + newTestElement(t, 1, Area{ + minX: -1, + minY: 0, + maxX: 120, + maxY: 1, + }, LocationHeader), + }, + expected: layoutReturn{ + area: Area{ + minX: -1, + minY: 1, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + "two odd-sized headers": { + headers: []*testElement{ + newTestElement(t, 2, Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 1, + }, LocationHeader), + newTestElement(t, 3, Area{ + minX: -1, + minY: 1, + maxX: 120, + maxY: 4, + }, LocationHeader), + }, + expected: layoutReturn{ + area: Area{ + minX: -1, + minY: 4, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + } + + for name, test := range table { + t.Log("case: ", name, " ---") + lm := NewManager() + for _, element := range test.headers { + lm.Add(element, element.location) + } + + area, err := lm.planAndLayoutHeaders(nil, Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 80, + }) + + if err != test.expected.err { + t.Errorf("%s: expected err '%+v', got error '%+v'", name, test.expected.err, err) + } + + if area != test.expected.area { + t.Errorf("%s: expected returned area '%+v', got area '%+v'", name, test.expected.area, area) + } + + } +} + +func Test_planAndLayoutColumns(t *testing.T) { + + table := map[string]struct { + columns []*testElement + expected layoutReturn + }{ + "single column": { + columns: []*testElement{newTestElement(t, -1, Area{ + minX: -1, + minY: -1, + maxX: 119, + maxY: 80, + }, LocationColumn)}, + expected: layoutReturn{ + area: Area{ + minX: 119, + minY: -1, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + "two equal columns": { + columns: []*testElement{ + newTestElement(t, -1, Area{ + minX: -1, + minY: -1, + maxX: 59, + maxY: 80, + }, LocationColumn), + newTestElement(t, -1, Area{ + minX: 59, + minY: -1, + maxX: 119, + maxY: 80, + }, LocationColumn), + }, + expected: layoutReturn{ + area: Area{ + minX: 119, + minY: -1, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + "two odd-sized columns": { + columns: []*testElement{ + newTestElement(t, 30, Area{ + minX: -1, + minY: -1, + maxX: 29, + maxY: 80, + }, LocationColumn), + newTestElement(t, -1, Area{ + minX: 29, + minY: -1, + maxX: 119, + maxY: 80, + }, LocationColumn), + }, + expected: layoutReturn{ + area: Area{ + minX: 119, + minY: -1, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + } + + for name, test := range table { + t.Log("case: ", name, " ---") + lm := NewManager() + for _, element := range test.columns { + lm.Add(element, element.location) + } + + area, err := lm.planAndLayoutColumns(nil, Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 80, + }) + + if err != test.expected.err { + t.Errorf("%s: expected err '%+v', got error '%+v'", name, test.expected.err, err) + } + + if area != test.expected.area { + t.Errorf("%s: expected returned area '%+v', got area '%+v'", name, test.expected.area, area) + } + + } +} + +func Test_layout(t *testing.T) { + + table := map[string]struct { + elements []*testElement + }{ + "1 header + 1 footer + 1 column": { + elements: []*testElement{ + newTestElement(t, 1, + Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 0, + }, LocationHeader), + newTestElement(t, 1, + Area{ + minX: -1, + minY: 78, + maxX: 120, + maxY: 80, + }, LocationFooter), + newTestElement(t, -1, + Area{ + minX: -1, + minY: 0, + maxX: 119, + maxY: 79, + }, LocationColumn), + }, + }, + "1 header + 1 footer + 3 column": { + elements: []*testElement{ + newTestElement(t, 1, + Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 0, + }, LocationHeader), + newTestElement(t, 1, + Area{ + minX: -1, + minY: 78, + maxX: 120, + maxY: 80, + }, LocationFooter), + newTestElement(t, -1, + Area{ + minX: -1, + minY: 0, + maxX: 39, + maxY: 79, + }, LocationColumn), + newTestElement(t, -1, + Area{ + minX: 39, + minY: 0, + maxX: 79, + maxY: 79, + }, LocationColumn), + newTestElement(t, -1, + Area{ + minX: 79, + minY: 0, + maxX: 119, + maxY: 79, + }, LocationColumn), + }, + }, + "1 header + 1 footer + 2 equal columns + 1 sized column": { + elements: []*testElement{ + newTestElement(t, 1, + Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 0, + }, LocationHeader), + newTestElement(t, 1, + Area{ + minX: -1, + minY: 78, + maxX: 120, + maxY: 80, + }, LocationFooter), + newTestElement(t, -1, + Area{ + minX: -1, + minY: 0, + maxX: 19, + maxY: 79, + }, LocationColumn), + newTestElement(t, 80, + Area{ + minX: 19, + minY: 0, + maxX: 99, + maxY: 79, + }, LocationColumn), + newTestElement(t, -1, + Area{ + minX: 99, + minY: 0, + maxX: 119, + maxY: 79, + }, LocationColumn), + }, + }, + } + + for name, test := range table { + t.Log("case: ", name, " ---") + lm := NewManager() + for _, element := range test.elements { + lm.Add(element, element.location) + } + + err := lm.layout(nil, 120, 80) + + if err != nil { + t.Fatalf("%s: unexpected error: %+v", name, err) + } + } +} diff --git a/runtime/ui/view/debug.go b/runtime/ui/view/debug.go new file mode 100644 index 0000000..aab9d06 --- /dev/null +++ b/runtime/ui/view/debug.go @@ -0,0 +1,122 @@ +package view + +import ( + "fmt" + "github.com/jroimartin/gocui" + "github.com/sirupsen/logrus" + "github.com/wagoodman/dive/runtime/ui/format" + "github.com/wagoodman/dive/utils" +) + +// Debug is just for me :) +type Debug struct { + name string + gui *gocui.Gui + view *gocui.View + header *gocui.View + + selectedView Helper +} + +// newDebugView creates a new view object attached the the global [gocui] screen object. +func newDebugView(gui *gocui.Gui) (controller *Debug) { + controller = new(Debug) + + // populate main fields + controller.name = "debug" + controller.gui = gui + + return controller +} + +func (v *Debug) SetCurrentView(r Helper) { + v.selectedView = r +} + +func (v *Debug) Name() string { + return v.name +} + +// Setup initializes the UI concerns within the context of a global [gocui] view object. +func (v *Debug) Setup(view *gocui.View, header *gocui.View) error { + logrus.Tracef("view.Setup() %s", v.Name()) + + // set controller options + v.view = view + v.view.Editable = false + v.view.Wrap = false + v.view.Frame = false + + v.header = header + v.header.Editable = false + v.header.Wrap = false + v.header.Frame = false + + return v.Render() +} + +// IsVisible indicates if the status view pane is currently initialized. +func (v *Debug) IsVisible() bool { + return v != nil +} + +// Update refreshes the state objects for future rendering (currently does nothing). +func (v *Debug) Update() error { + return nil +} + +// OnLayoutChange is called whenever the screen dimensions are changed +func (v *Debug) OnLayoutChange() error { + err := v.Update() + if err != nil { + return err + } + return v.Render() +} + +// Render flushes the state objects to the screen. +func (v *Debug) Render() error { + logrus.Tracef("view.Render() %s", v.Name()) + + v.gui.Update(func(g *gocui.Gui) error { + // update header... + v.header.Clear() + width, _ := g.Size() + headerStr := format.RenderHeader("Debug", width, false) + _, _ = fmt.Fprintln(v.header, headerStr) + + // update view... + v.view.Clear() + _, err := fmt.Fprintln(v.view, "blerg") + if err != nil { + logrus.Debug("unable to write to buffer: ", err) + } + + return nil + }) + return nil +} + +func (v *Debug) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { + logrus.Tracef("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name()) + + // header + headerSize := 1 + // note: maxY needs to account for the (invisible) border, thus a +1 + header, headerErr := g.SetView(v.Name()+"header", minX, minY, maxX, minY+headerSize+1) + // we are going to overlap the view over the (invisible) border (so minY will be one less than expected). + // additionally, maxY will be bumped by one to include the border + view, viewErr := g.SetView(v.Name(), minX, minY+headerSize, maxX, maxY+1) + if utils.IsNewView(viewErr, headerErr) { + err := v.Setup(view, header) + if err != nil { + logrus.Error("unable to setup debug controller", err) + return err + } + } + return nil +} + +func (v *Debug) RequestedSize(available int) *int { + return nil +} diff --git a/runtime/ui/view/details.go b/runtime/ui/view/details.go index 4611aa4..95ee962 100644 --- a/runtime/ui/view/details.go +++ b/runtime/ui/view/details.go @@ -29,11 +29,11 @@ type Details struct { } // newDetailsView creates a new view object attached the the global [gocui] screen object. -func newDetailsView(name string, gui *gocui.Gui, efficiency float64, inefficiencies filetree.EfficiencySlice, imageSize uint64) (controller *Details) { +func newDetailsView(gui *gocui.Gui, efficiency float64, inefficiencies filetree.EfficiencySlice, imageSize uint64) (controller *Details) { controller = new(Details) // populate main fields - controller.name = name + controller.name = "details" controller.gui = gui controller.efficiency = efficiency controller.inefficiencies = inefficiencies diff --git a/runtime/ui/view/filetree.go b/runtime/ui/view/filetree.go index 646973f..d306b97 100644 --- a/runtime/ui/view/filetree.go +++ b/runtime/ui/view/filetree.go @@ -39,12 +39,12 @@ type FileTree struct { } // newFileTreeView creates a new view object attached the the global [gocui] screen object. -func newFileTreeView(name string, gui *gocui.Gui, tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.Comparer) (controller *FileTree, err error) { +func newFileTreeView(gui *gocui.Gui, tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.Comparer) (controller *FileTree, err error) { controller = new(FileTree) controller.listeners = make([]ViewOptionChangeListener, 0) // populate main fields - controller.name = name + controller.name = "filetree" controller.gui = gui controller.vm, err = viewmodel.NewFileTreeViewModel(tree, refTrees, cache) if err != nil { @@ -377,7 +377,6 @@ func (v *FileTree) Render() error { if v.vm.ShowAttributes { headerStr += fmt.Sprintf(filetree.AttributeFormat+" %s", "P", "ermission", "UID:GID", "Size", "Filetree") } - _, _ = fmt.Fprintln(v.header, headerStr, false) // update the contents diff --git a/runtime/ui/view/filter.go b/runtime/ui/view/filter.go index d704ad8..e8f911b 100644 --- a/runtime/ui/view/filter.go +++ b/runtime/ui/view/filter.go @@ -27,13 +27,13 @@ type Filter struct { } // newFilterView creates a new view object attached the the global [gocui] screen object. -func newFilterView(name string, gui *gocui.Gui) (controller *Filter) { +func newFilterView(gui *gocui.Gui) (controller *Filter) { controller = new(Filter) controller.filterEditListeners = make([]FilterEditListener, 0) // populate main fields - controller.name = name + controller.name = "filter" controller.gui = gui controller.labelStr = "Path Filter: " controller.hidden = true diff --git a/runtime/ui/view/layer.go b/runtime/ui/view/layer.go index 8f1ec9e..578689d 100644 --- a/runtime/ui/view/layer.go +++ b/runtime/ui/view/layer.go @@ -31,13 +31,13 @@ type Layer struct { } // newLayerView creates a new view object attached the the global [gocui] screen object. -func newLayerView(name string, gui *gocui.Gui, layers []*image.Layer) (controller *Layer, err error) { +func newLayerView(gui *gocui.Gui, layers []*image.Layer) (controller *Layer, err error) { controller = new(Layer) controller.listeners = make([]LayerChangeListener, 0) // populate main fields - controller.name = name + controller.name = "layer" controller.gui = gui controller.Layers = layers diff --git a/runtime/ui/view/status.go b/runtime/ui/view/status.go index 59a9a53..87a4b46 100644 --- a/runtime/ui/view/status.go +++ b/runtime/ui/view/status.go @@ -25,11 +25,11 @@ type Status struct { } // newStatusView creates a new view object attached the the global [gocui] screen object. -func newStatusView(name string, gui *gocui.Gui) (controller *Status) { +func newStatusView(gui *gocui.Gui) (controller *Status) { controller = new(Status) // populate main fields - controller.name = name + controller.name = "status" controller.gui = gui controller.helpKeys = make([]*key.Binding, 0) controller.requestedHeight = 1 diff --git a/runtime/ui/view/views.go b/runtime/ui/view/views.go index 55ec6f6..71d3787 100644 --- a/runtime/ui/view/views.go +++ b/runtime/ui/view/views.go @@ -12,29 +12,31 @@ type Views struct { Status *Status Filter *Filter Details *Details - all []*Renderer + Debug *Debug } func NewViews(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Comparer) (*Views, error) { - Layer, err := newLayerView("layers", g, analysis.Layers) + Layer, err := newLayerView(g, analysis.Layers) if err != nil { return nil, err } treeStack := analysis.RefTrees[0] - Tree, err := newFileTreeView("filetree", g, treeStack, analysis.RefTrees, cache) + Tree, err := newFileTreeView(g, treeStack, analysis.RefTrees, cache) if err != nil { return nil, err } - Status := newStatusView("status", g) + Status := newStatusView(g) // set the layer view as the first selected view Status.SetCurrentView(Layer) - Filter := newFilterView("filter", g) + Filter := newFilterView(g) - Details := newDetailsView("details", g, analysis.Efficiency, analysis.Inefficiencies, analysis.SizeBytes) + Details := newDetailsView(g, analysis.Efficiency, analysis.Inefficiencies, analysis.SizeBytes) + + Debug := newDebugView(g) return &Views{ Tree: Tree, @@ -42,6 +44,7 @@ func NewViews(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Compa Status: Status, Filter: Filter, Details: Details, + Debug: Debug, }, nil }