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
502 lines
14 KiB
Go
502 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// testRedraw is the redraw interval the tests give a display, so that
|
|
// many redraws fit in a few milliseconds.
|
|
const testRedraw = time.Millisecond
|
|
|
|
// testDeadline bounds how long a test waits for a display to draw what
|
|
// it expects.
|
|
const testDeadline = 5 * time.Second
|
|
|
|
//nolint:paralleltest // replaces the process-wide os.Stderr
|
|
func TestStderrIsTTYFalseForNonTerminals(t *testing.T) {
|
|
r, pipe, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
regular, err := os.Create(filepath.Join(t.TempDir(), "stderr"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
saved := os.Stderr
|
|
|
|
t.Cleanup(func() {
|
|
os.Stderr = saved
|
|
|
|
for _, f := range []*os.File{r, pipe, regular, devNull} {
|
|
_ = f.Close()
|
|
}
|
|
})
|
|
|
|
cases := map[string]*os.File{
|
|
"a pipe": pipe,
|
|
"a regular file": regular,
|
|
os.DevNull: devNull,
|
|
}
|
|
|
|
for name, f := range cases {
|
|
os.Stderr = f
|
|
|
|
if stderrIsTTY() {
|
|
t.Errorf("stderrIsTTY() = true with stderr on %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPhaseLabel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
phases := []string{"load", "walk", "hash"}
|
|
if got := phaseLabel(phases, "walk"); got != "[2/3] walk" {
|
|
t.Errorf("phaseLabel = %q, want %q", got, "[2/3] walk")
|
|
}
|
|
}
|
|
|
|
// TestProgressLine checks the line a display shows in each state, ten
|
|
// seconds into the phase.
|
|
func TestProgressLine(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ago := time.Now().Add(-10 * time.Second)
|
|
|
|
cases := []struct {
|
|
p *progress
|
|
want string
|
|
}{{
|
|
p: &progress{
|
|
label: scanPhase("load"), total: -1,
|
|
waitingFor: "counting the records",
|
|
},
|
|
want: "[1/5] load: counting the records, elapsed 10s, eta unknown",
|
|
}, {
|
|
p: &progress{label: scanPhase("hash"), total: 100, count: 20},
|
|
want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
|
|
}, {
|
|
p: &progress{label: scanPhase("hash"), total: 100},
|
|
want: "[3/5] hash: [0/100] 0%, 0 files/s, elapsed 10s, eta unknown",
|
|
}, {
|
|
p: &progress{label: scanPhase("update"), total: 0},
|
|
want: "[4/5] update: [0/0] 100%, 0 files/s, elapsed 10s, eta 0s",
|
|
}, {
|
|
p: &progress{
|
|
label: scanPhase("walk"), total: 100, estimate: true, count: 20,
|
|
},
|
|
want: "[2/5] walk: [20/~100] 20%, 2 files/s, elapsed 10s, eta 40s",
|
|
}, {
|
|
// Past the estimate the walk cannot tell how long it has left.
|
|
p: &progress{
|
|
label: scanPhase("walk"), total: 100, estimate: true, count: 120,
|
|
},
|
|
want: "[2/5] walk: [120/~100] 120%, 12 files/s, elapsed 10s, " +
|
|
"eta unknown",
|
|
}, {
|
|
// A first scan's walk has no estimate.
|
|
p: &progress{label: scanPhase("walk"), total: -1, count: 20},
|
|
want: "[2/5] walk: 20 files, 2 files/s, elapsed 10s, eta unknown",
|
|
}}
|
|
|
|
for _, tc := range cases {
|
|
tc.p.start, tc.p.rateStart = ago, ago
|
|
|
|
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.
|
|
func TestSetTotalStartsRate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
waited := time.Now().Add(-20 * time.Second)
|
|
p := &progress{
|
|
label: scanPhase("load"), total: -1, start: waited, rateStart: waited,
|
|
}
|
|
|
|
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(math.MaxInt, true); 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
|
|
// has passed, and its line again as it ends.
|
|
//
|
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
|
func TestPlainProgressPrintsAtStartAndEnd(t *testing.T) {
|
|
stderr := captureStderr(t)
|
|
p := newProgress(progressParams{label: scanPhase("hash"), total: 1000})
|
|
|
|
for range 1000 {
|
|
p.increment()
|
|
}
|
|
|
|
p.finish()
|
|
|
|
lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n")
|
|
|
|
want := "[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown"
|
|
if len(lines) != 2 || lines[0] != want ||
|
|
!strings.HasPrefix(lines[1], "[3/5] hash: [1000/1000] 100%, ") {
|
|
t.Errorf("stderr lines = %q, want %q, then the line at 1000",
|
|
lines, want)
|
|
}
|
|
}
|
|
|
|
// TestPlainProgressPrintsWhileWaiting checks that a phase waiting with
|
|
// no completed items still prints a line every interval.
|
|
//
|
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
|
func TestPlainProgressPrintsWhileWaiting(t *testing.T) {
|
|
stderr := captureStderr(t)
|
|
p := startProgress(progressParams{
|
|
label: scanPhase("load"), total: -1,
|
|
waitingFor: "counting the records",
|
|
}, nil, testRedraw)
|
|
|
|
awaitOutput(t, stderr,
|
|
"[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3)
|
|
p.finish()
|
|
}
|
|
|
|
// lastFrame returns what a terminal shows of the frames a display drew:
|
|
// the last one. The library starts each frame with a carriage return
|
|
// and erases the previous one with spaces first.
|
|
func lastFrame(out string) string {
|
|
var shown string
|
|
|
|
for frame := range strings.SplitSeq(out, "\r") {
|
|
if strings.TrimSpace(frame) != "" {
|
|
shown = frame
|
|
}
|
|
}
|
|
|
|
return shown
|
|
}
|
|
|
|
// TestTerminalRedrawsWhileWaiting checks that the terminal display of a
|
|
// 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)
|
|
|
|
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],"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
before := len(stderr())
|
|
since := func() string { return stderr()[before:] }
|
|
p := startProgress(tc.params, terminalWidth, testRedraw)
|
|
|
|
for range 50 {
|
|
p.increment()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
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)
|
|
|
|
if width == 120 && !strings.Contains(out, "% |") {
|
|
t.Errorf("no bar on a terminal %d wide: %q", width, out)
|
|
}
|
|
|
|
lines := strings.FieldsFuncSeq(out, func(r rune) bool {
|
|
return r == '\r' || r == '\n'
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
cases := []progressParams{
|
|
{label: scanPhase("walk"), total: -1},
|
|
{label: scanPhase("hash"), total: 10},
|
|
}
|
|
|
|
issued := 0
|
|
|
|
for _, params := range cases {
|
|
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.
|
|
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.
|
|
shown := 0
|
|
|
|
for line := range strings.SplitSeq(stderr(), "\n") {
|
|
if !strings.Contains(line, "warning") {
|
|
continue
|
|
}
|
|
|
|
shown++
|
|
|
|
if text := line[strings.LastIndex(line, "\r")+1:]; text != "warning" {
|
|
t.Errorf("terminal shows %q, want %q", text, "warning")
|
|
}
|
|
}
|
|
|
|
if shown != issued {
|
|
t.Errorf("%d warning lines, want %d", shown, issued)
|
|
}
|
|
}
|
|
|
|
// 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 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},
|
|
terminalWidth, redrawInterval)
|
|
|
|
p.increment()
|
|
p.increment()
|
|
p.finish()
|
|
|
|
if shown := lastFrame(stderr()); !strings.Contains(shown, "[2/10],") {
|
|
t.Errorf("terminal shows %q, want a count of 2 of 10", shown)
|
|
}
|
|
}
|
|
|
|
// 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 TestWarningAfterLastItemKeepsBar(t *testing.T) {
|
|
stderr := captureStderr(t)
|
|
p := startProgress(progressParams{label: scanPhase("hash"), total: 2},
|
|
terminalWidth, 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],") {
|
|
t.Errorf("terminal shows %q, want a count of 2 of 2", shown)
|
|
}
|
|
}
|