package progressbar import ( "bytes" "context" "crypto/md5" "encoding/hex" "encoding/json" "fmt" "io" "log" "net" "net/http" "os" "strings" "sync" "testing" "time" "github.com/chengxilo/virtualterm" "github.com/stretchr/testify/assert" ) func TestMain(m *testing.M) { termWidth = func(w io.Writer) (int, error) { return 0, os.ErrPermission } os.Exit(m.Run()) } func BenchmarkRender(b *testing.B) { bar := NewOptions64(100000000, OptionSetWriter(os.Stderr), OptionShowIts(), ) for i := 0; i < b.N; i++ { bar.Add(1) } } func ExampleProgressBar() { bar := New(100) bar.Add(10) // Output: // 10% |████ | [0s:0s] } func ExampleProgressBar_Set() { bar := New(100) bar.Set(10) // Output: // 10% |████ | [0s:0s] } func ExampleProgressBar_Set64() { bar := New(100) bar.Set64(10) // Output: // 10% |████ | [0s:0s] } func ExampleProgressBar_basic() { bar := NewOptions(100, OptionSetWidth(10)) bar.Reset() time.Sleep(1 * time.Second) bar.Add(10) // Output: // 10% |█ | [1s:9s] } func ExampleProgressBar_invisible() { bar := NewOptions(100, OptionSetWidth(10), OptionSetRenderBlankState(true), OptionSetVisibility(false)) bar.Reset() fmt.Println("hello, world") time.Sleep(1 * time.Second) bar.Add(10) // Output: // hello, world } func ExampleOptionThrottle() { bar := NewOptions(100, OptionSetWidth(10), OptionThrottle(100*time.Millisecond)) bar.Reset() bar.Add(5) time.Sleep(150 * time.Millisecond) bar.Add(5) bar.Add(10) // Output: // 10% |█ | [0s:1s] } func ExampleOptionClearOnFinish() { bar := NewOptions(100, OptionSetWidth(10), OptionClearOnFinish()) bar.Reset() bar.Finish() fmt.Println("Finished") // Output: // Finished } func TestSpinnerClearOnFinish(t *testing.T) { buf := strings.Builder{} bar := NewOptions(-1, OptionSetWidth(100), OptionShowCount(), OptionShowBytes(true), OptionShowIts(), OptionClearOnFinish(), OptionSetWriter(&buf)) bar.Reset() time.Sleep(1 * time.Second) bar.Add(10) time.Sleep(1 * time.Second) bar.Finish() result, _ := virtualterm.Process(buf.String()) expect := " " if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } } func ExampleProgressBar_Finish() { bar := NewOptions(100, OptionSetWidth(10), OptionShowCount(), OptionShowBytes(true), OptionShowIts()) bar.Reset() time.Sleep(1 * time.Second) bar.Finish() // Output: // 100% |██████████| (100/100 B, 100 B/s, 100 it/s) } func TestSpinnerFinish(t *testing.T) { buf := strings.Builder{} bar := NewOptions(-1, OptionSetWidth(100), OptionShowCount(), OptionShowBytes(true), OptionShowIts(), OptionSetWriter(&buf)) bar.Reset() time.Sleep(1 * time.Second) bar.Add(10) time.Sleep(1 * time.Second) bar.Finish() result, err := virtualterm.Process(buf.String()) if err != nil { t.Error(err) } // the "\r \r" expect := "| (10 B, 5 B/s, 5 it/s) [2s] " if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } } func Example_xOutOfY() { bar := NewOptions(100, OptionSetPredictTime(true)) for i := 0; i < 100; i++ { bar.Add(1) time.Sleep(1 * time.Millisecond) } } func ExampleOptionShowIts_count() { bar := NewOptions(100, OptionSetWidth(10), OptionShowIts(), OptionShowCount()) bar.Reset() time.Sleep(1 * time.Second) bar.Add(10) // Output: // 10% |█ | (10/100, 10 it/s) [1s:9s] } func ExampleOptionShowIts() { bar := NewOptions(100, OptionSetWidth(10), OptionShowIts(), OptionSetPredictTime(false)) bar.Reset() time.Sleep(1 * time.Second) bar.Add(10) // Output: // 10% |█ | (10 it/s) } func ExampleOptionShowCount_minuscule() { bar := NewOptions(10000, OptionSetWidth(10), OptionShowCount(), OptionSetPredictTime(false)) bar.Add(1) // Output: // 0% | | (1/10000) } func ExampleOptionSetPredictTime() { bar := NewOptions(100, OptionSetWidth(10), OptionSetPredictTime(false)) _ = bar.Add(10) // Output: // 10% |█ | } func ExampleOptionShowDescriptionAtLineEnd() { bar := NewOptions(100, OptionSetWidth(10), OptionShowDescriptionAtLineEnd(), OptionSetDescription("hello")) _ = bar.Add(10) // Output: // 10% |█ | [0s:0s] hello } func ExampleOptionShowDescriptionAtLineEnd_spinner() { bar := NewOptions(-1, OptionSetWidth(10), OptionShowDescriptionAtLineEnd(), OptionSetDescription("hello")) _ = bar.Add(1) // Output: // | [0s] hello } func ExampleDefault() { bar := Default(100) for i := 0; i < 50; i++ { bar.Add(1) time.Sleep(10 * time.Millisecond) } // Output: // } func ExampleProgressBar_ChangeMax() { bar := NewOptions(100, OptionSetWidth(10), OptionSetPredictTime(false)) bar.ChangeMax(50) bar.Add(50) // Output: // 100% |██████████| } func ExampleProgressBar_AddMax() { bar := NewOptions(50, OptionSetWidth(10), OptionSetPredictTime(false)) bar.AddMax(50) bar.Add(100) // Output: // 100% |██████████| } func ExampleOptionShowIts_spinner() { /* Spinner test with iteration count and iteration rate */ vt := virtualterm.NewDefault() bar := NewOptions(-1, OptionSetWidth(10), OptionShowIts(), OptionShowCount(), OptionSetWriter(&vt), ) bar.Reset() time.Sleep(1 * time.Second) bar.Add(5) bar.lock.Lock() s, err := vt.String() bar.lock.Unlock() if err != nil { log.Fatal(err) } fmt.Print(s) // Output: // - (5/-, 5 it/s) [1s] } func TestSpinnerType(t *testing.T) { bar := NewOptions(-1, OptionSetWidth(10), OptionSetDescription("indeterminate spinner"), OptionShowIts(), OptionShowCount(), OptionSpinnerType(9), ) bar.Reset() for i := 0; i < 10; i++ { time.Sleep(120 * time.Millisecond) err := bar.Add(1) if err != nil { t.Errorf("Successfully tested one spinner option can be used.") } } if false { t.Errorf("error") } } func TestSpinnerCustom(t *testing.T) { bar := NewOptions(-1, OptionSetWidth(10), OptionSetDescription("indeterminate spinner"), OptionShowIts(), OptionShowCount(), OptionSpinnerCustom([]string{"🐰", "🐰", "🥕", "🥕"}), ) bar.Reset() for i := 0; i < 10; i++ { time.Sleep(120 * time.Millisecond) err := bar.Add(1) if err != nil { t.Errorf("Successfully tested one spinner option can be used.") } } if false { t.Errorf("error") } } func TestSpinnerTypeAndCustom(t *testing.T) { bar := NewOptions(-1, OptionSetWidth(10), OptionSetDescription("indeterminate spinner"), OptionShowIts(), OptionShowCount(), OptionSpinnerCustom([]string{"🐰", "🐰", "🥕", "🥕"}), OptionSpinnerType(9), ) bar.Reset() for i := 0; i < 10; i++ { time.Sleep(120 * time.Millisecond) err := bar.Add(1) if err == nil { t.Errorf("Successfully tested both spinner options cannot be used together.") } } } func Test_IsFinished(t *testing.T) { isCalled := false bar := NewOptions(72, OptionOnCompletion(func() { isCalled = true })) // Test1: If bar is not fully completed. bar.Add(5) if bar.IsFinished() || isCalled { t.Errorf("Successfully tested bar is not yet finished.") } // Test2: Bar fully completed. bar.Add(67) if !bar.IsFinished() || !isCalled { t.Errorf("Successfully tested bar is finished.") } // Test3: If increases maximum bytes error should be thrown and // bar finished will remain false. bar.Reset() err := bar.Add(73) if err == nil || bar.IsFinished() { t.Errorf("Successfully got error when bytes increases max bytes, bar finished: %v", bar.IsFinished()) } } func ExampleOptionShowBytes_spinner() { /* Spinner test with iterations and count */ buf := strings.Builder{} bar := NewOptions(-1, OptionSetWidth(10), OptionShowBytes(true), OptionSetWriter(&buf), ) bar.Reset() time.Sleep(1 * time.Second) // since 10 is the width and we don't know the max bytes // it will do a infinite scrolling. bar.Add(11) bar.lock.Lock() result, _ := virtualterm.Process(buf.String()) bar.lock.Unlock() fmt.Print(result) // Output: // - (11 B/s) [1s] } func TestBarSlowAdd(t *testing.T) { buf := strings.Builder{} bar := NewOptions(100, OptionSetWidth(10), OptionShowIts(), OptionSetWriter(&buf)) bar.Reset() time.Sleep(3 * time.Second) bar.Add(1) if !strings.Contains(buf.String(), "1%") { t.Errorf("wrong string: %s", buf.String()) } if !strings.Contains(buf.String(), "20 it/min") { t.Errorf("wrong string: %s", buf.String()) } if !strings.Contains(buf.String(), "[3s:") { t.Errorf("wrong string: %s", buf.String()) } // Output: // 1% | | (20 it/min) [3s:4m57s] } func TestBarSmallBytes(t *testing.T) { buf := strings.Builder{} bar := NewOptions64(100000000, OptionShowBytes(true), OptionShowCount(), OptionSetWidth(10), OptionSetWriter(&buf)) for i := 1; i < 10; i++ { time.Sleep(100 * time.Millisecond) bar.Add(1000) } if !strings.Contains(buf.String(), "9.0 kB/100 MB") { t.Errorf("wrong string: %s", buf.String()) } for i := 1; i < 10; i++ { time.Sleep(10 * time.Millisecond) bar.Add(1000000) } if !strings.Contains(buf.String(), "9.0/100 MB") { t.Errorf("wrong string: %s", buf.String()) } } func TestBarFastBytes(t *testing.T) { buf := strings.Builder{} bar := NewOptions64(1e8, OptionShowBytes(true), OptionShowCount(), OptionSetWidth(10), OptionSetWriter(&buf)) bar.StartWithoutRender() time.Sleep(time.Millisecond) bar.Add(1e7) if !strings.Contains(buf.String(), " GB/s)") { t.Errorf("wrong string: %s", buf.String()) } } func TestBar(t *testing.T) { bar := New(0) if err := bar.Add(1); err == nil { t.Error("should have an error for 0 bar") } bar = New(10) if err := bar.Add(11); err == nil { t.Error("should have an error for adding > bar") } } func TestAddExceedsMaxDoesNotCorruptState(t *testing.T) { bar := NewOptions(100, OptionSetWriter(io.Discard)) if err := bar.Add(95); err != nil { t.Fatalf("unexpected error: %v", err) } if err := bar.Add(10); err == nil { t.Error("should have an error for adding past max") } // A rejected Add must leave state exactly as it was before the call, // not partially applied, so the bar is not left stuck above 100%. if bar.state.currentNum != 95 { t.Errorf("expected currentNum to stay at 95 after a rejected Add, got %d", bar.state.currentNum) } if bar.state.currentPercent != 95 { t.Errorf("expected currentPercent to stay at 95 after a rejected Add, got %d", bar.state.currentPercent) } // The bar must still accept further, non-overshooting progress afterwards. if err := bar.Add(5); err != nil { t.Errorf("bar should still accept progress after a rejected Add, got error: %v", err) } if bar.state.currentNum != 100 { t.Errorf("expected currentNum to reach 100, got %d", bar.state.currentNum) } } func TestState(t *testing.T) { bar := NewOptions(100, OptionSetWidth(10)) bar.Reset() time.Sleep(1 * time.Second) bar.Add(10) s := bar.State() if s.CurrentPercent != 0.1 { t.Error(s) } } func TestStartingBytes(t *testing.T) { bar := NewOptions64(100, OptionSetWidth(10), OptionSetStartingBytes(80)) s := bar.State() if s.CurrentBytes != 80 { t.Errorf("expected CurrentBytes 80, got %v", s.CurrentBytes) } if s.CurrentPercent != 0.8 { t.Errorf("expected CurrentPercent 0.8, got %v", s.CurrentPercent) } // Transfer 10 more over ~0.2s: the rate must reflect only those 10 units, // not the 80 seeded ones. Without the offset the fallback rate would be // 90/0.2 = 450/s; with it, ~50/s. bar = NewOptions64(100, OptionSetWidth(10), OptionSetStartingBytes(80), OptionSetWriter(io.Discard)) bar.Add(0) // start the clock at the seeded position time.Sleep(200 * time.Millisecond) bar.Add(10) s = bar.State() if rate := s.KBsPerSecond * 1024; rate > 200 { t.Errorf("rate should exclude seeded bytes, got %v/s", rate) } } func ExampleOptionSetRenderBlankState() { NewOptions(10, OptionSetWidth(10), OptionSetRenderBlankState(true)) // Output: // 0% | | [0s:0s] } func TestBasicSets(t *testing.T) { b := NewOptions( 999, OptionSetWidth(888), OptionSetRenderBlankState(true), OptionSetWriter(io.Discard), // suppressing output for this test ) tc := b.config if tc.max != 999 { t.Errorf("Expected %s to be %d, instead I got %d\n%+v", "max", 999, tc.max, b) } if tc.width != 888 { t.Errorf("Expected %s to be %d, instead I got %d\n%+v", "width", 999, tc.max, b) } if !tc.renderWithBlankState { t.Errorf("Expected %s to be %t, instead I got %t\n%+v", "renderWithBlankState", true, tc.renderWithBlankState, b) } } func TestOptionSetTheme(t *testing.T) { buf := strings.Builder{} bar := NewOptions( 10, OptionSetTheme( Theme{Saucer: "#", SaucerPadding: "-", BarStart: ">", BarEnd: "<"}), OptionSetWidth(10), OptionSetWriter(&buf), ) bar.RenderBlank() result := strings.TrimSpace(buf.String()) expect := "0% >----------<" if !strings.Contains(result, expect) { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } buf.Reset() bar.Add(5) result = strings.TrimSpace(buf.String()) expect = "50% >#####-----< [0s:0s]" if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } buf.Reset() bar.Finish() result = strings.TrimSpace(buf.String()) expect = "100% >##########<" if !strings.Contains(result, expect) { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } } func TestOptionSetThemeFilled(t *testing.T) { buf := strings.Builder{} bar := NewOptions( 10, OptionSetTheme( Theme{Saucer: "#", SaucerPadding: "-", BarStart: ">", BarStartFilled: "]", BarEnd: "<", BarEndFilled: "["}), OptionSetWidth(10), OptionSetWriter(&buf), ) bar.RenderBlank() result := strings.TrimSpace(buf.String()) expect := "0% >----------<" if !strings.Contains(result, expect) { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } buf.Reset() bar.Add(5) result = strings.TrimSpace(buf.String()) expect = "50% ]#####-----< [0s:0s]" if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } buf.Reset() bar.Finish() result = strings.TrimSpace(buf.String()) expect = "100% ]##########[" if !strings.Contains(result, expect) { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } } // TestOptionSetPredictTime ensures that when predict time is turned off, the progress // bar is showing the total steps completed of the given max, otherwise the predicted // time in seconds is specified. func TestOptionSetPredictTime(t *testing.T) { buf := strings.Builder{} bar := NewOptions( 10, OptionSetPredictTime(false), OptionSetWidth(10), OptionSetWriter(&buf), ) _ = bar.Add(2) result := strings.TrimSpace(buf.String()) expect := "20% |██ |" if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } bar.Reset() bar.config.predictTime = true buf.Reset() _ = bar.Add(7) result = strings.TrimSpace(buf.String()) expect = "70% |███████ | [0s:0s]" if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } } func TestOptionSetElapsedTime_spinner(t *testing.T) { buf := strings.Builder{} bar := NewOptions(-1, OptionSetWidth(10), OptionSetWriter(&buf), OptionShowIts(), OptionShowCount(), OptionSetElapsedTime(false), ) bar.Reset() time.Sleep(1 * time.Second) bar.Add(5) bar.lock.Lock() result, err := virtualterm.Process(buf.String()) bar.lock.Unlock() result = strings.TrimSpace(result) if err != nil { t.Fatal(err) } expect := "- (5/-, 5 it/s)" if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } } func TestOptionSetElapsedTime(t *testing.T) { buf := strings.Builder{} bar := NewOptions( 10, OptionSetElapsedTime(false), OptionSetPredictTime(false), OptionSetWidth(10), OptionSetWriter(&buf), ) _ = bar.Add(2) result := strings.TrimSpace(buf.String()) expect := "20% |██ |" if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } bar.Reset() bar.config.elapsedTime = true buf.Reset() _ = bar.Add(7) result = strings.TrimSpace(buf.String()) expect = "70% |███████ | [0s]" if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } } func TestShowElapsedTimeOnFinish(t *testing.T) { buf := strings.Builder{} bar := NewOptions(10, OptionShowElapsedTimeOnFinish(), OptionSetWidth(10), OptionSetWriter(&buf), ) bar.Reset() time.Sleep(3 * time.Second) bar.Add(10) result := strings.TrimSpace(buf.String()) expect := "100% |██████████| [3s]" if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } } func TestSpinnerState(t *testing.T) { bar := NewOptions( -1, OptionSetWidth(100), ) bar.Reset() time.Sleep(1 * time.Second) bar.Add(10) state := bar.State() if state.Max != -1 { t.Errorf("Max mismatched gotMax %d wantMax %d", state.Max, -1) } if state.CurrentNum != 10 { t.Errorf("Number mismatched gotNum %d wantNum %d", state.CurrentNum, 10) } if state.CurrentBytes != 10.0 { t.Errorf("Number of bytes mismatched gotBytes %f wantBytes %f", state.CurrentBytes, 10.0) } if state.CurrentPercent != 0.1 { t.Errorf("Percent of bar mismatched got %f want %f", state.CurrentPercent, 0.1) } kbPerSec := fmt.Sprintf("%2.2f", state.KBsPerSecond) if kbPerSec != "0.01" { t.Errorf("Speed mismatched got %s want %s", kbPerSec, "0.01") } } func TestReaderToBuffer(t *testing.T) { if testing.Short() { t.SkipNow() } urlToGet := "https://dl.google.com/go/go1.14.1.src.tar.gz" req, err := http.NewRequest("GET", urlToGet, nil) assert.Nil(t, err) resp, err := http.DefaultClient.Do(req) assert.Nil(t, err) defer resp.Body.Close() buf := new(bytes.Buffer) bar := NewOptions(int(resp.ContentLength), OptionShowBytes(true), OptionShowCount()) out := io.MultiWriter(buf, bar) _, err = io.Copy(out, resp.Body) assert.Nil(t, err) md5, err := md5sum(buf) assert.Nil(t, err) assert.Equal(t, "d441819a800f8c90825355dfbede7266", md5) } func TestReaderToFile(t *testing.T) { if testing.Short() { t.SkipNow() } urlToGet := "https://dl.google.com/go/go1.14.1.src.tar.gz" req, err := http.NewRequest("GET", urlToGet, nil) assert.Nil(t, err) resp, err := http.DefaultClient.Do(req) assert.Nil(t, err) defer resp.Body.Close() f, err := os.CreateTemp("", "progressbar_testfile") if err != nil { t.Fatal() } defer os.Remove(f.Name()) defer f.Close() realStdout := os.Stdout defer func() { os.Stdout = realStdout }() r, fakeStdout, err := os.Pipe() if err != nil { t.Fatal(err) } os.Stdout = fakeStdout bar := DefaultBytes(resp.ContentLength) out := io.MultiWriter(f, bar) _, err = io.Copy(out, resp.Body) assert.Nil(t, err) f.Sync() f.Seek(0, 0) if err := fakeStdout.Close(); err != nil { t.Fatal(err) } b, err := io.ReadAll(r) if err != nil { t.Fatal(err) } if err := r.Close(); err != nil { t.Fatal(err) } assert.Equal(t, "", string(b)) md5, err := md5sum(f) assert.Nil(t, err) assert.Equal(t, "d441819a800f8c90825355dfbede7266", md5) } func TestReaderToFileUnknownLength(t *testing.T) { if testing.Short() { t.SkipNow() } urlToGet := "https://dl.google.com/go/go1.14.1.src.tar.gz" req, err := http.NewRequest("GET", urlToGet, nil) assert.Nil(t, err) resp, err := http.DefaultClient.Do(req) assert.Nil(t, err) defer resp.Body.Close() f, err := os.CreateTemp("", "progressbar_testfile") if err != nil { t.Fatal() } defer os.Remove(f.Name()) defer f.Close() realStdout := os.Stdout defer func() { os.Stdout = realStdout }() r, fakeStdout, err := os.Pipe() if err != nil { t.Fatal(err) } os.Stdout = fakeStdout bar := DefaultBytes(-1, " downloading") out := io.MultiWriter(f, bar) _, err = io.Copy(out, resp.Body) assert.Nil(t, err) f.Sync() f.Seek(0, 0) if err := fakeStdout.Close(); err != nil { t.Fatal(err) } b, err := io.ReadAll(r) if err != nil { t.Fatal(err) } if err := r.Close(); err != nil { t.Fatal(err) } assert.Equal(t, "", string(b)) md5, err := md5sum(f) assert.Nil(t, err) assert.Equal(t, "d441819a800f8c90825355dfbede7266", md5) } func TestConcurrency(t *testing.T) { buf := strings.Builder{} bar := NewOptions( 1000, OptionSetWriter(&buf), ) var wg sync.WaitGroup for i := 0; i < 900; i++ { wg.Add(1) go func(b *ProgressBar, wg *sync.WaitGroup) { bar.Add(1) wg.Done() }(bar, &wg) } wg.Wait() result := bar.state.currentNum expect := int64(900) assert.Equal(t, expect, result) } func TestIterationNames(t *testing.T) { b := Default(20) tc := b.config // Checking for the default iterations per second or "it/s" if tc.iterationString != "it" { t.Errorf("Expected %s to be %s, instead I got %s", "iterationString", "it", tc.iterationString) } // Change the default "it/s" to provide context, downloads per second or "dl/s" b = NewOptions(20, OptionSetItsString("dl")) tc = b.config if tc.iterationString != "dl" { t.Errorf("Expected %s to be %s, instead I got %s", "iterationString", "dl", tc.iterationString) } } func md5sum(r io.Reader) (string, error) { hash := md5.New() _, err := io.Copy(hash, r) return hex.EncodeToString(hash.Sum(nil)), err } func TestProgressBar_Describe(t *testing.T) { buf := strings.Builder{} bar := NewOptions(100, OptionSetWidth(10), OptionSetWriter(&buf)) bar.Describe("performing axial adjustments") bar.Add(10) result := buf.String() expect := "" + "\rperforming axial adjustments 0% | | [0s:0s]" + "\r \r" + "\rperforming axial adjustments 10% |█ | [0s:0s]" if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } } func TestProgressBar_DescribeAfterFinish(t *testing.T) { buf := strings.Builder{} bar := NewOptions(100, OptionSetDescription("Uploading files"), OptionShowCount(), OptionSetWriter(&buf), OptionSetWidth(10), ) for i := 0; i < 100; i++ { bar.Add(1) } bar.Describe("Upload complete") result := buf.String() assert.Contains(t, result, "Upload complete") } func TestRenderBlankStateWithThrottle(t *testing.T) { buf := strings.Builder{} bar := NewOptions(100, OptionSetWidth(10), OptionSetRenderBlankState(true), OptionThrottle(time.Millisecond), OptionSetWriter(&buf)) result := strings.TrimSpace(buf.String()) expect := "0% | | [0s:0s]" if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } } func TestOptionFullWidth(t *testing.T) { var tests = []struct { opts []Option expected string }{ { // 1 []Option{}, "" + "\r 10% |██████ | [1s:9s]" + "\r \r" + "\r 100% |██████████████████████████████████████████████████████████████| ", }, { // 2 []Option{OptionSetDescription("Progress:")}, "" + "\rProgress: 10% |█████ | [1s:9s]" + "\r \r" + "\rProgress: 100% |█████████████████████████████████████████████████████| ", }, { // 3 []Option{OptionSetDescription("<1/5>"), OptionShowDescriptionAtLineEnd()}, "" + "\r 10% |█████ | [1s:9s] <1/5>" + "\r \r" + "\r 100% |████████████████████████████████████████████████████████| <1/5>", }, { // 4 []Option{OptionSetPredictTime(false)}, "" + "\r 10% |██████ | " + "\r \r" + "\r 100% |█████████████████████████████████████████████████████████████████████| ", }, { // 5 []Option{OptionSetPredictTime(false), OptionShowElapsedTimeOnFinish()}, "" + "\r 10% |██████ | " + "\r \r" + "\r 100% |████████████████████████████████████████████████████████████████| [2s] ", }, { // 6 []Option{OptionSetPredictTime(false), OptionSetElapsedTime(false)}, "" + "\r 10% |██████ | " + "\r \r" + "\r 100% |█████████████████████████████████████████████████████████████████████| ", }, { // 7 []Option{OptionShowIts()}, "" + "\r 10% |█████ | (10 it/s) [1s:9s]" + "\r \r" + "\r 100% |█████████████████████████████████████████████████████| (50 it/s)", }, { // 8 []Option{OptionShowCount()}, "" + "\r 10% |█████ | (10/100) [1s:9s]" + "\r \r" + "\r 100% |█████████████████████████████████████████████████████| (100/100)", }, { // 9 []Option{OptionShowIts(), OptionShowCount(), OptionShowElapsedTimeOnFinish()}, "" + "\r 10% |████ | (10/100, 10 it/s) [1s:9s]" + "\r \r" + "\r 100% |████████████████████████████████████████████| (100/100, 50 it/s) [2s]", }, { // 10 []Option{OptionSetDescription("Progress:"), OptionShowIts(), OptionShowCount()}, "" + "\rProgress: 10% |███ | (10/100, 10 it/s) [1s:9s]" + "\r \r" + "\rProgress: 100% |███████████████████████████████████| (100/100, 50 it/s)", }, { // 11 []Option{OptionSetDescription("<3/5>"), OptionShowIts(), OptionShowCount(), OptionShowElapsedTimeOnFinish(), OptionShowDescriptionAtLineEnd()}, "" + "\r 10% |███ | (10/100, 10 it/s) [1s:9s] <3/5>" + "\r \r" + "\r 100% |██████████████████████████████████████| (100/100, 50 it/s) [2s] <3/5>", }, { // 12 []Option{OptionShowIts(), OptionShowCount(), OptionSetPredictTime(false)}, "" + "\r 10% |█████ | (10/100, 10 it/s) " + "\r \r" + "\r 100% |███████████████████████████████████████████████████| (100/100, 50 it/s) ", }, { // 13 []Option{OptionShowIts(), OptionShowCount(), OptionSetPredictTime(false), OptionShowElapsedTimeOnFinish()}, "" + "\r 10% |████ | (10/100, 10 it/s) " + "\r \r" + "\r 100% |██████████████████████████████████████████████| (100/100, 50 it/s) [2s] ", }, { // 14 []Option{OptionShowIts(), OptionShowCount(), OptionSetPredictTime(false), OptionSetElapsedTime(false)}, "" + "\r 10% |█████ | (10/100, 10 it/s) " + "\r \r" + "\r 100% |███████████████████████████████████████████████████| (100/100, 50 it/s) ", }, } for i, test := range tests { test := test t.Run(fmt.Sprintf("%d", i+1), func(t *testing.T) { t.Parallel() buf := strings.Builder{} bar := NewOptions(100, append(test.opts, []Option{OptionFullWidth(), OptionSetWriter(&buf)}...)...) bar.StartWithoutRender() time.Sleep(1 * time.Second) bar.Add(10) time.Sleep(1 * time.Second) bar.Add(90) assert.Equal(t, test.expected, buf.String()) }) } } func TestFixedWidthBarFitsTerminal(t *testing.T) { oldTermWidth := termWidth termWidth = func(w io.Writer) (int, error) { return 50, nil } defer func() { termWidth = oldTermWidth }() buf := strings.Builder{} bar := NewOptions( 100, OptionSetWriter(&buf), OptionSetDescription("really-long-file-name.bin "), OptionSetWidth(20), OptionShowCount(), OptionSetPredictTime(false), ) bar.Add(1) assert.LessOrEqual(t, getStringWidth(bar.config, bar.String()), 50) assert.NotContains(t, bar.String(), "| |") } func TestHumanizeBytesSI(t *testing.T) { amount, suffix := humanizeBytes(float64(12.34)*1000*1000, false) assert.Equal(t, "12 MB", fmt.Sprintf("%s%s", amount, suffix)) amount, suffix = humanizeBytes(float64(56.78)*1000*1000*1000, false) assert.Equal(t, "57 GB", fmt.Sprintf("%s%s", amount, suffix)) } func TestHumanizeBytesIEC(t *testing.T) { amount, suffix := humanizeBytes(float64(12.34)*1024*1024, true) assert.Equal(t, "12 MiB", fmt.Sprintf("%s%s", amount, suffix)) amount, suffix = humanizeBytes(float64(56.78)*1024*1024*1024, true) assert.Equal(t, "57 GiB", fmt.Sprintf("%s%s", amount, suffix)) } func TestProgressBar_StartWithoutRender(t *testing.T) { buf := strings.Builder{} bar := NewOptions(100, OptionSetWriter(&buf)) time.Sleep(1 * time.Second) bar.StartWithoutRender() time.Sleep(1 * time.Second) bar.Add(10) result := strings.TrimSpace(buf.String()) expect := "10% |████ | [1s:9s]" if result != expect { t.Errorf("Render miss-match\nResult: '%s'\nExpect: '%s'\n%+v", result, expect, bar) } } func TestOptionSetSpinnerChangeInterval(t *testing.T) { interval := 1000 * time.Millisecond vt := virtualterm.NewDefault() actuals := make([]string, 0, 8) expecteds := []string{ "◐ test [0s]", "◓ test [1s]", "◑ test [2s]", "◒ test [3s]", "◐ test [4s]", "◓ test [5s]", "◑ test [6s]", "◒ test [7s]", } bar := NewOptions(-1, OptionSetDescription("test"), OptionSpinnerType(7), OptionSetWriter(&vt), OptionSetSpinnerChangeInterval(interval)) bar.Add(1) for i := 0; i < 8; i++ { bar.lock.Lock() s, _ := vt.String() bar.lock.Unlock() s = strings.TrimSpace(s) actuals = append(actuals, s) // sleep 50 ms more to make sure to go to next interval each time time.Sleep(1050 * time.Millisecond) } for i := range actuals { assert.Equal(t, expecteds[i], actuals[i]) } } func TestOptionSetSpinnerChangeIntervalZero(t *testing.T) { vt := virtualterm.NewDefault() bar := NewOptions(-1, OptionSetDescription("test"), OptionSpinnerType(7), OptionSetWriter(&vt), OptionSetSpinnerChangeInterval(0)) actuals := make([]string, 0, 5) expected := []string{ "◐ test [0s]", "◓ test [1s]", "◑ test [2s]", "◒ test [3s]", "◐ test [4s]", } for i := 0; i < 5; i++ { bar.Add(1) bar.lock.Lock() s, _ := vt.String() bar.lock.Unlock() _ = strings.TrimSpace(s) } for i := range actuals { assert.Equal(t, expected[i], actuals[i]) } } func TestOptionShowTotalFalseDeterminate(t *testing.T) { buf := strings.Builder{} bar := NewOptions64( 100000000, OptionShowBytes(true), OptionShowCount(), OptionSetWidth(10), OptionShowTotalBytes(false), OptionSetWriter(&buf), ) bar.Add(10000) if !strings.Contains(buf.String(), "10 kB, ") { t.Errorf("wrong string: %s", buf.String()) } } func TestOptionShowTotalFalseIndeterminate(t *testing.T) { buf := strings.Builder{} bar := NewOptions(-1, OptionSetWidth(10), OptionSetDescription("indeterminate spinner"), OptionShowIts(), OptionShowCount(), OptionSpinnerType(9), OptionShowTotalBytes(false), OptionSetWriter(&buf), ) bar.Add(10) if !strings.Contains(buf.String(), "10, ") { t.Errorf("wrong string: %s", buf.String()) } } func TestOptionShowTotalTrueIndeterminate(t *testing.T) { buf := strings.Builder{} bar := NewOptions(-1, OptionSetWidth(10), OptionSetDescription("indeterminate spinner"), OptionShowIts(), OptionShowCount(), OptionSpinnerType(9), OptionShowTotalBytes(true), OptionSetWriter(&buf), ) bar.Add(10) if !strings.Contains(buf.String(), "10/-, ") { t.Errorf("wrong string: %s", buf.String()) } } func freeTestHTTPAddr(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen for free port: %v", err) } defer listener.Close() return listener.Addr().String() } func getHTTPWithRetry(t *testing.T, url string) *http.Response { t.Helper() var lastErr error deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { resp, err := http.Get(url) if err == nil { return resp } lastErr = err time.Sleep(10 * time.Millisecond) } t.Fatalf("GET %s: %v", url, lastErr) return nil } func TestStartHTTPServer(t *testing.T) { bar := Default(10, "test") bar.Add(1) hostPort := freeTestHTTPAddr(t) svr := bar.StartHTTPServer(hostPort) // check plain text resp := getHTTPWithRetry(t, fmt.Sprintf("http://%s/desc", hostPort)) got, err := io.ReadAll(resp.Body) if err != nil { t.Error(err) } if string(got) != "1/10, 10.00%, 0s left" { t.Errorf("wrong string: %s", string(got)) } // check json resp = getHTTPWithRetry(t, fmt.Sprintf("http://%s/state", hostPort)) got, err = io.ReadAll(resp.Body) if err != nil { t.Error(err) } var result State err = json.Unmarshal(got, &result) if err != nil { t.Error(err) } if result.Max != bar.State().Max || result.CurrentNum != bar.State().CurrentNum { t.Errorf("wrong state: %v", result) } // shutdown server err = svr.Shutdown(context.Background()) if err != nil { t.Errorf("shutdown server failed: %v", err) } // start new bar server bar = Default(10, "test") bar.Add(1) svr = bar.StartHTTPServer(hostPort) err = svr.Close() if err != nil { t.Errorf("shutdown server failed: %v", err) } } func TestTrimRateSamples(t *testing.T) { base := time.Now() times := []time.Time{ base.Add(-10 * time.Second), base.Add(-5 * time.Second), base.Add(-2 * time.Second), base, } rates := []float64{1, 2, 3, 4} clone := func() ([]float64, []time.Time) { return append([]float64{}, rates...), append([]time.Time{}, times...) } // A 6s window keeps only the samples collected within the last 6 seconds. r, tt := clone() gotRates, gotTimes := trimRateSamples(r, tt, 6*time.Second, base) if len(gotRates) != 3 || gotRates[0] != 2 { t.Errorf("window trim: expected 3 samples starting at 2, got %v", gotRates) } if len(gotTimes) != len(gotRates) { t.Errorf("window trim: rates/times length mismatch: %d vs %d", len(gotRates), len(gotTimes)) } // A window shorter than the gap between samples keeps only the newest sample. r, tt = clone() gotRates, _ = trimRateSamples(r, tt, time.Nanosecond, base) if len(gotRates) != 1 || gotRates[0] != 4 { t.Errorf("tiny window: expected only the newest sample 4, got %v", gotRates) } // Without a window, at most maxLegacyRateSamples samples are kept. manyRates := make([]float64, maxLegacyRateSamples+5) manyTimes := make([]time.Time, maxLegacyRateSamples+5) for i := range manyRates { manyRates[i] = float64(i) manyTimes[i] = base.Add(time.Duration(i) * time.Second) } gotRates, _ = trimRateSamples(manyRates, manyTimes, 0, base) if len(gotRates) != maxLegacyRateSamples { t.Errorf("legacy trim: expected %d samples, got %d", maxLegacyRateSamples, len(gotRates)) } if gotRates[0] != 5 { t.Errorf("legacy trim: expected to keep the newest samples, first kept = %v", gotRates[0]) } } func TestRateAveragingWindow(t *testing.T) { forceSamples := func(bar *ProgressBar, n int) { for i := 0; i < n; i++ { // backdate the counter so each Add records a new rate sample bar.state.counterTime = time.Now().Add(-time.Second) if err := bar.Add(1); err != nil { t.Fatal(err) } } } // Without a window the number of retained rate samples is capped. legacy := NewOptions(1000000, OptionSetWriter(io.Discard)) forceSamples(legacy, maxLegacyRateSamples+15) if got := len(legacy.state.counterLastTenRates); got != maxLegacyRateSamples { t.Errorf("legacy: expected %d retained rate samples, got %d", maxLegacyRateSamples, got) } // With a window covering the whole test, every sample is retained, so the // count exceeds the legacy cap. windowed := NewOptions(1000000, OptionSetWriter(io.Discard), OptionSetRateAveragingWindow(time.Hour)) forceSamples(windowed, maxLegacyRateSamples+15) if got := len(windowed.state.counterLastTenRates); got <= maxLegacyRateSamples { t.Errorf("windowed: expected more than %d retained samples, got %d", maxLegacyRateSamples, got) } }