1 Commits
Author SHA1 Message Date
clawbot a9b69e911b 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 names its phase and its place among the five,
as "[2/5] walk", with its progress, rate, elapsed time and ETA. load
counts the records first; the walk estimates its total from the
records under the operands; the content phase adds up the sizes of the
matching groups and says it is searching until the first record
returns. Each display is drawn from its own goroutine: several times a
second on a terminal, every 5 seconds in plain mode.

On a terminal a phase with an exact total draws its line before the
library's bar when there is room, and any other a spinner before the
line. A line too wide drops its rate, then its bar; it is cut only when
the phase, progress, elapsed time and ETA alone do not fit.

Judgement call: load and the content checks draw the bar too, once their totals are known.
Judgement call: the bar is 10 columns wide, not the width the line leaves.

Model: opus-5-5
2026-10-16 15:47:17 +00:00
4 changed files with 333 additions and 176 deletions

No files matched your search

+15 -10
View File
@@ -756,16 +756,21 @@ Example shape (exact layout is flexible, content is not):
Additional requirements: Additional requirements:
- On a terminal, a phase with an exact total from its start (`hash`, `update`, - On a terminal, a phase with an exact total (every phase but `walk`, once it
and the content phase's reads) is drawn as the progress-bar library's bar, has counted what it will do) is drawn as its line followed by the progress-bar
with the phase and its place before it and the library's counts, rate, elapsed library's bar, 10 columns wide, which shows the percent in place of the line's
time and ETA after it. Any other phase (`load`, `walk`, and the content own. Any other phase, and a phase whose bar has no room, is drawn as the
phase's checks) is drawn as the library's spinner followed by its line. Each library's spinner followed by its line. Each display is drawn again several
display is drawn again several times a second, so its elapsed time keeps times a second, so its elapsed time keeps moving while a phase waits for its
moving while a phase waits for its next item. next item.
- On a terminal every line fits the terminal's width: the bar takes the room the - On a terminal no line reaches the terminal's last column, however narrow the
rest of its line leaves, and a spinner's line is cut short where it would terminal is. The phase and its place, the progress, the elapsed time and the
reach the terminal's last column. 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 - 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
+8 -5
View File
@@ -34,11 +34,14 @@
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 `hash`, `update` and the until the first one is returned. On a terminal a phase with an exact total
content phase's reads keep the library's bar and the other displays are a draws its line followed by the library's bar, and any other a spinner followed
spinner followed by the line; every display is drawn again several times a by the line; every display is drawn again several times a second. No line
second, and every line fits the terminal's width. In plain mode the line is reaches the terminal's last column, however narrow: a line too wide leaves out
printed every 5 seconds whether or not an item completed (2026-10-16, 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) 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
+144 -102
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"math"
"os" "os"
"slices" "slices"
"sync" "sync"
@@ -22,19 +23,28 @@ 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 the progressbar library takes a terminal // fallbackWidth is the width taken for a terminal whose width cannot be
// to have when it cannot read it, as when stderr is not a terminal. // read, as the progressbar library takes it.
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 the line stays clear of, as the // and the terminal's last column, which every line stays clear of so
// library's bars do. // that no terminal wraps it.
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()))
@@ -67,35 +77,32 @@ 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 it. A phase with an exact total from its // progressbar library draws line, fitted to the terminal's width: after
// start gets the library's bar, with the phase and its place as the // the library's spinner, or, for a phase with an exact total, before the
// bar's description and the library's counts, rate, elapsed time and // library's bar when the terminal has room for the bar beside it. It is
// ETA beside it; the bar takes the room the rest leaves on the line. Any // drawn again every redrawInterval, so its elapsed time keeps moving
// other phase gets the library's spinner, with line as its text, cut to // while the phase waits for its next item. When stderr is not a TTY it
// the terminal's width. Either is drawn again every redrawInterval, so // emits no ANSI redraws: it prints line as the phase starts, every
// its elapsed time keeps moving while the phase waits for its next // plainInterval while it runs, and as it ends.
// 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 and every call on bar. Every method must be called from the // below it. Every method must be called from the main goroutine. A nil
// main goroutine. A nil *progress is a valid no-display receiver: every // *progress is a valid no-display receiver: every method is a no-op, so
// method is a no-op, so batched database flushes during the hash phase // batched database flushes during the hash phase can reuse the update
// can reuse the update phase's helpers without rendering anything. // phase's helpers without rendering anything.
type progress struct { type progress struct {
bar *progressbar.ProgressBar // nil when stderr is not a TTY width func() int // reads the terminal's width; nil when 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
} }
@@ -103,33 +110,26 @@ 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, 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 // startProgress shows the phase params describes at once, and again
// progressbar library when onTTY is set and as a plain line when not, // every interval until finish: with the progressbar library on a
// and again every interval until finish. // terminal, whose width each draw reads by calling width, or as a plain
func startProgress(params progressParams, onTTY bool, // line when width is nil.
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{
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, 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,65 +153,74 @@ func startProgress(params progressParams, onTTY bool,
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()
switch { if p.width == nil {
case p.bar == nil: text, _ := p.line(math.MaxInt, true)
fmt.Fprintln(os.Stderr, p.line()) fmt.Fprintln(os.Stderr, text)
case p.spinner:
text := p.line() return
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 // newBar has the library make what draw draws: a bar for the phase's
// empty add would count a wait as a rate of zero. // total when withBar is set, and a spinner when not. Either shows the
_ = p.bar.Add64(p.count - p.drawn) // text draw gives it, and nothing of its own besides the spinner, or
p.drawn = p.count // the bar and its percent. p.mu must be held.
default: func (p *progress) newBar(withBar bool) {
// No new items: the bar is drawn again all the same, with if p.bar != nil {
// its elapsed time as of now. // Erase what was drawn before.
p.bar.Describe(p.label) _ = 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
@@ -277,51 +286,84 @@ func (p *progress) finish() {
p.draw() p.draw()
if p.bar != nil { if p.width != 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, 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 // [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 // without the percent when percent is false, as before the library's
// shows the items done instead, or what it waits for. p.mu must be // bar, which shows its own. A phase with no total shows the items done
// held. // instead, or what it waits for.
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)
if p.total < 0 && p.waitingFor != "" { // head and tail hold the parts kept whole; extra, between them, is
return fmt.Sprintf("%s: %s, elapsed %s, eta unknown", // the part left out.
p.label, p.waitingFor, elapsed) var head, extra, tail string
}
if p.total < 0 && p.waitingFor != "" {
head, extra = p.label+": ", p.waitingFor
tail = fmt.Sprintf("elapsed %s, eta unknown", 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
} }
done := fmt.Sprintf("%d files", p.count) head = p.label + ": " + p.itemsDone(percent) + ", "
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)
} }
done = fmt.Sprintf("[%d/%s%d] %.0f%%", p.count, approx, p.total, pct) return fmt.Sprintf("%s %.0f%%", done, 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
+166 -59
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"math"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -114,12 +115,66 @@ 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(); got != tc.want { if got, _ := tc.p.line(math.MaxInt, true); 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.
@@ -140,7 +195,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(); got != want { if got, _ := p.line(math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want) t.Errorf("line = %q, want %q", got, want)
} }
} }
@@ -198,7 +253,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",
}, false, testRedraw) }, nil, 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)
@@ -221,84 +276,136 @@ func lastFrame(out string) string {
} }
// TestTerminalRedrawsWhileWaiting checks that the terminal display of a // TestTerminalRedrawsWhileWaiting checks that the terminal display of a
// phase keeps being drawn while the phase waits for its next item, and // phase, spinner or bar, keeps being drawn while the phase waits for its
// shows every item completed before the wait: a spinner's line with // next item, with the elapsed time as of now, and shows every item
// the elapsed time as of now, and the library's bar, which works out // completed before the wait. stderr is not a terminal here, so the
// its elapsed time at each draw. // displays take it to be fallbackWidth wide.
// //
//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)
walk := startProgress(progressParams{label: scanPhase("walk"), total: -1}, cases := []struct {
true, testRedraw) 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],"},
}
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 {
walk.increment() p.increment()
} }
// An hour into the phase, without waiting an hour. // An hour into the phase, without waiting an hour.
walk.mu.Lock() p.mu.Lock()
walk.start = walk.start.Add(-time.Hour) p.start = p.start.Add(-time.Hour)
walk.mu.Unlock() p.mu.Unlock()
awaitOutput(t, stderr, "elapsed 1h0m0s", 1) awaitOutput(t, since, "elapsed 1h0m0s", 1)
walk.finish() p.finish()
if shown := lastFrame(stderr()); !strings.Contains(shown, "50 files,") { if shown := lastFrame(since()); !strings.Contains(shown, tc.want) {
t.Errorf("terminal shows %q, want a count of 50", shown) 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 // drawLate draws the display params describes on a terminal width
// draws, bar or spinner, is narrower than the terminal at the size of a // columns wide as it starts and once more as it ends, with count items
// large scan. A line as wide as the terminal or wider would wrap, and // done two and a half hours into the phase, and returns what it wrote
// every redraw would leave a line behind. stderr is not a terminal // to stderr.
// here, so the displays take it to be fallbackWidth wide. 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 //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)
cases := []progressParams{ for _, width := range []int{120, 100, 80, 72, 60, 50, 40} {
{label: scanPhase("content"), total: 9876543}, for _, total := range []int64{9876543, 20000} {
{label: scanPhase("walk"), total: 9876543, estimate: true}, params := progressParams{label: scanPhase("content"), total: total}
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)
} }
for _, params := range cases { lines := strings.FieldsFuncSeq(out, func(r rune) bool {
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 >= fallbackWidth { if n := utf8.RuneCountInString(line); n >= width {
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, fallbackWidth, line) n, width, line)
}
}
} }
} }
} }
@@ -320,7 +427,7 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
issued := 0 issued := 0
for _, params := range cases { for _, params := range cases {
p := startProgress(params, true, testRedraw) p := startProgress(params, terminalWidth, 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.
@@ -360,13 +467,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},
true, redrawInterval) terminalWidth, 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)
} }
} }
@@ -379,16 +486,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},
true, testRedraw) terminalWidth, 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)
} }
} }