Files
got/progress/progress_test.go
Hojun-Cho 16cbb97748 progress: show multiple downloads as a bar table
lineMany crammed every download onto one line as name+percent, dropping
the speed column that answers "which one is stalled" and producing ugly
double brackets for names like "[Gecko]". render N>1 as a redrawn block:
one row each (name, bar, percent, DL speed, ETA/stalled), a header with
the active count and totals, falling back to a single summary line when
the table would overflow the window. one download keeps its dashboard.
2026-06-20 19:45:49 +09:00

231 lines
7.8 KiB
Go

package progress
import (
"bytes"
"strings"
"testing"
"time"
"github.com/hanbok/got/download"
)
// reporterAt builds a Reporter wired to a fixed stat snapshot for rendering tests.
func newReporter(human bool) *Reporter {
return &Reporter{human: human, width: 200, win: map[string]*speedWindow{}}
}
// TestSlidingWindowSpeed feeds samples across a ~10s window and checks the rate
// is the windowed delta over its real duration, not the last-tick delta.
func TestSlidingWindowSpeed(t *testing.T) {
r := newReporter(false)
base := time.Unix(1000, 0)
// Five one-second ticks, +1000 bytes each: window holds 4s of deltas.
w := &speedWindow{}
r.win["x"] = w
for i := 0; i < 5; i++ {
w.add(base.Add(time.Duration(i)*time.Second), int64(i*1000), 0)
}
s := download.Stat{ID: "x", Completed: 4000}
dl, ul := r.rates(s)
if dl != 1000 {
t.Errorf("dl = %d, want 1000", dl)
}
if ul != 0 {
t.Errorf("ul = %d, want 0", ul)
}
}
// TestWindowDropsStale ensures samples older than windowTime are evicted so the
// baseline of the delta stays inside the window.
func TestWindowDropsStale(t *testing.T) {
w := &speedWindow{}
base := time.Unix(0, 0)
// One old sample, then a fresh one 20s later.
w.add(base, 0, 0)
w.add(base.Add(20*time.Second), 5000, 0)
if len(w.samples) != 1 {
t.Fatalf("len(samples) = %d, want 1 (stale dropped)", len(w.samples))
}
}
// TestSingleSampleZero: with only one sample there is no span, so speed is 0.
func TestSingleSampleZero(t *testing.T) {
r := newReporter(false)
r.win["x"] = &speedWindow{}
r.win["x"].add(time.Unix(0, 0), 1<<20, 0)
if dl, _ := r.rates(download.Stat{ID: "x"}); dl != 0 {
t.Errorf("dl = %d, want 0 for single sample", dl)
}
}
// TestRatesKeyedByID confirms two downloads sharing a Name don't cross-subtract
// because the window is keyed on Stat.ID.
func TestRatesKeyedByID(t *testing.T) {
r := newReporter(false)
base := time.Unix(0, 0)
for _, id := range []string{"a", "b"} {
r.win[id] = &speedWindow{}
r.win[id].add(base, 0, 0)
}
r.win["a"].add(base.Add(time.Second), 1000, 0)
r.win["b"].add(base.Add(time.Second), 2000, 0)
if dl, _ := r.rates(download.Stat{ID: "a", Name: "dup"}); dl != 1000 {
t.Errorf("a dl = %d, want 1000", dl)
}
if dl, _ := r.rates(download.Stat{ID: "b", Name: "dup"}); dl != 2000 {
t.Errorf("b dl = %d, want 2000", dl)
}
}
// TestLineOneSeeding: while seeding, render SEED(ratio), drop DL:, keep UL:.
func TestLineOneSeeding(t *testing.T) {
r := newReporter(true)
s := download.Stat{
ID: "h", Name: "f", IsBT: true, Status: download.Seeding,
Total: 1000, Completed: 1000, Uploaded: 1500, Conns: 3, Seeders: 2,
}
line := r.lineOne(s)
if !strings.Contains(line, "SEED(1.5)") {
t.Errorf("missing SEED(1.5): %q", line)
}
if strings.Contains(line, "DL:") {
t.Errorf("DL: should be dropped while seeding: %q", line)
}
if !strings.Contains(line, "UL:") {
t.Errorf("UL: should be kept while seeding: %q", line)
}
}
// TestLineOneCNSD: CN always shown; SD shown for torrents (even with 0 seeders),
// absent for HTTP.
func TestLineOneCNSD(t *testing.T) {
r := newReporter(true)
bt := r.lineOne(download.Stat{ID: "h", Name: "f", IsBT: true, Total: 10, Completed: 1, Conns: 4, Seeders: 0})
if !strings.Contains(bt, "CN:4") || !strings.Contains(bt, "SD:0") {
t.Errorf("torrent line missing CN/SD: %q", bt)
}
http := r.lineOne(download.Stat{ID: "h2", Name: "f", IsBT: false, Total: 10, Completed: 1, Conns: 1})
if !strings.Contains(http, "CN:1") {
t.Errorf("http line missing CN: %q", http)
}
if strings.Contains(http, "SD:") {
t.Errorf("http line should not show SD: %q", http)
}
}
func TestBarOf(t *testing.T) {
tests := []struct {
name string
s download.Stat
want string
}{
{"empty", download.Stat{Status: download.Active, Total: 100, Completed: 0}, "|------------|"},
{"half", download.Stat{Status: download.Active, Total: 100, Completed: 50}, "|######------|"},
{"full by complete", download.Stat{Status: download.Complete}, "|############|"},
{"full by seeding", download.Stat{Status: download.Seeding}, "|############|"},
{"unknown total is empty", download.Stat{Status: download.Active, Total: -1, Completed: 9}, "|------------|"},
}
for _, tc := range tests {
if got := barOf(tc.s); got != tc.want {
t.Errorf("%s: barOf = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestPctField(t *testing.T) {
if got := pctField(download.Stat{Status: download.Active, Total: 100, Completed: 50}); got != " 50%" {
t.Errorf("active 50%% = %q, want ' 50%%'", got)
}
if got := pctField(download.Stat{Status: download.Seeding}); got != "100%" {
t.Errorf("seeding = %q, want 100%%", got)
}
if got := pctField(download.Stat{Status: download.Active, Total: -1}); got != " --" {
t.Errorf("unknown total = %q, want ' --'", got)
}
}
// TestRowMetric: the two trailing fields per download state.
func TestRowMetric(t *testing.T) {
r := newReporter(true)
if m, tail := r.rowMetric(download.Stat{Status: download.Active, Total: 100, Completed: 50}, 10, 0); !strings.HasPrefix(m, "DL:") || tail == "" {
t.Errorf("active downloading: metric=%q tail=%q, want DL:… + an ETA", m, tail)
}
if m, tail := r.rowMetric(download.Stat{Status: download.Seeding}, 0, 128<<10); m != "seeding" || !strings.HasPrefix(tail, "UL:") {
t.Errorf("seeding: metric=%q tail=%q, want seeding + UL:…", m, tail)
}
if m, _ := r.rowMetric(download.Stat{Status: download.Complete}, 0, 0); m != "done" {
t.Errorf("complete metric=%q, want done", m)
}
}
// TestBlockTableAndFallback: several downloads render as header+rows when they
// fit, and collapse to a single summary line when the window is too short.
func TestBlockTableAndFallback(t *testing.T) {
r := newReporter(true)
stats := []download.Stat{
{ID: "a", Name: "a", Status: download.Active, Total: 100, Completed: 50},
{ID: "b", Name: "b", Status: download.Active, Total: 100, Completed: 10},
}
r.height = 24
if got := r.block(stats); len(got) != 3 { // header + 2 rows
t.Errorf("table block = %d lines, want 3:\n%v", len(got), got)
}
r.height = 2 // header + 2 rows = 3 > 2
if got := r.block(stats); len(got) != 1 {
t.Errorf("overflow block = %d lines, want 1 summary:\n%v", len(got), got)
}
}
// TestRenderRedraw: the TTY path draws the block, then on the next frame moves
// the cursor up over the previous frame and clears it before repainting.
func TestRenderRedraw(t *testing.T) {
snap := []download.Stat{
{ID: "a", Name: "alpha", IsBT: true, Status: download.Active, Total: 100, Completed: 50},
{ID: "b", Name: "beta", IsBT: true, Status: download.Seeding, Total: 100, Completed: 100, Uploaded: 200},
}
var buf bytes.Buffer
r := &Reporter{
snap: func() []download.Stat { return snap },
human: true, w: &buf, tty: true, width: 200, height: 24,
win: map[string]*speedWindow{},
}
r.render(false)
first := buf.String()
for _, want := range []string{"2 active", "alpha", "seeding", "|"} {
if !strings.Contains(first, want) {
t.Fatalf("first frame missing %q:\n%q", want, first)
}
}
buf.Reset()
r.render(false)
second := buf.String()
if !strings.Contains(second, "\x1b[2A") { // up prevLines-1 = 3-1
t.Errorf("second frame should move cursor up 2 lines:\n%q", second)
}
if !strings.Contains(second, "\x1b[J") {
t.Errorf("second frame should clear to end of screen:\n%q", second)
}
}
// TestShortNameRune ensures CJK names are truncated on runes, not bytes.
func TestShortNameRune(t *testing.T) {
name := strings.Repeat("あ", 30) // 30 runes, 90 bytes
got := shortName(name)
runes := []rune(got)
if len(runes) != 20 {
t.Errorf("shortName rune count = %d, want 20", len(runes))
}
if runes[len(runes)-1] != '~' {
t.Errorf("expected trailing ~, got %q", got)
}
}
// TestClampRunes clamps on runes so a multibyte glyph is never split.
func TestClampRunes(t *testing.T) {
s := strings.Repeat("あ", 10) // 10 runes
got := clampRunes(s, 5)
if r := []rune(got); len(r) != 5 {
t.Errorf("clampRunes rune count = %d, want 5", len(r))
}
}