Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b69e911b |
@@ -756,16 +756,21 @@ Example shape (exact layout is flexible, content is not):
|
||||
|
||||
Additional requirements:
|
||||
|
||||
- On a terminal, a phase with an exact total from its start (`hash`, `update`,
|
||||
and the content phase's reads) is drawn as the progress-bar library's bar,
|
||||
with the phase and its place before it and the library's counts, rate, elapsed
|
||||
time and ETA after it. Any other phase (`load`, `walk`, and the content
|
||||
phase's checks) is drawn as the library's spinner followed by its line. Each
|
||||
display is drawn again several times a second, so its elapsed time keeps
|
||||
moving while a phase waits for its next item.
|
||||
- On a terminal every line fits the terminal's width: the bar takes the room the
|
||||
rest of its line leaves, and a spinner's line is cut short where it would
|
||||
reach the terminal's last column.
|
||||
- On a terminal, a phase with an exact total (every phase but `walk`, once it
|
||||
has counted what it will do) is drawn as its line followed by the progress-bar
|
||||
library's bar, 10 columns wide, which shows the percent in place of the line's
|
||||
own. Any other phase, and a phase whose bar has no room, is drawn as the
|
||||
library's spinner followed by its line. Each display is drawn again several
|
||||
times a second, so its elapsed time keeps moving while a phase waits for its
|
||||
next item.
|
||||
- On a terminal no line reaches the terminal's last column, however narrow the
|
||||
terminal is. The phase and its place, the progress, the elapsed time and the
|
||||
ETA are kept whole: a line too wide for the terminal leaves out its rate first
|
||||
(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
|
||||
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
|
||||
|
||||
@@ -34,11 +34,14 @@
|
||||
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
|
||||
it checks from SQLite's grouping alone, then says it is searching the database
|
||||
until the first one is returned. On a terminal `hash`, `update` and the
|
||||
content phase's reads keep the library's bar and the other displays are a
|
||||
spinner followed by the line; every display is drawn again several times a
|
||||
second, and every line fits the terminal's width. In plain mode the line is
|
||||
printed every 5 seconds whether or not an item completed (2026-10-16,
|
||||
until the first one is returned. On a terminal a phase with an exact total
|
||||
draws its line followed by the library's bar, and any other a spinner followed
|
||||
by the line; every display is drawn again several times a second. No line
|
||||
reaches the terminal's last column, however narrow: a line too wide leaves out
|
||||
its rate, then its bar, keeping the phase, progress, elapsed time and ETA
|
||||
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)
|
||||
|
||||
- `scan`, `report` and `trees` first print a line on stderr naming the database
|
||||
|
||||
+160
-118
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"slices"
|
||||
"sync"
|
||||
@@ -22,19 +23,28 @@ const redrawInterval = 100 * time.Millisecond
|
||||
// spinnerType selects the progressbar library's spinner style.
|
||||
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.
|
||||
const percentScale = 100
|
||||
|
||||
// fallbackWidth is the width the progressbar library takes a terminal
|
||||
// to have when it cannot read it, as when stderr is not a terminal.
|
||||
// fallbackWidth is the width taken for a terminal whose width cannot be
|
||||
// read, as the progressbar library takes it.
|
||||
const fallbackWidth = 80
|
||||
|
||||
// spinnerMargin is the columns of a spinner's line besides its text:
|
||||
// the spinner and the three spaces the library draws around the text,
|
||||
// and the terminal's last column, which the line stays clear of, as the
|
||||
// library's bars do.
|
||||
// and the terminal's last column, which every line stays clear of so
|
||||
// that no terminal wraps it.
|
||||
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.
|
||||
func stderrIsTTY() bool {
|
||||
return term.IsTerminal(int(os.Stderr.Fd()))
|
||||
@@ -67,35 +77,32 @@ type progressParams struct {
|
||||
}
|
||||
|
||||
// progress renders one phase's progress on stderr. On a TTY the
|
||||
// progressbar library draws it. A phase with an exact total from its
|
||||
// start gets the library's bar, with the phase and its place as the
|
||||
// bar's description and the library's counts, rate, elapsed time and
|
||||
// ETA beside it; the bar takes the room the rest leaves on the line. Any
|
||||
// other phase gets the library's spinner, with line as its text, cut to
|
||||
// the terminal's width. Either is drawn again every redrawInterval, so
|
||||
// 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.
|
||||
// progressbar library draws line, fitted to the terminal's width: after
|
||||
// the library's spinner, or, for a phase with an exact total, before the
|
||||
// library's bar when the terminal has room for the bar beside it. It is
|
||||
// drawn again every redrawInterval, so 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
|
||||
// below it and every call on bar. Every method must be called from the
|
||||
// main goroutine. A nil *progress is a valid no-display receiver: every
|
||||
// method is a no-op, so batched database flushes during the hash phase
|
||||
// can reuse the update phase's helpers without rendering anything.
|
||||
// below it. Every method must be called from the main goroutine. A nil
|
||||
// *progress is a valid no-display receiver: every method is a no-op, so
|
||||
// batched database flushes during the hash phase can reuse the update
|
||||
// phase's helpers without rendering anything.
|
||||
type progress struct {
|
||||
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
|
||||
done chan struct{} // closed once the draws have ended
|
||||
width func() int // reads the terminal's width; nil when not a TTY
|
||||
stop chan struct{} // closed by finish to end the draws
|
||||
done chan struct{} // closed once the draws have ended
|
||||
|
||||
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
|
||||
total int64
|
||||
estimate bool
|
||||
waitingFor string
|
||||
count int64
|
||||
drawn int64 // the items bar has been given, unless a spinner
|
||||
start time.Time // when the phase started, for the elapsed time
|
||||
rateStart time.Time // when the count started, for the rate
|
||||
}
|
||||
@@ -103,33 +110,26 @@ type progress struct {
|
||||
// newProgress starts the display of one phase on stderr.
|
||||
func newProgress(params progressParams) *progress {
|
||||
if stderrIsTTY() {
|
||||
return startProgress(params, true, redrawInterval)
|
||||
return startProgress(params, terminalWidth, redrawInterval)
|
||||
}
|
||||
|
||||
return startProgress(params, false, plainInterval)
|
||||
return startProgress(params, nil, plainInterval)
|
||||
}
|
||||
|
||||
// startProgress shows the phase params describes at once, with the
|
||||
// progressbar library when onTTY is set and as a plain line when not,
|
||||
// and again every interval until finish.
|
||||
func startProgress(params progressParams, onTTY bool,
|
||||
// startProgress shows the phase params describes at once, and again
|
||||
// every interval until finish: with the progressbar library on a
|
||||
// terminal, whose width each draw reads by calling width, or as a plain
|
||||
// line when width is nil.
|
||||
func startProgress(params progressParams, width func() int,
|
||||
interval time.Duration,
|
||||
) *progress {
|
||||
now := time.Now()
|
||||
p := &progress{
|
||||
stop: make(chan struct{}), done: make(chan struct{}),
|
||||
width: width, stop: make(chan struct{}), done: make(chan struct{}),
|
||||
label: params.label, total: params.total, estimate: params.estimate,
|
||||
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,
|
||||
// and a phase must never look hung.
|
||||
p.draw()
|
||||
@@ -153,65 +153,74 @@ func startProgress(params progressParams, onTTY bool,
|
||||
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
|
||||
// before, and otherwise as a plain line.
|
||||
func (p *progress) draw() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case p.bar == nil:
|
||||
fmt.Fprintln(os.Stderr, p.line())
|
||||
case p.spinner:
|
||||
text := p.line()
|
||||
if room := terminalWidth() - spinnerMargin; len(text) > room {
|
||||
text = text[:max(room, 0)]
|
||||
}
|
||||
if p.width == nil {
|
||||
text, _ := p.line(math.MaxInt, true)
|
||||
fmt.Fprintln(os.Stderr, 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)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -277,51 +286,84 @@ func (p *progress) finish() {
|
||||
|
||||
p.draw()
|
||||
|
||||
if p.bar != nil {
|
||||
if p.width != nil {
|
||||
// The display stays on the TTY as drawn; what follows starts
|
||||
// on a line of its own.
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// line formats the phase's progress, as
|
||||
// line formats the phase's progress in at most room columns, as
|
||||
//
|
||||
// [3/5] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, eta 13s
|
||||
//
|
||||
// with "~" before a total that is an estimate. A phase with no total
|
||||
// shows the items done instead, or what it waits for. p.mu must be
|
||||
// held.
|
||||
func (p *progress) line() string {
|
||||
// without the percent when percent is false, as before the library's
|
||||
// bar, which shows its own. A phase with no total shows the items done
|
||||
// instead, or what it waits for.
|
||||
//
|
||||
// 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)
|
||||
|
||||
// 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 != "" {
|
||||
return fmt.Sprintf("%s: %s, elapsed %s, eta unknown",
|
||||
p.label, p.waitingFor, elapsed)
|
||||
}
|
||||
|
||||
rate := 0.0
|
||||
if s := time.Since(p.rateStart).Seconds(); s > 0 {
|
||||
rate = float64(p.count) / s
|
||||
}
|
||||
|
||||
done := fmt.Sprintf("%d files", p.count)
|
||||
|
||||
if p.total >= 0 {
|
||||
approx := ""
|
||||
if p.estimate {
|
||||
approx = "~"
|
||||
head, extra = p.label+": ", p.waitingFor
|
||||
tail = fmt.Sprintf("elapsed %s, eta unknown", elapsed)
|
||||
} else {
|
||||
rate := 0.0
|
||||
if s := time.Since(p.rateStart).Seconds(); s > 0 {
|
||||
rate = float64(p.count) / s
|
||||
}
|
||||
|
||||
pct := float64(percentScale)
|
||||
if p.total > 0 {
|
||||
pct = percentScale * float64(p.count) / float64(p.total)
|
||||
}
|
||||
|
||||
done = fmt.Sprintf("[%d/%s%d] %.0f%%", p.count, approx, p.total, pct)
|
||||
head = p.label + ": " + p.itemsDone(percent) + ", "
|
||||
extra = fmt.Sprintf("%.0f files/s", rate)
|
||||
tail = fmt.Sprintf("elapsed %s, eta %s", elapsed, p.eta(rate))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s: %s, %.0f files/s, elapsed %s, eta %s",
|
||||
p.label, done, rate, 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)
|
||||
}
|
||||
|
||||
approx := ""
|
||||
if p.estimate {
|
||||
approx = "~"
|
||||
}
|
||||
|
||||
done := fmt.Sprintf("[%d/%s%d]", p.count, approx, p.total)
|
||||
if !percent {
|
||||
return done
|
||||
}
|
||||
|
||||
pct := float64(percentScale)
|
||||
if p.total > 0 {
|
||||
pct = percentScale * float64(p.count) / float64(p.total)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s %.0f%%", done, pct)
|
||||
}
|
||||
|
||||
// eta returns the time the phase has left at rate items a second, or
|
||||
|
||||
+174
-67
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -114,12 +115,66 @@ func TestProgressLine(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
tc.p.start, tc.p.rateStart = ago, ago
|
||||
|
||||
if got := tc.p.line(); got != tc.want {
|
||||
if got, _ := tc.p.line(math.MaxInt, true); 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,
|
||||
// and so its ETA, from when the total becomes known, not from when the
|
||||
// phase started waiting for it.
|
||||
@@ -140,7 +195,7 @@ func TestSetTotalStartsRate(t *testing.T) {
|
||||
p.count = 20
|
||||
|
||||
want := "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s"
|
||||
if got := p.line(); got != want {
|
||||
if got, _ := p.line(math.MaxInt, true); got != want {
|
||||
t.Errorf("line = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -198,7 +253,7 @@ func TestPlainProgressPrintsWhileWaiting(t *testing.T) {
|
||||
p := startProgress(progressParams{
|
||||
label: scanPhase("load"), total: -1,
|
||||
waitingFor: "counting the records",
|
||||
}, false, testRedraw)
|
||||
}, nil, testRedraw)
|
||||
|
||||
awaitOutput(t, stderr,
|
||||
"[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3)
|
||||
@@ -221,84 +276,136 @@ func lastFrame(out string) string {
|
||||
}
|
||||
|
||||
// TestTerminalRedrawsWhileWaiting checks that the terminal display of a
|
||||
// phase keeps being drawn while the phase waits for its next item, and
|
||||
// shows every item completed before the wait: a spinner's line with
|
||||
// the elapsed time as of now, and the library's bar, which works out
|
||||
// its elapsed time at each draw.
|
||||
// phase, spinner or bar, keeps being drawn while the phase waits for its
|
||||
// next item, with the elapsed time as of now, and shows every item
|
||||
// completed before the wait. stderr is not a terminal here, so the
|
||||
// displays take it to be fallbackWidth wide.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestTerminalRedrawsWhileWaiting(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
|
||||
walk := startProgress(progressParams{label: scanPhase("walk"), total: -1},
|
||||
true, testRedraw)
|
||||
|
||||
for range 50 {
|
||||
walk.increment()
|
||||
cases := []struct {
|
||||
params progressParams
|
||||
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],"},
|
||||
}
|
||||
|
||||
// An hour into the phase, without waiting an hour.
|
||||
walk.mu.Lock()
|
||||
walk.start = walk.start.Add(-time.Hour)
|
||||
walk.mu.Unlock()
|
||||
for _, tc := range cases {
|
||||
before := len(stderr())
|
||||
since := func() string { return stderr()[before:] }
|
||||
p := startProgress(tc.params, terminalWidth, testRedraw)
|
||||
|
||||
awaitOutput(t, stderr, "elapsed 1h0m0s", 1)
|
||||
walk.finish()
|
||||
for range 50 {
|
||||
p.increment()
|
||||
}
|
||||
|
||||
if shown := lastFrame(stderr()); !strings.Contains(shown, "50 files,") {
|
||||
t.Errorf("terminal shows %q, want a count of 50", shown)
|
||||
// An hour into the phase, without waiting an hour.
|
||||
p.mu.Lock()
|
||||
p.start = p.start.Add(-time.Hour)
|
||||
p.mu.Unlock()
|
||||
|
||||
awaitOutput(t, since, "elapsed 1h0m0s", 1)
|
||||
p.finish()
|
||||
|
||||
if shown := lastFrame(since()); !strings.Contains(shown, tc.want) {
|
||||
t.Errorf("terminal shows %q, want %q", shown, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
hash := startProgress(progressParams{label: scanPhase("hash"), total: 100},
|
||||
true, testRedraw)
|
||||
|
||||
for range 50 {
|
||||
hash.increment()
|
||||
}
|
||||
|
||||
// Drawn with the 50 items, then twice more with none.
|
||||
awaitOutput(t, stderr, "(50/100,", 3)
|
||||
hash.finish()
|
||||
}
|
||||
|
||||
// TestTerminalLinesFitWidth checks that every line a terminal display
|
||||
// draws, bar or spinner, is narrower than the terminal at the size of a
|
||||
// large scan. A line as wide as the terminal or wider would wrap, and
|
||||
// every redraw would leave a line behind. stderr is not a terminal
|
||||
// here, so the displays take it to be fallbackWidth wide.
|
||||
// drawLate draws the display params describes on a terminal width
|
||||
// columns wide as it starts and once more as it ends, with count items
|
||||
// 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.
|
||||
p := startProgress(params, func() int { return width }, time.Hour)
|
||||
|
||||
// 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
|
||||
// 80-column terminal at a large scan's counts and times: the rate is
|
||||
// left out, so that the elapsed time and the ETA stay whole.
|
||||
//
|
||||
//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
|
||||
// scan's, two and a half hours into the phase, on terminals from 120
|
||||
// columns down to 40, and checks that every line stays clear of the
|
||||
// terminal's last column. A line that reaches it wraps, and every
|
||||
// redraw then leaves a line behind. The bar is drawn where it has room,
|
||||
// as at 120 columns, and left out where it has none.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestTerminalLinesFitWidth(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
|
||||
cases := []progressParams{
|
||||
{label: scanPhase("content"), total: 9876543},
|
||||
{label: scanPhase("walk"), total: 9876543, estimate: true},
|
||||
}
|
||||
for _, width := range []int{120, 100, 80, 72, 60, 50, 40} {
|
||||
for _, total := range []int64{9876543, 20000} {
|
||||
params := progressParams{label: scanPhase("content"), total: total}
|
||||
out := drawLate(stderr, params, width, total/8)
|
||||
|
||||
for _, params := range cases {
|
||||
p := startProgress(params, true, testRedraw)
|
||||
if width == 120 && !strings.Contains(out, "% |") {
|
||||
t.Errorf("no bar on a terminal %d wide: %q", width, out)
|
||||
}
|
||||
|
||||
// 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()
|
||||
lines := strings.FieldsFuncSeq(out, func(r rune) bool {
|
||||
return r == '\r' || r == '\n'
|
||||
})
|
||||
|
||||
awaitOutput(t, stderr, "1234567", 1)
|
||||
p.finish()
|
||||
}
|
||||
|
||||
lines := strings.FieldsFuncSeq(stderr(), func(r rune) bool {
|
||||
return r == '\r' || r == '\n'
|
||||
})
|
||||
|
||||
for line := range lines {
|
||||
if n := utf8.RuneCountInString(line); n >= fallbackWidth {
|
||||
t.Errorf("a line of %d columns on a terminal %d wide: %q",
|
||||
n, fallbackWidth, line)
|
||||
for line := range lines {
|
||||
if n := utf8.RuneCountInString(line); n >= width {
|
||||
t.Errorf("a line of %d columns on a terminal %d wide: %q",
|
||||
n, width, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,7 +427,7 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
|
||||
issued := 0
|
||||
|
||||
for _, params := range cases {
|
||||
p := startProgress(params, true, testRedraw)
|
||||
p := startProgress(params, terminalWidth, testRedraw)
|
||||
|
||||
// No pause between warnings: one written straight to stderr is
|
||||
// garbled only if a redraw lands while it is being written.
|
||||
@@ -360,13 +467,13 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
|
||||
func TestTerminalStoppedShortKeepsCount(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
p := startProgress(progressParams{label: scanPhase("hash"), total: 10},
|
||||
true, redrawInterval)
|
||||
terminalWidth, redrawInterval)
|
||||
|
||||
p.increment()
|
||||
p.increment()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -379,16 +486,16 @@ func TestTerminalStoppedShortKeepsCount(t *testing.T) {
|
||||
func TestWarningAfterLastItemKeepsBar(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
p := startProgress(progressParams{label: scanPhase("hash"), total: 2},
|
||||
true, testRedraw)
|
||||
terminalWidth, testRedraw)
|
||||
|
||||
p.increment()
|
||||
p.increment()
|
||||
|
||||
awaitOutput(t, stderr, "(2/2,", 1)
|
||||
awaitOutput(t, stderr, "[2/2],", 1)
|
||||
p.warnf("warning")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user