Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a04601f010 |
@@ -731,10 +731,12 @@ showing its progress, its elapsed time and its estimated time remaining (ETA):
|
||||
scan, the ETA is unknown.
|
||||
- `hash` and `update` have exact totals. Only files that actually need hashing
|
||||
appear in the hash total, so its ETA is meaningful.
|
||||
- `content` counts the records whose files it checks with `lstat`, saying it is
|
||||
searching the database while SQLite finds them, then checks them against that
|
||||
count. The files to read then get a display of their own, with their count as
|
||||
its total.
|
||||
- `content` first counts the records whose files it checks with `lstat`, which
|
||||
SQLite does by adding up the number of records with each matching size,
|
||||
`head`, and `tail`, without fetching the records. It then says it is searching
|
||||
the database until SQLite returns the first record; the checks, and their rate
|
||||
and ETA, start there. The files to read then get a display of their own, with
|
||||
their count as its total.
|
||||
|
||||
Each display is one line holding:
|
||||
|
||||
@@ -748,15 +750,22 @@ Each display is one line holding:
|
||||
Example shape (exact layout is flexible, content is not):
|
||||
|
||||
```
|
||||
[3/5] hash: [12345/98765] 12%, 92 files/s, elapsed 2m32s, eta 17m54s
|
||||
[3/5] hash: [12345/98765] 12% |████ | 92 files/s elapsed 2:32 eta 17:54
|
||||
[2/5] walk: [1234567/~1200000] 103%, 5120 files/s, elapsed 4m1s, eta unknown
|
||||
```
|
||||
|
||||
Additional requirements:
|
||||
|
||||
- On a terminal the progress-bar library draws the line behind a spinner, and
|
||||
draws it again several times a second, so the elapsed time and ETA stay
|
||||
current while a phase waits for its next item.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -32,11 +32,14 @@
|
||||
- each `scan` phase's progress display names the phase and its place among the
|
||||
five, as `[2/5] walk`, from the moment it starts, with its progress, elapsed
|
||||
time and ETA: `load` counts the records first, the walk's total is estimated
|
||||
from the records under the operands, and the content phase says it is
|
||||
searching the database before it checks the files it found against their
|
||||
count. On a terminal the line is drawn again several times a second; in plain
|
||||
mode it is printed every 5 seconds whether or not an item completed
|
||||
(2026-10-16, https://git.eeqj.de/sneak/sfdupes/issues/112)
|
||||
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,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/112)
|
||||
|
||||
- `scan`, `report` and `trees` first print a line on stderr naming the database
|
||||
they use, as an absolute path, and whether that path is the default or comes
|
||||
|
||||
@@ -564,35 +564,41 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentCandidatesSQL selects every record of at least headTailMin
|
||||
// bytes whose size, head, and tail equal another record's, in each
|
||||
// group (the records sharing a size, head, and tail) where at least one
|
||||
// record has no content hash, with whether each record has one. SQLite
|
||||
// does the grouping, so no other record's hashes are loaded into
|
||||
// memory; the rows come ordered by size, head, and tail, so each
|
||||
// group's rows arrive together.
|
||||
// contentGroupsSQL selects each group (the records sharing a size,
|
||||
// head, and tail) of records of at least headTailMin bytes that has more
|
||||
// than one record and at least one record without a content hash, with
|
||||
// the number of its records.
|
||||
const contentGroupsSQL = `
|
||||
SELECT size, head, tail, COUNT(*) AS records
|
||||
FROM files
|
||||
WHERE size >= ? AND head <> ''
|
||||
GROUP BY size, head, tail
|
||||
HAVING COUNT(*) > 1 AND SUM(content = '') > 0
|
||||
`
|
||||
|
||||
// contentCandidatesSQL selects every record of the groups
|
||||
// contentGroupsSQL selects, with whether each record has a content
|
||||
// hash. SQLite does the grouping, so no other record's hashes are
|
||||
// loaded into memory; the rows come ordered by size, head, and tail, so
|
||||
// each group's rows arrive together.
|
||||
const contentCandidatesSQL = `
|
||||
SELECT f.path, f.size, f.mtime, f.mtime_nsec, f.head, f.tail, f.content <> ''
|
||||
FROM files AS f
|
||||
JOIN (
|
||||
SELECT size, head, tail
|
||||
FROM files
|
||||
WHERE size >= ? AND head <> ''
|
||||
GROUP BY size, head, tail
|
||||
HAVING COUNT(*) > 1 AND SUM(content = '') > 0
|
||||
) AS g USING (size, head, tail)
|
||||
JOIN (` + contentGroupsSQL + `) AS g USING (size, head, tail)
|
||||
ORDER BY size, head, tail
|
||||
`
|
||||
|
||||
// countContentCandidates returns the number of rows of
|
||||
// contentCandidatesSQL.
|
||||
// contentCandidatesSQL, adding up the records of the groups
|
||||
// contentGroupsSQL selects, so it neither joins nor sorts.
|
||||
func countContentCandidates(ctx context.Context, db *sql.DB) (int64, error) {
|
||||
var n int64
|
||||
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM ("+contentCandidatesSQL+")", headTailMin).Scan(&n)
|
||||
"SELECT COALESCE(SUM(records), 0) FROM ("+contentGroupsSQL+")",
|
||||
headTailMin).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count records: %w", err)
|
||||
return 0, fmt.Errorf("count content candidates: %w", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
|
||||
+59
@@ -385,3 +385,62 @@ func TestApplyChangesBatching(t *testing.T) {
|
||||
t.Fatalf("records = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountContentCandidates checks that countContentCandidates counts
|
||||
// the records the content phase's search returns: those of each group
|
||||
// of two or more records of headTailMin or more with a head, where at
|
||||
// least one has no content hash.
|
||||
func TestCountContentCandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := openTestDB(t)
|
||||
|
||||
// Each group's records: size, head, and content, one per record.
|
||||
groups := []struct {
|
||||
size int64
|
||||
head string
|
||||
contents []string
|
||||
}{
|
||||
{headTailMin, "h", []string{"", "c", "c"}}, // returned
|
||||
{headTailMin + 1, "h", []string{"", ""}}, // returned
|
||||
{headTailMin + 2, "h", []string{"c", "c"}}, // all hashed
|
||||
{headTailMin + 3, "h", []string{""}}, // one record
|
||||
{headTailMin - 1, "h", []string{"", ""}}, // too small
|
||||
{headTailMin + 4, "", []string{"", ""}}, // no head
|
||||
}
|
||||
|
||||
var recs []scanRec
|
||||
|
||||
for _, g := range groups {
|
||||
for _, content := range g.contents {
|
||||
recs = append(recs, scanRec{
|
||||
size: g.size, mtime: time.Unix(1, 0), head: g.head,
|
||||
tail: "t", content: content,
|
||||
path: fmt.Sprintf("/r/%d", len(recs)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err := applyChanges(t.Context(), db, recs, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("applyChanges: %v", err)
|
||||
}
|
||||
|
||||
got, err := countContentCandidates(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("countContentCandidates: %v", err)
|
||||
}
|
||||
|
||||
var returned int64
|
||||
|
||||
err = loadContentCandidates(t.Context(), db,
|
||||
func(scanRec, bool) { returned++ })
|
||||
if err != nil {
|
||||
t.Fatalf("loadContentCandidates: %v", err)
|
||||
}
|
||||
|
||||
if got != 5 || returned != 5 {
|
||||
t.Errorf("count = %d, records returned = %d, want 5 and 5",
|
||||
got, returned)
|
||||
}
|
||||
}
|
||||
|
||||
+108
-45
@@ -25,11 +25,33 @@ const spinnerType = 14
|
||||
// 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.
|
||||
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.
|
||||
const spinnerMargin = 5
|
||||
|
||||
// stderrIsTTY reports whether stderr is attached to a terminal.
|
||||
func stderrIsTTY() bool {
|
||||
return term.IsTerminal(int(os.Stderr.Fd()))
|
||||
}
|
||||
|
||||
// terminalWidth returns the width of the terminal on stderr, or
|
||||
// fallbackWidth, as the progressbar library does, when it cannot read
|
||||
// one.
|
||||
func terminalWidth() int {
|
||||
width, _, err := term.GetSize(int(os.Stderr.Fd()))
|
||||
if err != nil || width <= 0 {
|
||||
return fallbackWidth
|
||||
}
|
||||
|
||||
return width
|
||||
}
|
||||
|
||||
// phaseLabel names phase with its place among phases, as "[2/5] walk".
|
||||
func phaseLabel(phases []string, phase string) string {
|
||||
return fmt.Sprintf("[%d/%d] %s",
|
||||
@@ -44,24 +66,28 @@ type progressParams struct {
|
||||
waitingFor string // what the phase waits for until setTotal is called
|
||||
}
|
||||
|
||||
// progress renders one phase's progress on stderr as one line (see
|
||||
// line): the phase and its place among the phases, the items done out
|
||||
// of the total, the rate, the elapsed time and the estimated time
|
||||
// remaining. On a TTY the progressbar library draws the line behind a
|
||||
// spinner, again every redrawInterval. When stderr is not a TTY it
|
||||
// emits no ANSI redraws: it prints the line as the phase starts, every
|
||||
// plainInterval while it runs, and as it ends. Either way the display
|
||||
// shows the phase is alive while it waits for its next item.
|
||||
// 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.
|
||||
//
|
||||
// The redraws come from a goroutine of their own, so mu guards what
|
||||
// they read. 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.
|
||||
// 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.
|
||||
type progress struct {
|
||||
bar *progressbar.ProgressBar // nil when stderr is not a TTY
|
||||
stop chan struct{} // closed by finish to end the redraws
|
||||
done chan struct{} // closed once the redraws have ended
|
||||
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
|
||||
|
||||
mu sync.Mutex
|
||||
label string
|
||||
@@ -69,6 +95,7 @@ type progress struct {
|
||||
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
|
||||
}
|
||||
@@ -76,24 +103,33 @@ type progress struct {
|
||||
// newProgress starts the display of one phase on stderr.
|
||||
func newProgress(params progressParams) *progress {
|
||||
if stderrIsTTY() {
|
||||
return startProgress(params, newSpinner(), redrawInterval)
|
||||
return startProgress(params, true, redrawInterval)
|
||||
}
|
||||
|
||||
return startProgress(params, nil, plainInterval)
|
||||
return startProgress(params, false, plainInterval)
|
||||
}
|
||||
|
||||
// startProgress shows the phase params describes at once, on bar, or as
|
||||
// a plain line when bar is nil, and again every interval until finish.
|
||||
func startProgress(params progressParams, bar *progressbar.ProgressBar,
|
||||
// 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,
|
||||
interval time.Duration,
|
||||
) *progress {
|
||||
now := time.Now()
|
||||
p := &progress{
|
||||
bar: bar, 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,
|
||||
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()
|
||||
@@ -117,8 +153,26 @@ func startProgress(params progressParams, bar *progressbar.ProgressBar,
|
||||
return p
|
||||
}
|
||||
|
||||
// newSpinner builds the TTY display for newProgress: the library's
|
||||
// spinner, followed by the text draw gives it.
|
||||
// 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),
|
||||
@@ -131,19 +185,33 @@ func newSpinner() *progressbar.ProgressBar {
|
||||
)
|
||||
}
|
||||
|
||||
// draw shows the phase's line: on a TTY in place of the one before, and
|
||||
// otherwise as a plain line.
|
||||
// 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()
|
||||
|
||||
if p.bar == nil {
|
||||
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)]
|
||||
}
|
||||
|
||||
return
|
||||
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)
|
||||
}
|
||||
|
||||
p.bar.Describe(p.line())
|
||||
}
|
||||
|
||||
// setTotal gives a phase that started without a total its total, once
|
||||
@@ -187,21 +255,18 @@ func (p *progress) warnf(format string, args ...any) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.bar == nil {
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
|
||||
return
|
||||
if p.bar != nil {
|
||||
// The warning takes the display's line; the next draw shows
|
||||
// the display again, on the line below.
|
||||
_ = p.bar.Clear()
|
||||
}
|
||||
|
||||
// A direct write could land inside the line on the TTY. The bar
|
||||
// prints the warning on a line of its own, just before its next
|
||||
// draw.
|
||||
_, _ = progressbar.Bprintln(p.bar, msg)
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
}
|
||||
|
||||
// finish ends the phase's display with its line as the phase ends. A
|
||||
// phase stopped short of its total, as an interrupted one is, shows the
|
||||
// count it stopped at.
|
||||
// finish ends the phase's display, drawn once more as the phase ends.
|
||||
// A phase stopped short of its total, as an interrupted one is, shows
|
||||
// the count it stopped at rather than a full bar.
|
||||
func (p *progress) finish() {
|
||||
if p == nil {
|
||||
return
|
||||
@@ -213,10 +278,8 @@ func (p *progress) finish() {
|
||||
p.draw()
|
||||
|
||||
if p.bar != nil {
|
||||
// Finish draws the line once more, below any warning it holds,
|
||||
// and leaves it on the TTY.
|
||||
_ = p.bar.Finish()
|
||||
|
||||
// The display stays on the TTY as drawn; what follows starts
|
||||
// on a line of its own.
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
+135
-72
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// testRedraw is the redraw interval the tests give a display, so that
|
||||
@@ -119,24 +120,49 @@ func TestProgressLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetTotalStartsRate checks that a phase's rate, and so its ETA,
|
||||
// count from when its total became known, not from when it started
|
||||
// waiting for it.
|
||||
// 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.
|
||||
func TestSetTotalStartsRate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := progress{
|
||||
label: scanPhase("load"), total: 100, count: 20,
|
||||
start: time.Now().Add(-20 * time.Second),
|
||||
rateStart: time.Now().Add(-10 * time.Second),
|
||||
waited := time.Now().Add(-20 * time.Second)
|
||||
p := &progress{
|
||||
label: scanPhase("load"), total: -1, start: waited, rateStart: waited,
|
||||
}
|
||||
|
||||
want := "[1/5] load: [20/100] 20%, 2 files/s, elapsed 20s, eta 40s"
|
||||
p.setTotal(100)
|
||||
|
||||
// Ten seconds after the total came, without waiting ten seconds,
|
||||
// 20 records are read.
|
||||
p.start = p.start.Add(-10 * time.Second)
|
||||
p.rateStart = p.rateStart.Add(-10 * time.Second)
|
||||
p.count = 20
|
||||
|
||||
want := "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s"
|
||||
if got := p.line(); got != want {
|
||||
t.Errorf("line = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// awaitOutput waits until stderr holds want at least times times, and
|
||||
// fails the test if it never does.
|
||||
func awaitOutput(t *testing.T, stderr func() string, want string,
|
||||
times int,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(testDeadline)
|
||||
for strings.Count(stderr(), want) < times {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("stderr = %q, want %q at least %d times",
|
||||
stderr(), want, times)
|
||||
}
|
||||
|
||||
time.Sleep(testRedraw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlainProgressPrintsAtStartAndEnd checks that when stderr is not a
|
||||
// terminal a phase prints its line the moment it starts, before its
|
||||
// first item, nothing for the items it completes before plainInterval
|
||||
@@ -172,19 +198,11 @@ func TestPlainProgressPrintsWhileWaiting(t *testing.T) {
|
||||
p := startProgress(progressParams{
|
||||
label: scanPhase("load"), total: -1,
|
||||
waitingFor: "counting the records",
|
||||
}, nil, testRedraw)
|
||||
|
||||
want := "[1/5] load: counting the records, elapsed 0s, eta unknown\n"
|
||||
for deadline := time.Now().Add(testDeadline); strings.Count(stderr(),
|
||||
want) < 3 && time.Now().Before(deadline); {
|
||||
time.Sleep(testRedraw)
|
||||
}
|
||||
}, false, testRedraw)
|
||||
|
||||
awaitOutput(t, stderr,
|
||||
"[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3)
|
||||
p.finish()
|
||||
|
||||
if got := stderr(); strings.Count(got, want) < 3 {
|
||||
t.Errorf("stderr = %q, want %q at least three times", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// lastFrame returns what a terminal shows of the frames a display drew:
|
||||
@@ -202,73 +220,116 @@ func lastFrame(out string) string {
|
||||
return shown
|
||||
}
|
||||
|
||||
// awaitFrame waits until the last frame a terminal display drew to
|
||||
// stderr shows want, and fails the test if it never does.
|
||||
func awaitFrame(t *testing.T, stderr func() string, want string) {
|
||||
t.Helper()
|
||||
|
||||
for deadline := time.Now().Add(testDeadline); !strings.Contains(
|
||||
lastFrame(stderr()), want); time.Sleep(testRedraw) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("terminal shows %q, want %q", lastFrame(stderr()), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalRedrawsWhileWaiting checks that the terminal display of a
|
||||
// phase, with a total or without, keeps showing the elapsed time while
|
||||
// the phase waits for its next item, and shows every item completed
|
||||
// before the wait.
|
||||
// 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.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestTerminalRedrawsWhileWaiting(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
|
||||
counts := map[int64]string{-1: "50 files", 100: "[50/100]"}
|
||||
walk := startProgress(progressParams{label: scanPhase("walk"), total: -1},
|
||||
true, testRedraw)
|
||||
|
||||
for total, count := range counts {
|
||||
p := startProgress(progressParams{
|
||||
label: scanPhase("hash"), total: total,
|
||||
}, newSpinner(), testRedraw)
|
||||
for range 50 {
|
||||
walk.increment()
|
||||
}
|
||||
|
||||
for range 50 {
|
||||
p.increment()
|
||||
}
|
||||
// An hour into the phase, without waiting an hour.
|
||||
walk.mu.Lock()
|
||||
walk.start = walk.start.Add(-time.Hour)
|
||||
walk.mu.Unlock()
|
||||
|
||||
// An hour into the phase, without waiting an hour.
|
||||
awaitOutput(t, stderr, "elapsed 1h0m0s", 1)
|
||||
walk.finish()
|
||||
|
||||
if shown := lastFrame(stderr()); !strings.Contains(shown, "50 files,") {
|
||||
t.Errorf("terminal shows %q, want a count of 50", shown)
|
||||
}
|
||||
|
||||
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.
|
||||
//
|
||||
//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 _, 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.start = p.start.Add(-time.Hour)
|
||||
p.count = 1234567
|
||||
p.start = p.start.Add(-150 * time.Minute)
|
||||
p.rateStart = p.start
|
||||
p.mu.Unlock()
|
||||
|
||||
awaitFrame(t, stderr, "elapsed 1h0m0s")
|
||||
|
||||
if shown := lastFrame(stderr()); !strings.Contains(shown, count) {
|
||||
t.Errorf("terminal shows %q, want %q", shown, count)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressWarningsOnOwnLines drives the terminal display through a
|
||||
// run of warnings with no items between them, as when the walk meets
|
||||
// many unreadable paths, for many redraws: every warning must land on a
|
||||
// line of its own, never inside a redraw.
|
||||
// TestProgressWarningsOnOwnLines drives the terminal displays, spinner
|
||||
// and bar, through a run of warnings with no items between them, as when
|
||||
// the walk meets many unreadable paths, for many redraws: every warning
|
||||
// must land on a line of its own, never inside a redraw.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestProgressWarningsOnOwnLines(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
p := startProgress(progressParams{label: scanPhase("walk"), total: -1},
|
||||
newSpinner(), testRedraw)
|
||||
|
||||
// No pause between warnings: one written straight to stderr is
|
||||
// garbled only if a redraw lands while it is being written.
|
||||
issued := 0
|
||||
for start := time.Now(); time.Since(start) < 50*testRedraw; issued++ {
|
||||
p.warnf("warning")
|
||||
cases := []progressParams{
|
||||
{label: scanPhase("walk"), total: -1},
|
||||
{label: scanPhase("hash"), total: 10},
|
||||
}
|
||||
|
||||
p.finish()
|
||||
issued := 0
|
||||
|
||||
for _, params := range cases {
|
||||
p := startProgress(params, true, testRedraw)
|
||||
|
||||
// No pause between warnings: one written straight to stderr is
|
||||
// garbled only if a redraw lands while it is being written.
|
||||
for start := time.Now(); time.Since(start) < 50*testRedraw; issued++ {
|
||||
p.warnf("warning")
|
||||
}
|
||||
|
||||
p.finish()
|
||||
}
|
||||
|
||||
// A terminal shows each line as the text after its last carriage
|
||||
// return.
|
||||
@@ -293,39 +354,41 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
|
||||
|
||||
// TestTerminalStoppedShortKeepsCount checks that the terminal display
|
||||
// of a phase that stops before its total, as an interrupted one does,
|
||||
// is left showing the count it stopped at.
|
||||
// is left showing the count it stopped at rather than a full bar.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestTerminalStoppedShortKeepsCount(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
p := startProgress(progressParams{label: scanPhase("hash"), total: 10},
|
||||
newSpinner(), redrawInterval)
|
||||
true, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarningAfterLastItemKeepsLine checks that a warning printed after
|
||||
// a phase's last item, as for a failed read of the last file, leaves
|
||||
// the phase's last line on the terminal below the warning.
|
||||
// TestWarningAfterLastItemKeepsBar checks that a warning printed after
|
||||
// a phase's last item, as for a failed read of the last file, leaves the
|
||||
// phase's full bar on the terminal below the warning.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestWarningAfterLastItemKeepsLine(t *testing.T) {
|
||||
func TestWarningAfterLastItemKeepsBar(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
p := startProgress(progressParams{label: scanPhase("hash"), total: 2},
|
||||
newSpinner(), redrawInterval)
|
||||
true, testRedraw)
|
||||
|
||||
p.increment()
|
||||
p.increment()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,7 +856,9 @@ func (s *scanState) contentCandidates(
|
||||
) ([]fileRec, map[string]scanRec, error) {
|
||||
// The search and the checks take real time on a large database;
|
||||
// without a display the scan looks hung before the reads begin.
|
||||
// Counting what the search finds first gives the checks a total.
|
||||
// The display says it is searching until SQLite returns the first
|
||||
// record. The checks start there, against a count of the records
|
||||
// the search returns, and so do their rate and ETA.
|
||||
prog := newProgress(progressParams{
|
||||
label: scanPhase("content"), total: -1,
|
||||
waitingFor: "searching the database",
|
||||
@@ -868,13 +870,12 @@ func (s *scanState) contentCandidates(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
prog.setTotal(total)
|
||||
|
||||
var (
|
||||
toRead []fileRec
|
||||
first scanRec // the current group's first record
|
||||
passed int // the current group's files that passed the check
|
||||
unread []fileRec // those of them without a content hash
|
||||
searching = true // no record has been returned yet
|
||||
toRead []fileRec
|
||||
first scanRec // the current group's first record
|
||||
passed int // the current group's files that passed the check
|
||||
unread []fileRec // those of them without a content hash
|
||||
)
|
||||
|
||||
recs := make(map[string]scanRec)
|
||||
@@ -894,6 +895,12 @@ func (s *scanState) contentCandidates(
|
||||
}
|
||||
|
||||
err = loadContentCandidates(ctx, s.db, func(r scanRec, hashed bool) {
|
||||
if searching {
|
||||
searching = false
|
||||
|
||||
prog.setTotal(total)
|
||||
}
|
||||
|
||||
prog.increment()
|
||||
|
||||
if r.size != first.size || r.head != first.head || r.tail != first.tail {
|
||||
@@ -924,6 +931,11 @@ func (s *scanState) contentCandidates(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// A search that returned no record still ends showing its count.
|
||||
if searching {
|
||||
prog.setTotal(total)
|
||||
}
|
||||
|
||||
endGroup()
|
||||
|
||||
return toRead, recs, nil
|
||||
|
||||
Reference in New Issue
Block a user