progress: redesign the multi-download table

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.
This commit is contained in:
2026-06-22 04:13:58 +09:00
parent b632f37e3d
commit a40cc67c38
2 changed files with 204 additions and 104 deletions

View File

@@ -5,6 +5,7 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/hanbok/got/download"
)
@@ -113,25 +114,6 @@ func TestLineOneCNSD(t *testing.T) {
}
}
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)
@@ -144,22 +126,53 @@ func TestPctField(t *testing.T) {
}
}
// TestRowMetric: the two trailing fields per download state.
// TestRowMetric: the rate column and trailing field 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 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 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 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 m, _ := r.rowMetric(download.Stat{Status: download.Complete}, 0, 0); m != "done" {
t.Errorf("complete metric=%q, want done", m)
if rate, _ := r.rowMetric(download.Stat{Status: download.Complete}, 0, 0); rate != "done" {
t.Errorf("complete rate=%q, want done", rate)
}
}
// TestBlockTableAndFallback: several downloads render as header+rows when they
// fit, and collapse to a single summary line when the window is too short.
// 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{
@@ -167,10 +180,14 @@ func TestBlockTableAndFallback(t *testing.T) {
{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)
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 = 2 // header + 2 rows = 3 > 2
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)
}
@@ -191,32 +208,50 @@ func TestRenderRedraw(t *testing.T) {
}
r.render(false)
first := buf.String()
for _, want := range []string{"2 active", "alpha", "seeding", "|"} {
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[2A") { // up prevLines-1 = 3-1
t.Errorf("second frame should move cursor up 2 lines:\n%q", second)
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)
}
}
// 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))
// 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 runes[len(runes)-1] != '~' {
t.Errorf("expected trailing ~, got %q", got)
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)
}
}