Files
schollz-progressbar/progressbar_test.go
T
dualfroz cf25c2235d fix: do not corrupt state when Add/Set overshoots max
Add64 checked currentNum > max only after already mutating currentNum
and currentBytes, so a single call that overshoots max returned an
error but left the bar stuck: currentNum frozen above max, percent
stuck above 100%, and every later Add/Set call failing with the same
error forever, since the top-of-function currentNum < max guard could
never pass again.

Check the bound before mutating state instead, so an overshooting call
is rejected as a whole and leaves currentNum/currentBytes/currentPercent
exactly as they were. The now-unreachable post-hoc check is removed.

Add TestAddExceedsMaxDoesNotCorruptState covering the corrupted-state
case and that the bar keeps accepting valid progress afterwards.
2026-09-06 00:44:44 +02:00

1372 lines
38 KiB
Go

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 untouched, not partially applied.
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)
}
}