Files
sfdupes/progress_test.go
T
clawbot f9ba1a3c5a
check / check (push) Successful in 1m25s
Print progress at once off a terminal, keep stderr to one writer (closes #13)
On a pipe, a file or /dev/null each scan phase now prints its zero-state
line the moment it starts instead of after its first finished item, so
a slow first read under cron no longer leaves the log silent. Terminal
detection uses golang.org/x/term, now a direct dependency listed in the
README; the old character-device test took /dev/null for a terminal.
The walk spinner no longer redraws from the library's own goroutine; it
advances only as items complete, so a redraw cannot garble a warning.
Building the bar moved into newBar so the terminal display can be
tested without a terminal.

Lint suppressed: gosec on two test file opens, paralleltest on three
tests that replace os.Stderr.

Model: opus-5-5
2026-10-03 13:26:35 +00:00

151 lines
3.4 KiB
Go

package main
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
)
// spinnerIdle outlasts the 100ms interval at which the progressbar
// library redraws a spinner from its own goroutine unless told not to.
const spinnerIdle = 300 * time.Millisecond
// captureStderr points os.Stderr at a file for the rest of the test and
// returns a function reading back everything written to it.
func captureStderr(t *testing.T) func() string {
t.Helper()
path := filepath.Join(t.TempDir(), "stderr")
f, err := os.Create(path) //nolint:gosec // test-controlled path
if err != nil {
t.Fatal(err)
}
saved := os.Stderr
os.Stderr = f
t.Cleanup(func() {
os.Stderr = saved
_ = f.Close()
})
return func() string {
b, err := os.ReadFile(path) //nolint:gosec // test-controlled path
if err != nil {
t.Fatal(err)
}
return string(b)
}
}
//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)
}
}
}
// TestNewProgressPrintsBeforeFirstItem checks that each pass shows its
// zero state the moment it starts when stderr is not a terminal, and
// that the next line still waits for plainInterval.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestNewProgressPrintsBeforeFirstItem(t *testing.T) {
stderr := captureStderr(t)
newProgress("walk", -1).increment()
newProgress("hash", 10).increment()
want := "walk: 0 files, elapsed 0s\n" +
"hash: [0/10] 0% 0 files/s elapsed 0s eta ?\n"
if got := stderr(); got != want {
t.Errorf("stderr = %q, want %q", got, want)
}
}
// TestProgressWarningsOnOwnLines drives the terminal display of the walk
// pass: every warning must land on a line of its own between redraws of
// the spinner, and nothing may redraw while no method is being called,
// or a warning could be garbled by a redraw from another goroutine.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestProgressWarningsOnOwnLines(t *testing.T) {
stderr := captureStderr(t)
p := &progress{
label: "walk", total: -1, start: time.Now(),
bar: newBar("walk", -1),
}
for _, w := range []string{"first", "second", "third"} {
p.increment()
p.warnf("warning %s", w)
}
p.increment()
before := stderr()
time.Sleep(spinnerIdle)
if after := stderr(); after != before {
t.Errorf("stderr written while idle: %q", after[len(before):])
}
// A terminal shows each line as the text after its last carriage
// return.
var warnings []string
for line := range strings.SplitSeq(before, "\n") {
if strings.Contains(line, "warning") {
warnings = append(warnings, line[strings.LastIndex(line, "\r")+1:])
}
}
want := []string{"warning first", "warning second", "warning third"}
if !slices.Equal(warnings, want) {
t.Errorf("warning lines = %q, want %q", warnings, want)
}
}