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 357 additions and 200 deletions
+15 -10
View File
@@ -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
+8 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}