Drop the stacked progress bars for an aligned column table under a header. Middle-elide names so downloads sharing a long prefix stay distinct (a leading truncation rendered them identically), drop the per-row DL:/UL: labels, and break the summary into downloading/seeding/ queued counts. Add SIZE (completed/total) and CN/SD (peer/seeder) columns and show the seed share ratio in place of an idle upload rate. Also fix the in-place redraw: the block could fill the pane's bottom row and scroll it out from under the cursor, stranding a summary line each tick. Reserve that row so the table falls back to the summary line instead.
266 lines
9.8 KiB
Go
266 lines
9.8 KiB
Go
package progress
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"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 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 rate column and trailing field per download state.
|
|
func TestRowMetric(t *testing.T) {
|
|
r := newReporter(true)
|
|
if rate, tail := r.rowMetric(download.Stat{Status: download.Active, Total: 100, Completed: 50}, 10, 0); !strings.HasSuffix(rate, "/s") || tail == "" {
|
|
t.Errorf("active downloading: rate=%q tail=%q, want a …/s rate + an ETA", rate, tail)
|
|
}
|
|
if rate, tail := r.rowMetric(download.Stat{Status: download.Seeding, Completed: 1000, Uploaded: 1500}, 0, 0); rate != "seeding" || tail != "r1.50" {
|
|
t.Errorf("seeding: rate=%q tail=%q, want seeding + r1.50 (share ratio)", rate, tail)
|
|
}
|
|
if rate, _ := r.rowMetric(download.Stat{Status: download.Complete}, 0, 0); rate != "done" {
|
|
t.Errorf("complete rate=%q, want done", rate)
|
|
}
|
|
}
|
|
|
|
// TestPeerFields: CN (connections/peers) and SD (seeders) show for active and
|
|
// seeding downloads; SD is blank for HTTP; both blank when there are no live
|
|
// connections (queued).
|
|
func TestPeerFields(t *testing.T) {
|
|
if cn, sd := peerFields(download.Stat{Status: download.Active, IsBT: true, Conns: 18, Seeders: 4}); cn != "18" || sd != "4" {
|
|
t.Errorf("active torrent: cn=%q sd=%q, want 18 / 4", cn, sd)
|
|
}
|
|
if cn, sd := peerFields(download.Stat{Status: download.Active, IsBT: false, Conns: 5}); cn != "5" || sd != "" {
|
|
t.Errorf("active http: cn=%q sd=%q, want 5 / blank", cn, sd)
|
|
}
|
|
if cn, sd := peerFields(download.Stat{Status: download.Waiting, Conns: 0}); cn != "" || sd != "" {
|
|
t.Errorf("queued: cn=%q sd=%q, want blank / blank", cn, sd)
|
|
}
|
|
}
|
|
|
|
// TestSizeField: completed/total when the total is known, just the completed
|
|
// bytes when it is not, and blank for a queued download.
|
|
func TestSizeField(t *testing.T) {
|
|
if got := sizeField(download.Stat{Status: download.Active, Completed: 512, Total: 2048}, false); got != "512B/2048B" {
|
|
t.Errorf("known total = %q, want 512B/2048B", got)
|
|
}
|
|
if got := sizeField(download.Stat{Status: download.Active, Completed: 512, Total: -1}, false); got != "512B" {
|
|
t.Errorf("unknown total = %q, want 512B", got)
|
|
}
|
|
if got := sizeField(download.Stat{Status: download.Waiting, Total: 2048}, false); got != "" {
|
|
t.Errorf("queued = %q, want blank", got)
|
|
}
|
|
}
|
|
|
|
// TestBlockTableAndFallback: several downloads render as summary+header+rows when
|
|
// they fit, and collapse to a single summary line when the window is too short.
|
|
// The fallback must leave the terminal's bottom row free (the stacked-summary
|
|
// redraw fix): summary+header+2 rows is 4 lines, so it needs height >= 5.
|
|
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) != 4 { // summary + header + 2 rows
|
|
t.Errorf("table block = %d lines, want 4:\n%v", len(got), got)
|
|
}
|
|
r.height = 5 // 4 lines fit with the bottom row left free
|
|
if got := r.block(stats); len(got) != 4 {
|
|
t.Errorf("just-fits block = %d lines, want 4:\n%v", len(got), got)
|
|
}
|
|
r.height = 4 // one short: would fill to the bottom row, so fall back
|
|
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{"downloading", "seeding", "alpha", "NAME"} {
|
|
if !strings.Contains(first, want) {
|
|
t.Fatalf("first frame missing %q:\n%q", want, first)
|
|
}
|
|
}
|
|
if strings.Contains(first, "|") {
|
|
t.Errorf("first frame should carry no progress bar:\n%q", first)
|
|
}
|
|
buf.Reset()
|
|
r.render(false)
|
|
second := buf.String()
|
|
if !strings.Contains(second, "\x1b[3A") { // up prevLines-1 = 4-1 (summary+header+2 rows)
|
|
t.Errorf("second frame should move cursor up 3 lines:\n%q", second)
|
|
}
|
|
if !strings.Contains(second, "\x1b[J") {
|
|
t.Errorf("second frame should clear to end of screen:\n%q", second)
|
|
}
|
|
}
|
|
|
|
// TestElideKeepsTail: names sharing a long prefix but differing at the end stay
|
|
// distinct — the failure the old leading truncation caused — and the result is
|
|
// capped at the column width on runes with the distinguishing tail preserved.
|
|
func TestElideKeepsTail(t *testing.T) {
|
|
a := elide("[Group] Long Common Series Name vol.1", nameW)
|
|
b := elide("[Group] Long Common Series Name vol.2", nameW)
|
|
if a == b {
|
|
t.Errorf("names differing only in the tail collapsed: %q == %q", a, b)
|
|
}
|
|
if n := utf8.RuneCountInString(a); n != nameW {
|
|
t.Errorf("elide rune count = %d, want %d (%q)", n, nameW, a)
|
|
}
|
|
if !strings.Contains(a, "…") {
|
|
t.Errorf("expected a middle ellipsis: %q", a)
|
|
}
|
|
if !strings.HasSuffix(a, "vol.1") {
|
|
t.Errorf("expected the distinguishing tail kept: %q", a)
|
|
}
|
|
}
|
|
|
|
// TestElideRune ensures CJK names are elided on runes, not bytes.
|
|
func TestElideRune(t *testing.T) {
|
|
name := strings.Repeat("あ", 30) // 30 runes, 90 bytes
|
|
if n := utf8.RuneCountInString(elide(name, nameW)); n != nameW {
|
|
t.Errorf("elide rune count = %d, want %d", n, nameW)
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|