1 Commits
Author SHA1 Message Date
clawbot a04601f010 scan: show each phase as N of M, with progress, elapsed time and ETA, also while it waits (closes #112)
check / check (push) Canceled after 0s
Every scan display now starts with its phase and its place among the
five, as "[2/5] walk", and shows its progress, rate, elapsed time and
ETA. On a terminal hash, update and the content reads keep the
library's bar, which fills what the rest of the line leaves; load,
the walk and the content checks are a spinner whose text is cut to
the terminal's width. load counts the records first. The content
phase adds up the sizes of the matching groups, without the join and
sort, and says it is searching until the first record returns. The
walk's total is estimated from the records under the operands. Each
display is drawn from its own goroutine: several times a second on a
terminal, every 5 seconds in plain mode.

Judgement call: load and the content checks stay spinners once their totals are known.

Model: opus-5-5
2026-10-16 14:50:03 +00:00
4 changed files with 173 additions and 330 deletions

No files matched your search

+10 -15
View File
@@ -756,21 +756,16 @@ Example shape (exact layout is flexible, content is not):
Additional requirements: Additional requirements:
- On a terminal, a phase with an exact total (every phase but `walk`, once it - On a terminal, a phase with an exact total from its start (`hash`, `update`,
has counted what it will do) is drawn as its line followed by the progress-bar and the content phase's reads) is drawn as the progress-bar library's bar,
library's bar, 10 columns wide, which shows the percent in place of the line's with the phase and its place before it and the library's counts, rate, elapsed
own. Any other phase, and a phase whose bar has no room, is drawn as the time and ETA after it. Any other phase (`load`, `walk`, and the content
library's spinner followed by its line. Each display is drawn again several phase's checks) is drawn as the library's spinner followed by its line. Each
times a second, so its elapsed time keeps moving while a phase waits for its display is drawn again several times a second, so its elapsed time keeps
next item. moving while a phase waits for its next item.
- On a terminal no line reaches the terminal's last column, however narrow the - On a terminal every line fits the terminal's width: the bar takes the room the
terminal is. The phase and its place, the progress, the elapsed time and the rest of its line leaves, and a spinner's line is cut short where it would
ETA are kept whole: a line too wide for the terminal leaves out its rate first reach the terminal's last column.
(or what the phase is waiting for), and a bar with no room beside those four
gives way to the spinner. Only when those four alone are too wide, as on a
terminal narrower than about 70 columns hours into a scan of millions of
files, is the line cut short at the terminal's edge, so the end of the ETA is
lost first.
- When stderr is not a terminal (a pipe, a file, `/dev/null`), do not emit ANSI - When stderr is not a terminal (a pipe, a file, `/dev/null`), do not emit ANSI
redraws: print the line the moment each phase starts, every 5 seconds while it redraws: print the line the moment each phase starts, every 5 seconds while it
runs (whether or not an item has completed, and never more often), and once runs (whether or not an item has completed, and never more often), and once
+5 -8
View File
@@ -34,14 +34,11 @@
time and ETA: `load` counts the records first, the walk's total is estimated time and ETA: `load` counts the records first, the walk's total is estimated
from the records under the operands, and the content phase counts the records from the records under the operands, and the content phase counts the records
it checks from SQLite's grouping alone, then says it is searching the database it checks from SQLite's grouping alone, then says it is searching the database
until the first one is returned. On a terminal a phase with an exact total until the first one is returned. On a terminal `hash`, `update` and the
draws its line followed by the library's bar, and any other a spinner followed content phase's reads keep the library's bar and the other displays are a
by the line; every display is drawn again several times a second. No line spinner followed by the line; every display is drawn again several times a
reaches the terminal's last column, however narrow: a line too wide leaves out second, and every line fits the terminal's width. In plain mode the line is
its rate, then its bar, keeping the phase, progress, elapsed time and ETA printed every 5 seconds whether or not an item completed (2026-10-16,
whole, and is cut short only when those alone do not fit, as below about 70
columns late in a large scan. In plain mode the line is printed every 5
seconds whether or not an item completed (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/112) https://git.eeqj.de/sneak/sfdupes/issues/112)
- `scan`, `report` and `trees` first print a line on stderr naming the database - `scan`, `report` and `trees` first print a line on stderr naming the database
+102 -144
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"fmt" "fmt"
"math"
"os" "os"
"slices" "slices"
"sync" "sync"
@@ -23,28 +22,19 @@ const redrawInterval = 100 * time.Millisecond
// spinnerType selects the progressbar library's spinner style. // spinnerType selects the progressbar library's spinner style.
const spinnerType = 14 const spinnerType = 14
// barWidth is the width of the progressbar library's bar between its
// two ends, as in the example line in README "Progress".
const barWidth = 10
// percentScale converts a fraction to a percentage. // percentScale converts a fraction to a percentage.
const percentScale = 100 const percentScale = 100
// fallbackWidth is the width taken for a terminal whose width cannot be // fallbackWidth is the width the progressbar library takes a terminal
// read, as the progressbar library takes it. // to have when it cannot read it, as when stderr is not a terminal.
const fallbackWidth = 80 const fallbackWidth = 80
// spinnerMargin is the columns of a spinner's line besides its text: // spinnerMargin is the columns of a spinner's line besides its text:
// the spinner and the three spaces the library draws around the text, // the spinner and the three spaces the library draws around the text,
// and the terminal's last column, which every line stays clear of so // and the terminal's last column, which the line stays clear of, as the
// that no terminal wraps it. // library's bars do.
const spinnerMargin = 5 const spinnerMargin = 5
// barMargin is the columns of a bar's line besides its text: the
// percent and the bar the library draws after the text, the spaces
// around them, and the terminal's last column.
const barMargin = barWidth + 11
// stderrIsTTY reports whether stderr is attached to a terminal. // stderrIsTTY reports whether stderr is attached to a terminal.
func stderrIsTTY() bool { func stderrIsTTY() bool {
return term.IsTerminal(int(os.Stderr.Fd())) return term.IsTerminal(int(os.Stderr.Fd()))
@@ -77,32 +67,35 @@ type progressParams struct {
} }
// progress renders one phase's progress on stderr. On a TTY the // progress renders one phase's progress on stderr. On a TTY the
// progressbar library draws line, fitted to the terminal's width: after // progressbar library draws it. A phase with an exact total from its
// the library's spinner, or, for a phase with an exact total, before the // start gets the library's bar, with the phase and its place as the
// library's bar when the terminal has room for the bar beside it. It is // bar's description and the library's counts, rate, elapsed time and
// drawn again every redrawInterval, so its elapsed time keeps moving // ETA beside it; the bar takes the room the rest leaves on the line. Any
// while the phase waits for its next item. When stderr is not a TTY it // other phase gets the library's spinner, with line as its text, cut to
// emits no ANSI redraws: it prints line as the phase starts, every // the terminal's width. Either is drawn again every redrawInterval, so
// plainInterval while it runs, and as it ends. // its elapsed time keeps moving while the phase waits for its next
// item. When stderr is not a TTY it emits no ANSI redraws: it prints
// line as the phase starts, every plainInterval while it runs, and as it
// ends.
// //
// The draws come from a goroutine of their own, so mu guards the fields // The draws come from a goroutine of their own, so mu guards the fields
// below it. Every method must be called from the main goroutine. A nil // below it and every call on bar. Every method must be called from the
// *progress is a valid no-display receiver: every method is a no-op, so // main goroutine. A nil *progress is a valid no-display receiver: every
// batched database flushes during the hash phase can reuse the update // method is a no-op, so batched database flushes during the hash phase
// phase's helpers without rendering anything. // can reuse the update phase's helpers without rendering anything.
type progress struct { type progress struct {
width func() int // reads the terminal's width; nil when not a TTY bar *progressbar.ProgressBar // nil when stderr is not a TTY
spinner bool // bar is a spinner, with line as its text
stop chan struct{} // closed by finish to end the draws stop chan struct{} // closed by finish to end the draws
done chan struct{} // closed once the draws have ended done chan struct{} // closed once the draws have ended
mu sync.Mutex mu sync.Mutex
bar *progressbar.ProgressBar // what the library draws; nil until then
withBar bool // bar is the library's bar, not its spinner
label string label string
total int64 total int64
estimate bool estimate bool
waitingFor string waitingFor string
count int64 count int64
drawn int64 // the items bar has been given, unless a spinner
start time.Time // when the phase started, for the elapsed time start time.Time // when the phase started, for the elapsed time
rateStart time.Time // when the count started, for the rate rateStart time.Time // when the count started, for the rate
} }
@@ -110,26 +103,33 @@ type progress struct {
// newProgress starts the display of one phase on stderr. // newProgress starts the display of one phase on stderr.
func newProgress(params progressParams) *progress { func newProgress(params progressParams) *progress {
if stderrIsTTY() { if stderrIsTTY() {
return startProgress(params, terminalWidth, redrawInterval) return startProgress(params, true, redrawInterval)
} }
return startProgress(params, nil, plainInterval) return startProgress(params, false, plainInterval)
} }
// startProgress shows the phase params describes at once, and again // startProgress shows the phase params describes at once, with the
// every interval until finish: with the progressbar library on a // progressbar library when onTTY is set and as a plain line when not,
// terminal, whose width each draw reads by calling width, or as a plain // and again every interval until finish.
// line when width is nil. func startProgress(params progressParams, onTTY bool,
func startProgress(params progressParams, width func() int,
interval time.Duration, interval time.Duration,
) *progress { ) *progress {
now := time.Now() now := time.Now()
p := &progress{ p := &progress{
width: width, stop: make(chan struct{}), done: make(chan struct{}), stop: make(chan struct{}), done: make(chan struct{}),
label: params.label, total: params.total, estimate: params.estimate, label: params.label, total: params.total, estimate: params.estimate,
waitingFor: params.waitingFor, start: now, rateStart: now, waitingFor: params.waitingFor, start: now, rateStart: now,
} }
switch {
case onTTY && (params.total < 0 || params.estimate):
// The library's bar needs an exact total.
p.bar, p.spinner = newSpinner(), true
case onTTY:
p.bar = newBar(params.label, params.total)
}
// Show the zero state at once: the first item may take minutes, // Show the zero state at once: the first item may take minutes,
// and a phase must never look hung. // and a phase must never look hung.
p.draw() p.draw()
@@ -153,74 +153,65 @@ func startProgress(params progressParams, width func() int,
return p return p
} }
// newBar builds the library's bar for a phase with an exact total, with
// label as its description and the library's counts, rate, elapsed time
// and ETA beside it. The bar is as wide as the rest of the line leaves
// room for on the terminal.
func newBar(label string, total int64) *progressbar.ProgressBar {
return progressbar.NewOptions64(total,
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionSetDescription(label),
progressbar.OptionShowCount(),
progressbar.OptionShowIts(),
progressbar.OptionSetItsString("files"),
progressbar.OptionSetElapsedTime(true),
progressbar.OptionSetPredictTime(true),
progressbar.OptionShowElapsedTimeOnFinish(),
progressbar.OptionFullWidth(),
)
}
// newSpinner builds the library's spinner for a phase without an exact
// total, followed by the text draw gives it.
func newSpinner() *progressbar.ProgressBar {
return progressbar.NewOptions64(-1,
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionSpinnerType(spinnerType),
// The spinner turns at each draw, not on a timer of the
// library's own.
progressbar.OptionSetSpinnerChangeInterval(0),
// The text carries the elapsed time.
progressbar.OptionSetElapsedTime(false),
)
}
// draw shows the phase's progress: on a TTY in place of the line // draw shows the phase's progress: on a TTY in place of the line
// before, and otherwise as a plain line. // before, and otherwise as a plain line.
func (p *progress) draw() { func (p *progress) draw() {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.width == nil { switch {
text, _ := p.line(math.MaxInt, true) case p.bar == nil:
fmt.Fprintln(os.Stderr, text) fmt.Fprintln(os.Stderr, p.line())
case p.spinner:
return text := p.line()
} if room := terminalWidth() - spinnerMargin; len(text) > room {
text = text[:max(room, 0)]
width := p.width()
// A phase with an exact total gets the library's bar when its line,
// without the percent the bar shows, fits whole beside the bar. A
// total of 0 leaves the bar nothing to fill.
text, withBar := "", false
if p.total > 0 && !p.estimate {
text, withBar = p.line(width-barMargin, false)
}
if !withBar {
text, _ = p.line(width-spinnerMargin, true)
}
// The library makes a bar or a spinner. A display that turns from
// one to the other, as its line grows or the terminal is resized,
// gets a new one.
if p.bar == nil || p.withBar != withBar {
p.newBar(withBar)
}
if withBar {
_ = p.bar.Set64(p.count)
} }
p.bar.Describe(text) p.bar.Describe(text)
case p.count > p.drawn:
// The library measures its rate over the items it is given,
// so it is given items only when there are new ones: an
// empty add would count a wait as a rate of zero.
_ = p.bar.Add64(p.count - p.drawn)
p.drawn = p.count
default:
// No new items: the bar is drawn again all the same, with
// its elapsed time as of now.
p.bar.Describe(p.label)
} }
// newBar has the library make what draw draws: a bar for the phase's
// total when withBar is set, and a spinner when not. Either shows the
// text draw gives it, and nothing of its own besides the spinner, or
// the bar and its percent. p.mu must be held.
func (p *progress) newBar(withBar bool) {
if p.bar != nil {
// Erase what was drawn before.
_ = p.bar.Clear()
}
total := int64(-1) // the library's spinner
if withBar {
total = p.total
}
p.bar = progressbar.NewOptions64(total,
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionSetWidth(barWidth),
progressbar.OptionSpinnerType(spinnerType),
// The spinner turns at each draw, not on a timer of the
// library's own.
progressbar.OptionSetSpinnerChangeInterval(0),
// The text carries the elapsed time and the ETA.
progressbar.OptionSetElapsedTime(false),
progressbar.OptionSetPredictTime(false),
)
p.withBar = withBar
} }
// setTotal gives a phase that started without a total its total, once // setTotal gives a phase that started without a total its total, once
@@ -286,84 +277,51 @@ func (p *progress) finish() {
p.draw() p.draw()
if p.width != nil { if p.bar != nil {
// The display stays on the TTY as drawn; what follows starts // The display stays on the TTY as drawn; what follows starts
// on a line of its own. // on a line of its own.
fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr)
} }
} }
// line formats the phase's progress in at most room columns, as // line formats the phase's progress, as
// //
// [3/5] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, eta 13s // [3/5] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, eta 13s
// //
// without the percent when percent is false, as before the library's // with "~" before a total that is an estimate. A phase with no total
// bar, which shows its own. A phase with no total shows the items done // shows the items done instead, or what it waits for. p.mu must be
// instead, or what it waits for. // held.
// func (p *progress) line() string {
// The phase and its place, the progress, the elapsed time and the ETA
// are kept whole. A line wider than room leaves out the rate, or what
// the phase waits for; only if it is still wider is it cut at room, and
// line reports that it does not fit. Every part is ASCII, so its length
// is its width in columns. p.mu must be held.
func (p *progress) line(room int, percent bool) (string, bool) {
elapsed := time.Since(p.start).Round(time.Second) elapsed := time.Since(p.start).Round(time.Second)
// head and tail hold the parts kept whole; extra, between them, is
// the part left out.
var head, extra, tail string
if p.total < 0 && p.waitingFor != "" { if p.total < 0 && p.waitingFor != "" {
head, extra = p.label+": ", p.waitingFor return fmt.Sprintf("%s: %s, elapsed %s, eta unknown",
tail = fmt.Sprintf("elapsed %s, eta unknown", elapsed) p.label, p.waitingFor, elapsed)
} else { }
rate := 0.0 rate := 0.0
if s := time.Since(p.rateStart).Seconds(); s > 0 { if s := time.Since(p.rateStart).Seconds(); s > 0 {
rate = float64(p.count) / s rate = float64(p.count) / s
} }
head = p.label + ": " + p.itemsDone(percent) + ", " done := fmt.Sprintf("%d files", p.count)
extra = fmt.Sprintf("%.0f files/s", rate)
tail = fmt.Sprintf("elapsed %s, eta %s", elapsed, p.eta(rate))
}
full, kept := head+extra+", "+tail, head+tail
switch {
case len(full) <= room:
return full, true
case len(kept) <= room:
return kept, true
}
return kept[:max(room, 0)], false
}
// itemsDone formats the items done, as "[1234/5000] 25%", with "~"
// before a total that is an estimate and without the percent when
// percent is false, or as "1234 files" when the phase has no total.
// p.mu must be held.
func (p *progress) itemsDone(percent bool) string {
if p.total < 0 {
return fmt.Sprintf("%d files", p.count)
}
if p.total >= 0 {
approx := "" approx := ""
if p.estimate { if p.estimate {
approx = "~" approx = "~"
} }
done := fmt.Sprintf("[%d/%s%d]", p.count, approx, p.total)
if !percent {
return done
}
pct := float64(percentScale) pct := float64(percentScale)
if p.total > 0 { if p.total > 0 {
pct = percentScale * float64(p.count) / float64(p.total) pct = percentScale * float64(p.count) / float64(p.total)
} }
return fmt.Sprintf("%s %.0f%%", done, pct) done = fmt.Sprintf("[%d/%s%d] %.0f%%", p.count, approx, p.total, pct)
}
return fmt.Sprintf("%s: %s, %.0f files/s, elapsed %s, eta %s",
p.label, done, rate, elapsed, p.eta(rate))
} }
// eta returns the time the phase has left at rate items a second, or // eta returns the time the phase has left at rate items a second, or
+56 -163
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"math"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -115,66 +114,12 @@ func TestProgressLine(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
tc.p.start, tc.p.rateStart = ago, ago tc.p.start, tc.p.rateStart = ago, ago
if got, _ := tc.p.line(math.MaxInt, true); got != tc.want { if got := tc.p.line(); got != tc.want {
t.Errorf("line = %q, want %q", got, tc.want) t.Errorf("line = %q, want %q", got, tc.want)
} }
} }
} }
// TestLineFitsRoom checks how a line is fitted to the room it has, ten
// seconds into the phase: it leaves out the rate, or what the phase
// waits for, and is cut only when what is left is still too wide.
func TestLineFitsRoom(t *testing.T) {
t.Parallel()
ago := time.Now().Add(-10 * time.Second)
hash := &progress{
label: scanPhase("hash"), total: 100, count: 20,
start: ago, rateStart: ago,
}
content := &progress{
label: scanPhase("content"), total: -1,
waitingFor: "searching the database", start: ago, rateStart: ago,
}
cases := []struct {
p *progress
room int
percent bool
want string
fits bool
}{{
p: hash, room: 57, percent: true,
want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
fits: true,
}, {
p: hash, room: 56, percent: true,
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40s",
fits: true,
}, {
p: hash, room: 45, percent: true,
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40",
fits: false,
}, {
// Before the library's bar, which shows the percent.
p: hash, room: 57, percent: false,
want: "[3/5] hash: [20/100], 2 files/s, elapsed 10s, eta 40s",
fits: true,
}, {
p: content, room: 40, percent: true,
want: "[5/5] content: elapsed 10s, eta unknown",
fits: true,
}}
for _, tc := range cases {
got, fits := tc.p.line(tc.room, tc.percent)
if got != tc.want || fits != tc.fits {
t.Errorf("line(%d, %t) = %q, %t, want %q, %t",
tc.room, tc.percent, got, fits, tc.want, tc.fits)
}
}
}
// TestSetTotalStartsRate checks that setTotal starts a phase's rate, // TestSetTotalStartsRate checks that setTotal starts a phase's rate,
// and so its ETA, from when the total becomes known, not from when the // and so its ETA, from when the total becomes known, not from when the
// phase started waiting for it. // phase started waiting for it.
@@ -195,7 +140,7 @@ func TestSetTotalStartsRate(t *testing.T) {
p.count = 20 p.count = 20
want := "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s" want := "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s"
if got, _ := p.line(math.MaxInt, true); got != want { if got := p.line(); got != want {
t.Errorf("line = %q, want %q", got, want) t.Errorf("line = %q, want %q", got, want)
} }
} }
@@ -253,7 +198,7 @@ func TestPlainProgressPrintsWhileWaiting(t *testing.T) {
p := startProgress(progressParams{ p := startProgress(progressParams{
label: scanPhase("load"), total: -1, label: scanPhase("load"), total: -1,
waitingFor: "counting the records", waitingFor: "counting the records",
}, nil, testRedraw) }, false, testRedraw)
awaitOutput(t, stderr, awaitOutput(t, stderr,
"[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3) "[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3)
@@ -276,136 +221,84 @@ func lastFrame(out string) string {
} }
// TestTerminalRedrawsWhileWaiting checks that the terminal display of a // TestTerminalRedrawsWhileWaiting checks that the terminal display of a
// phase, spinner or bar, keeps being drawn while the phase waits for its // phase keeps being drawn while the phase waits for its next item, and
// next item, with the elapsed time as of now, and shows every item // shows every item completed before the wait: a spinner's line with
// completed before the wait. stderr is not a terminal here, so the // the elapsed time as of now, and the library's bar, which works out
// displays take it to be fallbackWidth wide. // its elapsed time at each draw.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalRedrawsWhileWaiting(t *testing.T) { func TestTerminalRedrawsWhileWaiting(t *testing.T) {
stderr := captureStderr(t) stderr := captureStderr(t)
cases := []struct { walk := startProgress(progressParams{label: scanPhase("walk"), total: -1},
params progressParams true, testRedraw)
want string // how the line shows a count of 50
}{
{progressParams{label: scanPhase("walk"), total: -1}, "50 files,"},
{progressParams{label: scanPhase("hash"), total: 100}, "[50/100],"},
}
for _, tc := range cases {
before := len(stderr())
since := func() string { return stderr()[before:] }
p := startProgress(tc.params, terminalWidth, testRedraw)
for range 50 { for range 50 {
p.increment() walk.increment()
} }
// An hour into the phase, without waiting an hour. // An hour into the phase, without waiting an hour.
p.mu.Lock() walk.mu.Lock()
p.start = p.start.Add(-time.Hour) walk.start = walk.start.Add(-time.Hour)
p.mu.Unlock() walk.mu.Unlock()
awaitOutput(t, since, "elapsed 1h0m0s", 1) awaitOutput(t, stderr, "elapsed 1h0m0s", 1)
p.finish() walk.finish()
if shown := lastFrame(since()); !strings.Contains(shown, tc.want) { if shown := lastFrame(stderr()); !strings.Contains(shown, "50 files,") {
t.Errorf("terminal shows %q, want %q", shown, tc.want) t.Errorf("terminal shows %q, want a count of 50", shown)
}
}
} }
// drawLate draws the display params describes on a terminal width hash := startProgress(progressParams{label: scanPhase("hash"), total: 100},
// columns wide as it starts and once more as it ends, with count items true, testRedraw)
// done two and a half hours into the phase, and returns what it wrote
// to stderr.
func drawLate(stderr func() string, params progressParams, width int,
count int64,
) string {
before := len(stderr())
// No timed draws come between the two. for range 50 {
p := startProgress(params, func() int { return width }, time.Hour) hash.increment()
// Without counting the items one by one or waiting the hours.
p.mu.Lock()
p.count = count
p.start = p.start.Add(-150 * time.Minute)
p.rateStart = p.start
p.mu.Unlock()
p.finish()
return stderr()[before:]
} }
// TestTerminalLinesKeepETA checks the line each display shows on an // Drawn with the 50 items, then twice more with none.
// 80-column terminal at a large scan's counts and times: the rate is awaitOutput(t, stderr, "(50/100,", 3)
// left out, so that the elapsed time and the ETA stay whole. hash.finish()
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalLinesKeepETA(t *testing.T) {
stderr := captureStderr(t)
cases := []struct {
params progressParams
want string
}{{
// Past its estimate, the walk cannot tell how long it has left.
params: progressParams{
label: scanPhase("walk"), total: 1200000, estimate: true,
},
want: "[2/5] walk: [1234567/~1200000] 103%, elapsed 2h30m0s, " +
"eta unknown",
}, {
params: progressParams{label: scanPhase("hash"), total: 9876543},
want: "[3/5] hash: [1234567/9876543] 12%, elapsed 2h30m0s, " +
"eta 17h30m0s",
}, {
params: progressParams{label: scanPhase("content"), total: 9876543},
want: "[5/5] content: [1234567/9876543] 12%, elapsed 2h30m0s, " +
"eta 17h30m0s",
}}
for _, tc := range cases {
out := drawLate(stderr, tc.params, 80, 1234567)
if shown := lastFrame(out); !strings.Contains(shown, tc.want) {
t.Errorf("terminal shows %q, want %q", shown, tc.want)
}
}
} }
// TestTerminalLinesFitWidth draws a large scan's bar display and a small // TestTerminalLinesFitWidth checks that every line a terminal display
// scan's, two and a half hours into the phase, on terminals from 120 // draws, bar or spinner, is narrower than the terminal at the size of a
// columns down to 40, and checks that every line stays clear of the // large scan. A line as wide as the terminal or wider would wrap, and
// terminal's last column. A line that reaches it wraps, and every // every redraw would leave a line behind. stderr is not a terminal
// redraw then leaves a line behind. The bar is drawn where it has room, // here, so the displays take it to be fallbackWidth wide.
// as at 120 columns, and left out where it has none.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalLinesFitWidth(t *testing.T) { func TestTerminalLinesFitWidth(t *testing.T) {
stderr := captureStderr(t) stderr := captureStderr(t)
for _, width := range []int{120, 100, 80, 72, 60, 50, 40} { cases := []progressParams{
for _, total := range []int64{9876543, 20000} { {label: scanPhase("content"), total: 9876543},
params := progressParams{label: scanPhase("content"), total: total} {label: scanPhase("walk"), total: 9876543, estimate: true},
out := drawLate(stderr, params, width, total/8)
if width == 120 && !strings.Contains(out, "% |") {
t.Errorf("no bar on a terminal %d wide: %q", width, out)
} }
lines := strings.FieldsFuncSeq(out, func(r rune) bool { for _, params := range cases {
p := startProgress(params, true, testRedraw)
// 1234567 items two and a half hours into the phase, without
// counting them one by one or waiting the hours.
p.mu.Lock()
p.count = 1234567
p.start = p.start.Add(-150 * time.Minute)
p.rateStart = p.start
p.mu.Unlock()
awaitOutput(t, stderr, "1234567", 1)
p.finish()
}
lines := strings.FieldsFuncSeq(stderr(), func(r rune) bool {
return r == '\r' || r == '\n' return r == '\r' || r == '\n'
}) })
for line := range lines { for line := range lines {
if n := utf8.RuneCountInString(line); n >= width { if n := utf8.RuneCountInString(line); n >= fallbackWidth {
t.Errorf("a line of %d columns on a terminal %d wide: %q", t.Errorf("a line of %d columns on a terminal %d wide: %q",
n, width, line) n, fallbackWidth, line)
}
}
} }
} }
} }
@@ -427,7 +320,7 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
issued := 0 issued := 0
for _, params := range cases { for _, params := range cases {
p := startProgress(params, terminalWidth, testRedraw) p := startProgress(params, true, testRedraw)
// No pause between warnings: one written straight to stderr is // No pause between warnings: one written straight to stderr is
// garbled only if a redraw lands while it is being written. // garbled only if a redraw lands while it is being written.
@@ -467,13 +360,13 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
func TestTerminalStoppedShortKeepsCount(t *testing.T) { func TestTerminalStoppedShortKeepsCount(t *testing.T) {
stderr := captureStderr(t) stderr := captureStderr(t)
p := startProgress(progressParams{label: scanPhase("hash"), total: 10}, p := startProgress(progressParams{label: scanPhase("hash"), total: 10},
terminalWidth, redrawInterval) true, redrawInterval)
p.increment() p.increment()
p.increment() p.increment()
p.finish() p.finish()
if shown := lastFrame(stderr()); !strings.Contains(shown, "[2/10],") { if shown := lastFrame(stderr()); !strings.Contains(shown, "(2/10,") {
t.Errorf("terminal shows %q, want a count of 2 of 10", shown) t.Errorf("terminal shows %q, want a count of 2 of 10", shown)
} }
} }
@@ -486,16 +379,16 @@ func TestTerminalStoppedShortKeepsCount(t *testing.T) {
func TestWarningAfterLastItemKeepsBar(t *testing.T) { func TestWarningAfterLastItemKeepsBar(t *testing.T) {
stderr := captureStderr(t) stderr := captureStderr(t)
p := startProgress(progressParams{label: scanPhase("hash"), total: 2}, p := startProgress(progressParams{label: scanPhase("hash"), total: 2},
terminalWidth, testRedraw) true, testRedraw)
p.increment() p.increment()
p.increment() p.increment()
awaitOutput(t, stderr, "[2/2],", 1) awaitOutput(t, stderr, "(2/2,", 1)
p.warnf("warning") p.warnf("warning")
p.finish() p.finish()
if shown := lastFrame(stderr()); !strings.Contains(shown, "[2/2],") { if shown := lastFrame(stderr()); !strings.Contains(shown, "(2/2,") {
t.Errorf("terminal shows %q, want a count of 2 of 2", shown) t.Errorf("terminal shows %q, want a count of 2 of 2", shown)
} }
} }