From 0e71d0bc23d52364d24d1d8dadfa68f3a46eae7c Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Sun, 22 Feb 2026 13:07:18 -0500 Subject: [PATCH] test: add image details perf regression coverage --- .../cli/internal/ui/v1/view/image_details.go | 32 +---- .../ui/v1/view/image_details_content.go | 48 +++++++ .../v1/view/image_details_regression_test.go | 121 ++++++++++++++++++ 3 files changed, 173 insertions(+), 28 deletions(-) create mode 100644 cmd/dive/cli/internal/ui/v1/view/image_details_content.go create mode 100644 cmd/dive/cli/internal/ui/v1/view/image_details_regression_test.go diff --git a/cmd/dive/cli/internal/ui/v1/view/image_details.go b/cmd/dive/cli/internal/ui/v1/view/image_details.go index a119ba7..74a1249 100644 --- a/cmd/dive/cli/internal/ui/v1/view/image_details.go +++ b/cmd/dive/cli/internal/ui/v1/view/image_details.go @@ -6,11 +6,8 @@ import ( "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/key" "github.com/wagoodman/dive/internal/log" - "strconv" - "strings" "github.com/awesome-gocui/gocui" - "github.com/dustin/go-humanize" "github.com/wagoodman/dive/dive/filetree" ) @@ -80,22 +77,11 @@ func (v *ImageDetails) Setup(body, header *gocui.View) error { // 2. the estimated wasted image space // 3. a list of inefficient file allocations func (v *ImageDetails) Render() error { - analysisTemplate := "%5s %12s %-s\n" - inefficiencyReport := fmt.Sprintf(format.Header(analysisTemplate), "Count", "Total Space", "Path") - - var wastedSpace int64 - for idx := 0; idx < len(v.inefficiencies); idx++ { - data := v.inefficiencies[len(v.inefficiencies)-1-idx] - wastedSpace += data.CumulativeSize - - inefficiencyReport += fmt.Sprintf(analysisTemplate, strconv.Itoa(len(data.Nodes)), humanize.Bytes(uint64(data.CumulativeSize)), data.Path) + body, err := renderImageDetailsBody(v.imageName, v.imageSize, v.efficiency, v.inefficiencies) + if err != nil { + return err } - imageNameStr := fmt.Sprintf("%s %s", format.Header("Image name:"), v.imageName) - imageSizeStr := fmt.Sprintf("%s %s", format.Header("Total Image size:"), humanize.Bytes(v.imageSize)) - efficiencyStr := fmt.Sprintf("%s %d %%", format.Header("Image efficiency score:"), int(100.0*v.efficiency)) - wastedSpaceStr := fmt.Sprintf("%s %s", format.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace))) - v.gui.Update(func(g *gocui.Gui) error { width, _ := v.body.Size() @@ -106,18 +92,8 @@ func (v *ImageDetails) Render() error { if err != nil { log.WithFields("error", err).Debug("unable to write to buffer") } - - var lines = []string{ - imageNameStr, - imageSizeStr, - wastedSpaceStr, - efficiencyStr, - " ", // to avoid an empty line so CursorDown can work as expected - inefficiencyReport, - } - v.body.Clear() - _, err = fmt.Fprintln(v.body, strings.Join(lines, "\n")) + _, err = fmt.Fprintln(v.body, body) if err != nil { log.WithFields("error", err).Debug("unable to write to buffer") } diff --git a/cmd/dive/cli/internal/ui/v1/view/image_details_content.go b/cmd/dive/cli/internal/ui/v1/view/image_details_content.go new file mode 100644 index 0000000..ac76500 --- /dev/null +++ b/cmd/dive/cli/internal/ui/v1/view/image_details_content.go @@ -0,0 +1,48 @@ +package view + +import ( + "fmt" + "strconv" + "strings" + + "github.com/dustin/go-humanize" + "github.com/wagoodman/dive/cmd/dive/cli/internal/ui/v1/format" + "github.com/wagoodman/dive/dive/filetree" +) + +const imageDetailsAnalysisTemplate = "%5s %12s %-s\n" + +// NOTE: this intentionally retains the original string concatenation behavior. +// A regression test will fail until this is optimized in a follow-up commit. +func renderImageDetailsBody(imageName string, imageSize uint64, efficiency float64, inefficiencies filetree.EfficiencySlice) (string, error) { + inefficiencyReport := fmt.Sprintf(format.Header(imageDetailsAnalysisTemplate), "Count", "Total Space", "Path") + + var wastedSpace int64 + for idx := 0; idx < len(inefficiencies); idx++ { + data := inefficiencies[len(inefficiencies)-1-idx] + wastedSpace += data.CumulativeSize + + inefficiencyReport += fmt.Sprintf( + imageDetailsAnalysisTemplate, + strconv.Itoa(len(data.Nodes)), + humanize.Bytes(uint64(data.CumulativeSize)), + data.Path, + ) + } + + imageNameStr := fmt.Sprintf("%s %s", format.Header("Image name:"), imageName) + imageSizeStr := fmt.Sprintf("%s %s", format.Header("Total Image size:"), humanize.Bytes(imageSize)) + efficiencyStr := fmt.Sprintf("%s %d %%", format.Header("Image efficiency score:"), int(100.0*efficiency)) + wastedSpaceStr := fmt.Sprintf("%s %s", format.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace))) + + var lines = []string{ + imageNameStr, + imageSizeStr, + wastedSpaceStr, + efficiencyStr, + " ", // to avoid an empty line so CursorDown can work as expected + inefficiencyReport, + } + + return strings.Join(lines, "\n"), nil +} diff --git a/cmd/dive/cli/internal/ui/v1/view/image_details_regression_test.go b/cmd/dive/cli/internal/ui/v1/view/image_details_regression_test.go new file mode 100644 index 0000000..272ed7f --- /dev/null +++ b/cmd/dive/cli/internal/ui/v1/view/image_details_regression_test.go @@ -0,0 +1,121 @@ +package view + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "runtime" + "strconv" + "testing" + "time" + + "github.com/lunixbochs/vtclean" + "github.com/stretchr/testify/require" + "github.com/wagoodman/dive/dive/filetree" +) + +func imageDetailsContentSourcePath(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok, "unable to determine test file path") + return filepath.Join(filepath.Dir(thisFile), "image_details_content.go") +} + +func imageDetailsContentAST(t *testing.T) *ast.File { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, imageDetailsContentSourcePath(t), nil, parser.ParseComments) + require.NoError(t, err, "unable to parse image_details_content.go") + return file +} + +func funcDeclByName(file *ast.File, funcName string) *ast.FuncDecl { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name == nil || fn.Name.Name != funcName || fn.Recv != nil { + continue + } + return fn + } + return nil +} + +// Regression guard: report construction must not use repeated string appends +// (`+=`) in loops, which can reintroduce O(n^2) behavior. +func TestRenderImageDetailsBodyAvoidsInLoopStringAddAssign(t *testing.T) { + file := imageDetailsContentAST(t) + fn := funcDeclByName(file, "renderImageDetailsBody") + require.NotNil(t, fn, "missing renderImageDetailsBody declaration") + + found := false + ast.Inspect(fn.Body, func(node ast.Node) bool { + assign, ok := node.(*ast.AssignStmt) + if !ok || assign.Tok != token.ADD_ASSIGN || len(assign.Lhs) != 1 { + return true + } + + lhs, ok := assign.Lhs[0].(*ast.Ident) + if ok && lhs.Name == "inefficiencyReport" { + found = true + return false + } + return true + }) + + require.False(t, found, "renderImageDetailsBody reintroduced inefficiencyReport += ... in-loop concatenation") +} + +func TestRenderImageDetailsBodyIncludesExpectedContent(t *testing.T) { + body, err := renderImageDetailsBody( + "ghcr.io/openclaw/openclaw:test", + 10*1024*1024, + 0.72, + filetree.EfficiencySlice{ + {Path: "/a", Nodes: []*filetree.FileNode{{}, {}, {}}, CumulativeSize: 40}, + {Path: "/b", Nodes: []*filetree.FileNode{{}}, CumulativeSize: 60}, + }, + ) + require.NoError(t, err) + + clean := vtclean.Clean(body, false) + require.Contains(t, clean, "Image name:") + require.Contains(t, clean, "ghcr.io/openclaw/openclaw:test") + require.Contains(t, clean, "Total Image size:") + require.Contains(t, clean, "Potential wasted space:") + require.Contains(t, clean, "Image efficiency score:") + require.Contains(t, clean, "/a") + require.Contains(t, clean, "/b") +} + +// Integration-style perf check: rendering should scale near-linearly as +// inefficiency row counts grow. +func TestRenderImageDetailsBodyScaling(t *testing.T) { + if testing.Short() { + t.Skip("skipping perf-sensitive scaling check in short mode") + } + + buildDuration := func(entries, rounds int) time.Duration { + payload := make(filetree.EfficiencySlice, 0, entries) + for i := 0; i < entries; i++ { + payload = append(payload, &filetree.EfficiencyData{ + Path: "/path/" + strconv.Itoa(i), + Nodes: make([]*filetree.FileNode, (i%3)+1), + CumulativeSize: int64(64 + i%1024), + }) + } + + start := time.Now() + for i := 0; i < rounds; i++ { + _, err := renderImageDetailsBody("img:test", 1234, 0.9, payload) + require.NoError(t, err) + } + return time.Since(start) + } + + small := buildDuration(1200, 10) + large := buildDuration(2400, 10) + ratio := float64(large) / float64(small) + + require.Less(t, ratio, 3.2, "expected near-linear scaling, got ratio %.2f (small=%s large=%s)", ratio, small, large) +}