first commit
This commit is contained in:
79
progress/format.go
Normal file
79
progress/format.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// humanSize formats a byte count by abbreviating it: divide by
|
||||
// 1024 while the value is at least one unit and a larger unit remains, then bump
|
||||
// to the next unit when the quotient is >=922 (so 0.9Mi shows instead of 922Ki).
|
||||
// The unit ladder is capped at Gi, so a terabyte prints
|
||||
// as "1024.0GiB". When human is false it prints the raw integer with a B suffix.
|
||||
func humanSize(n int64, human bool) string {
|
||||
if !human {
|
||||
return fmt.Sprintf("%dB", n)
|
||||
}
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%dB", n)
|
||||
}
|
||||
// "KMG" caps the ladder at Gi (the units {"","Ki","Mi","Gi"}).
|
||||
const suffixes = "KMG"
|
||||
div, exp := int64(unit), 0
|
||||
for v := n / unit; v >= unit && exp+1 < len(suffixes); v /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
// Bump to the next unit when the quotient is >=922 and a unit remains, so
|
||||
// 922*1024 bytes reads as 0.9MiB rather than 922KiB.
|
||||
if q := n / div; q >= 922 && exp+1 < len(suffixes) {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
val := float64(n) / float64(div)
|
||||
suffix := suffixes[exp]
|
||||
// One decimal for small mantissas and for anything that overflowed the
|
||||
// capped Gi unit (1024.0GiB and up); a bare integer otherwise (e.g. 20MiB).
|
||||
if val < 10 || exp == len(suffixes)-1 && val >= unit {
|
||||
return fmt.Sprintf("%.1f%ciB", val, suffix)
|
||||
}
|
||||
return fmt.Sprintf("%.0f%ciB", val, suffix)
|
||||
}
|
||||
|
||||
// speed formats a bytes-per-second rate. The DL:/UL: fields show the bare
|
||||
// abbreviated size with no "/s" suffix.
|
||||
func speed(bytesPerSec int64, human bool) string {
|
||||
return humanSize(bytesPerSec, human)
|
||||
}
|
||||
|
||||
// secfmt renders a duration as "1h2m3s", appending each unit only when it is
|
||||
// nonzero (but still showing seconds when the whole input is 0):
|
||||
// 3600s->"1h", 120s->"2m", 3720s->"1h2m". A non-positive or absurd
|
||||
// duration renders as "--".
|
||||
func secfmt(d time.Duration) string {
|
||||
s := int64(d.Seconds())
|
||||
if s < 0 || d > 99*time.Hour {
|
||||
return "--"
|
||||
}
|
||||
h, m, sec := s/3600, (s%3600)/60, s%60
|
||||
var str string
|
||||
if h > 0 {
|
||||
str += fmt.Sprintf("%dh", h)
|
||||
}
|
||||
if m > 0 {
|
||||
str += fmt.Sprintf("%dm", m)
|
||||
}
|
||||
if sec > 0 || s == 0 {
|
||||
str += fmt.Sprintf("%ds", sec)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
// percent returns completed/total as a 0-100 integer, or 0 when total is unknown.
|
||||
func percent(completed, total int64) int {
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(completed * 100 / total)
|
||||
}
|
||||
73
progress/format_test.go
Normal file
73
progress/format_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHumanSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
n int64
|
||||
human bool
|
||||
want string
|
||||
}{
|
||||
{500, true, "500B"},
|
||||
{1536, true, "1.5KiB"},
|
||||
{1 << 20, true, "1.0MiB"},
|
||||
{5 * 1 << 20, true, "5.0MiB"},
|
||||
{20 * 1 << 20, true, "20MiB"},
|
||||
{1 << 30, true, "1.0GiB"},
|
||||
{2048, false, "2048B"},
|
||||
// abbrevSize bumps to the next unit once the quotient reaches 922, so
|
||||
// 922*1024 bytes reads as 0.9MiB rather than 922KiB.
|
||||
{922 * 1024, true, "0.9MiB"},
|
||||
{921 * 1024, true, "921KiB"},
|
||||
// The unit ladder is capped at Gi, so a terabyte overflows GiB.
|
||||
{1 << 40, true, "1024.0GiB"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := humanSize(tc.n, tc.human); got != tc.want {
|
||||
t.Errorf("humanSize(%d, %v) = %q, want %q", tc.n, tc.human, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecfmt(t *testing.T) {
|
||||
tests := []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{0, "0s"},
|
||||
{30 * time.Second, "30s"},
|
||||
{90 * time.Second, "1m30s"},
|
||||
{120 * time.Second, "2m"},
|
||||
{3600 * time.Second, "1h"},
|
||||
{3661 * time.Second, "1h1m1s"},
|
||||
{3720 * time.Second, "1h2m"},
|
||||
{-1 * time.Second, "--"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := secfmt(tc.d); got != tc.want {
|
||||
t.Errorf("secfmt(%v) = %q, want %q", tc.d, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedNoSuffix(t *testing.T) {
|
||||
// The DL:/UL: fields show the bare abbreviated size with no "/s".
|
||||
if got := speed(1536, true); got != "1.5KiB" {
|
||||
t.Errorf("speed(1536,true) = %q, want %q", got, "1.5KiB")
|
||||
}
|
||||
if got := speed(2048, false); got != "2048B" {
|
||||
t.Errorf("speed(2048,false) = %q, want %q", got, "2048B")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPercent(t *testing.T) {
|
||||
if got := percent(50, 100); got != 50 {
|
||||
t.Errorf("percent(50,100) = %d, want 50", got)
|
||||
}
|
||||
if got := percent(1, 0); got != 0 {
|
||||
t.Errorf("percent(1,0) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
274
progress/progress.go
Normal file
274
progress/progress.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// Package progress renders a live, single-line status display. One goroutine ticks
|
||||
// once a second, pulls a snapshot of the running downloads, and derives speeds
|
||||
// from the change since the previous tick. There are no locks here: the data
|
||||
// arrives by value through the snapshot function.
|
||||
package progress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/hanbok/got/download"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Reporter draws the progress line until its context is cancelled.
|
||||
type Reporter struct {
|
||||
snap func() []download.Stat
|
||||
human bool
|
||||
interval time.Duration
|
||||
w io.Writer
|
||||
tty bool
|
||||
width int
|
||||
|
||||
win map[string]*speedWindow // keyed by Stat.ID
|
||||
lastLog time.Time // last non-TTY line emitted
|
||||
}
|
||||
|
||||
// logEvery bounds how often progress is printed when output is not a TTY, so a
|
||||
// redirected or piped run does not get a line every second.
|
||||
const logEvery = 30 * time.Second
|
||||
|
||||
// windowTime is the span of the sliding speed window (10s): the rate is the
|
||||
// byte delta across this window divided by its real duration, which smooths the
|
||||
// per-tick jitter.
|
||||
const windowTime = 10 * time.Second
|
||||
|
||||
// sample is one (time, cumulative-counters) observation in a speedWindow.
|
||||
type sample struct {
|
||||
t time.Time
|
||||
completed, uploaded int64
|
||||
}
|
||||
|
||||
// speedWindow keeps the last ~windowTime of samples for one download so we can
|
||||
// derive down/up speed from the change across the window rather than the last
|
||||
// tick alone. Samples are appended in time order, so stale ones live at the
|
||||
// front.
|
||||
type speedWindow struct {
|
||||
samples []sample
|
||||
}
|
||||
|
||||
// New returns a Reporter that reads live stats from snap.
|
||||
func New(snap func() []download.Stat, human bool) *Reporter {
|
||||
return &Reporter{
|
||||
snap: snap,
|
||||
human: human,
|
||||
interval: time.Second,
|
||||
w: os.Stdout,
|
||||
tty: term.IsTerminal(int(os.Stdout.Fd())),
|
||||
width: 80,
|
||||
win: map[string]*speedWindow{},
|
||||
}
|
||||
}
|
||||
|
||||
// Run draws on every tick and clears the line when ctx is cancelled.
|
||||
func (r *Reporter) Run(ctx context.Context) {
|
||||
t := time.NewTicker(r.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
r.render(true)
|
||||
return
|
||||
case <-t.C:
|
||||
r.render(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reporter) render(final bool) {
|
||||
stats := r.snap()
|
||||
now := time.Now()
|
||||
|
||||
// Record one sample per download before deriving rates, and forget windows
|
||||
// for downloads that have dropped out of the snapshot.
|
||||
seen := make(map[string]bool, len(stats))
|
||||
for _, s := range stats {
|
||||
seen[s.ID] = true
|
||||
w := r.win[s.ID]
|
||||
if w == nil {
|
||||
w = &speedWindow{}
|
||||
r.win[s.ID] = w
|
||||
}
|
||||
w.add(now, s.Completed, s.Uploaded)
|
||||
}
|
||||
for id := range r.win {
|
||||
if !seen[id] {
|
||||
delete(r.win, id)
|
||||
}
|
||||
}
|
||||
|
||||
var line string
|
||||
switch len(stats) {
|
||||
case 0:
|
||||
if !final && r.tty {
|
||||
return // nothing active; leave the line as-is
|
||||
}
|
||||
case 1:
|
||||
line = r.lineOne(stats[0])
|
||||
default:
|
||||
line = r.lineMany(stats)
|
||||
}
|
||||
|
||||
if r.tty {
|
||||
r.width = termWidth(r.width)
|
||||
line = clampRunes(line, r.width)
|
||||
fmt.Fprint(r.w, "\r"+line+"\x1b[K")
|
||||
if final {
|
||||
fmt.Fprintln(r.w)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Not a TTY: can't redraw one line, so print sparingly (and always the
|
||||
// final line) to keep redirected output and logs readable.
|
||||
if line != "" && (final || now.Sub(r.lastLog) >= logEvery) {
|
||||
fmt.Fprintln(r.w, line)
|
||||
r.lastLog = now
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reporter) lineOne(s download.Stat) string {
|
||||
dl, ul := r.rates(s)
|
||||
seeding := s.Status == download.Seeding
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[%s ", shortName(s.Name))
|
||||
// While seeding, report the share ratio in place of the size block and drop
|
||||
// the DL: field; otherwise show completed/total (or bare completed).
|
||||
if seeding {
|
||||
fmt.Fprintf(&b, "SEED(%.1f)", ratio(s.Uploaded, s.Completed))
|
||||
} else if s.Total > 0 {
|
||||
fmt.Fprintf(&b, "%s/%s(%d%%)", humanSize(s.Completed, r.human), humanSize(s.Total, r.human), percent(s.Completed, s.Total))
|
||||
} else {
|
||||
b.WriteString(humanSize(s.Completed, r.human))
|
||||
}
|
||||
// CN is always shown (including HTTP); SD is shown for any torrent.
|
||||
fmt.Fprintf(&b, " CN:%d", s.Conns)
|
||||
if s.IsBT {
|
||||
fmt.Fprintf(&b, " SD:%d", s.Seeders)
|
||||
}
|
||||
if !seeding {
|
||||
fmt.Fprintf(&b, " DL:%s", speed(dl, r.human))
|
||||
}
|
||||
if seeding || ul > 0 {
|
||||
fmt.Fprintf(&b, " UL:%s", speed(ul, r.human))
|
||||
}
|
||||
if !seeding && s.Total > 0 && dl > 0 {
|
||||
eta := time.Duration(float64(s.Total-s.Completed)/float64(dl)) * time.Second
|
||||
fmt.Fprintf(&b, " ETA:%s", secfmt(eta))
|
||||
}
|
||||
b.WriteByte(']')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (r *Reporter) lineMany(stats []download.Stat) string {
|
||||
var totDL, totUL int64
|
||||
for _, s := range stats {
|
||||
dl, ul := r.rates(s)
|
||||
totDL += dl
|
||||
totUL += ul
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[%d active DL:%s UL:%s]", len(stats), speed(totDL, r.human), speed(totUL, r.human))
|
||||
for i, s := range stats {
|
||||
if i >= 4 {
|
||||
fmt.Fprintf(&b, "[+%d]", len(stats)-i)
|
||||
break
|
||||
}
|
||||
if s.Total > 0 {
|
||||
fmt.Fprintf(&b, "[%s %d%%]", shortName(s.Name), percent(s.Completed, s.Total))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "[%s %s]", shortName(s.Name), humanSize(s.Completed, r.human))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// add records a sample and drops any that have fallen out of the window. A
|
||||
// sample is only appended once per second, but the latest counters are folded
|
||||
// into the newest slot so the final cancel-render, which typically fires in the
|
||||
// same second as the preceding tick, still sees current totals.
|
||||
func (w *speedWindow) add(now time.Time, completed, uploaded int64) {
|
||||
// Drop samples older than the window, keeping the first one that still falls
|
||||
// inside it as the baseline for the delta.
|
||||
cut := now.Add(-windowTime)
|
||||
i := 0
|
||||
for i < len(w.samples) && w.samples[i].t.Before(cut) {
|
||||
i++
|
||||
}
|
||||
w.samples = w.samples[i:]
|
||||
|
||||
if n := len(w.samples); n > 0 && now.Sub(w.samples[n-1].t) < time.Second {
|
||||
w.samples[n-1].completed = completed
|
||||
w.samples[n-1].uploaded = uploaded
|
||||
return
|
||||
}
|
||||
w.samples = append(w.samples, sample{now, completed, uploaded})
|
||||
}
|
||||
|
||||
// rates derives down/up speed in bytes/s from the change across the speed
|
||||
// window (windowBytes / windowSeconds). With a single sample we have no span
|
||||
// yet, so we report 0 rather than mistaking the cumulative bytes (e.g. resumed
|
||||
// data) for one window's worth.
|
||||
func (r *Reporter) rates(s download.Stat) (dl, ul int64) {
|
||||
w := r.win[s.ID]
|
||||
if w == nil || len(w.samples) < 2 {
|
||||
return 0, 0
|
||||
}
|
||||
first, last := w.samples[0], w.samples[len(w.samples)-1]
|
||||
secs := last.t.Sub(first.t).Seconds()
|
||||
if secs <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
dl = int64(float64(last.completed-first.completed) / secs)
|
||||
ul = int64(float64(last.uploaded-first.uploaded) / secs)
|
||||
if dl < 0 {
|
||||
dl = 0
|
||||
}
|
||||
if ul < 0 {
|
||||
ul = 0
|
||||
}
|
||||
return dl, ul
|
||||
}
|
||||
|
||||
// ratio is uploaded/completed for the seeding readout; 0 when nothing has been
|
||||
// downloaded yet to avoid dividing by zero.
|
||||
func ratio(uploaded, completed int64) float64 {
|
||||
if completed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(uploaded) / float64(completed)
|
||||
}
|
||||
|
||||
func shortName(s string) string {
|
||||
const max = 20
|
||||
// Truncate on runes, not bytes, so a multibyte name (CJK, emoji) is never
|
||||
// cut mid-rune.
|
||||
if utf8.RuneCountInString(s) > max {
|
||||
return string([]rune(s)[:max-1]) + "~"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// clampRunes limits a line to width runes (not bytes) so multibyte glyphs are
|
||||
// never split when the terminal is narrow. A non-positive width leaves it whole.
|
||||
func clampRunes(s string, width int) string {
|
||||
if width <= 0 || utf8.RuneCountInString(s) <= width {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:width])
|
||||
}
|
||||
|
||||
func termWidth(prev int) int {
|
||||
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
|
||||
return w
|
||||
}
|
||||
if prev > 0 {
|
||||
return prev
|
||||
}
|
||||
return 80
|
||||
}
|
||||
135
progress/progress_test.go
Normal file
135
progress/progress_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user