From 86db90ea9a63efeaef03d7f277309ca9a46dc6f7 Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:02:46 +0700 Subject: [PATCH] =?UTF-8?q?feat(stats):=20tab=20Statistik=20(F-005)=20?= =?UTF-8?q?=E2=80=94=20bar=20CPU/RAM,=20riwayat=20sparkline=2060=20dtk,=20?= =?UTF-8?q?laju=20NET/Disk,=20PIDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/e2e_test.go | 39 +++++++- internal/app/app.go | 120 ++++++++++++++++++++--- internal/app/home.go | 8 +- internal/app/logstream.go | 23 +++++ internal/docker/stats.go | 96 ++++++++++++++++++ internal/docker/stats_test.go | 67 +++++++++++++ internal/i18n/locales/en.yaml | 6 ++ internal/i18n/locales/id.yaml | 6 ++ internal/ui/components/sparkline.go | 38 +++++++ internal/ui/components/sparkline_test.go | 24 +++++ 10 files changed, 409 insertions(+), 18 deletions(-) create mode 100644 internal/docker/stats.go create mode 100644 internal/docker/stats_test.go create mode 100644 internal/ui/components/sparkline.go create mode 100644 internal/ui/components/sparkline_test.go diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index bbc40ce..dc550e7 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -312,6 +312,42 @@ func TestResourceSections(t *testing.T) { } } +// TestStatsTab — tab Statistik: bar CPU/RAM, riwayat sparkline, NET/Disk, +// PIDs muncul dari data nyata. Butuh docker + minimal 1 kontainer. +func TestStatsTab(t *testing.T) { + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + t.Skip("docker socket tidak ada") + } + cl, err := docker.New() + if err != nil { + t.Skipf("docker: %v", err) + } + defer cl.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + items, err := cl.ListContainers(ctx) + if err != nil || len(items) == 0 { + t.Skipf("tidak ada kontainer (err=%v)", err) + } + name := items[0].Name + if len(name) > 12 { + name = name[:12] + } + + u := startTUI(t) + u.waitContains("beranda", 5*time.Second) + u.send("1") + u.waitContains(name, 5*time.Second) + u.send("l") // tab Statistik (fokus pindah ke konten) + u.waitContains("Riwayat CPU", 6*time.Second) + u.waitContains("PIDs", 3*time.Second) + u.send("h") // kembali ke Log + u.send("q") + if code := u.waitExit(5 * time.Second); code != 0 { + t.Fatalf("exit code %d, mau 0", code) + } +} + func TestThemeCycle(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) @@ -440,7 +476,8 @@ func TestLogTab(t *testing.T) { u.send("k") u.send("j") u.send("G") - u.send("l") // tab berikutnya → placeholder + u.send("l") // tab berikutnya (Statistik) + u.send("l") // lalu Konfigurasi → placeholder u.waitContains("menyusul", 3*time.Second) u.send("h") // kembali ke tab Log u.send("\x1b[<0;60;10M\x1b[<0;60;10m") // klik area konten = fokus diff --git a/internal/app/app.go b/internal/app/app.go index 6061050..1aff66e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -56,6 +56,9 @@ const ( sectionCount = 4 ) +// statsHistory — jumlah sampel riwayat (60 ≈ 1 menit @1 dtk, PRD F-005). +const statsHistory = 60 + // --- messages (async via tea.Cmd) --- type containersMsg struct { @@ -106,6 +109,9 @@ type Model struct { logOffset int logEOF bool + stats []docker.Stats + statsID int64 + hostname string sys sysinfo.Stats @@ -256,7 +262,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case containersMsg: mm, cmd := m.applyList(msg.err, func(mm *Model) { mm.containers = msg.items }) if mm2, ok := mm.(Model); ok { - return mm2, tea.Batch(cmd, mm2.restartLogs()) + return mm2, tea.Batch(cmd, mm2.restartData()) } return mm, cmd @@ -303,6 +309,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, waitLogBatch(m.logID, m.logCh) + case statsMsg: + if msg.id != m.statsID { + return m, nil // stream basi + } + if msg.err == nil { + m.stats = append(m.stats, msg.st) + if over := len(m.stats) - statsHistory; over > 0 { + m.stats = m.stats[over:] + } + } + return m, statsNext(m.statsID) + + case statsTickMsg: + if msg.id != m.statsID || m.cl == nil || m.sectionLen() == 0 { + return m, nil + } + c := m.containers[min(m.cur(), m.sectionLen()-1)] + return m, fetchStats(m.statsID, m.cl, c.ID) + case tea.BackgroundColorMsg: // Mode tema "auto": Mocha vs Latte sesuai background terminal. theme.SetDetectedDark(msg.IsDark()) @@ -337,19 +362,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "1", "enter": m.jumpSection(sectionContainers) m.sound.Play(audio.Click) - return m, m.restartLogs() + return m, m.restartData() case "2": m.jumpSection(sectionImages) m.sound.Play(audio.Click) - return m, m.restartLogs() + return m, m.restartData() case "3": m.jumpSection(sectionVolumes) m.sound.Play(audio.Click) - return m, m.restartLogs() + return m, m.restartData() case "4": m.jumpSection(sectionNetworks) m.sound.Play(audio.Click) - return m, m.restartLogs() + return m, m.restartData() case "a": // reduced motion m.animating = !m.animating if m.animating { @@ -362,7 +387,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.loading = true m.sound.Play(audio.Click) - return m, tea.Batch(append(loadAll(m.cl), m.restartLogs())...) + return m, tea.Batch(append(loadAll(m.cl), m.restartData())...) case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal cur := theme.Current() next := theme.CycleMode(cur.Mode) @@ -404,7 +429,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.toast.Show(components.LevelInfo, i18n.Tf("toast.timestamps", label), i18n.T("theme.switchHint")) m.sound.Play(audio.Click) - return m, m.restartLogs() + return m, m.restartData() case "j", "down": if m.view == viewList && m.focus == focusContent { if m.logOffset > 0 { @@ -417,7 +442,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setCur(c + 1) } m.sound.Play(audio.Navigate) - return m, m.restartLogs() + return m, m.restartData() case "k", "up": if m.view == viewList && m.focus == focusContent { if m.logOffset < len(m.logs)-1 { @@ -430,7 +455,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setCur(c - 1) } m.sound.Play(audio.Navigate) - return m, m.restartLogs() + return m, m.restartData() } case tea.MouseClickMsg: @@ -449,7 +474,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { idx := msg.Y - (m.headerRows() + 1) + m.scrollStart() if idx >= 0 && idx < m.sectionLen() { m.setCur(idx) - return m, m.restartLogs() + return m, m.restartData() } } } @@ -542,7 +567,25 @@ func (m *Model) cycleTab(delta int) tea.Cmd { m.tab = (m.tab + delta + tabCount) % tabCount m.focus = focusContent m.sound.Play(audio.Click) - return m.restartLogs() + return m.restartData() +} + +// restartStats — bersihkan riwayat & mulai snapshot periodik (one-shot @1 dtk) +// bila konteksnya cocok (tab Statistik). Tidak ada stream persisten. +func (m *Model) restartStats() tea.Cmd { + m.statsID++ + m.stats = nil + if m.view != viewList || m.section != sectionContainers || m.tab != tabStats || + m.cl == nil || m.sectionLen() == 0 { + return nil + } + c := m.containers[min(m.cur(), m.sectionLen()-1)] + return fetchStats(m.statsID, m.cl, c.ID) +} + +// restartData — restart semua sumber data detail (log + stats) untuk seleksi kini. +func (m *Model) restartData() tea.Cmd { + return tea.Batch(m.restartLogs(), m.restartStats()) } // --- data helpers --- @@ -882,9 +925,12 @@ func (m Model) contentView() string { if m.section == sectionContainers { c := m.containers[idx] lines := []string{m.tabBarLine(w - 4)} - if m.tab == tabLogs { + switch m.tab { + case tabLogs: lines = append(lines, m.logLines(h-3)...) - } else { + case tabStats: + lines = append(lines, m.statsLines(h-3, w-4)...) + default: lines = append(lines, "", dim(i18n.T("hint.tabComing"))) } return panel(c.Name, c.State, lines, w, h, focused) @@ -978,6 +1024,54 @@ func (m Model) logLines(rows int) []string { // logLevelRE — token level umum di log (INFO/WARN/ERRO/…). var logLevelRE = regexp.MustCompile(`\b(TRACE|TRAC|DEBUG|DEBU|INFO|WARN|WARNING|ERRO|ERROR|FATAL|FATA)\b`) +// statsLines — isi tab Statistik (PRD §5.1.1): bar CPU/RAM, riwayat CPU +// 60 detik sebagai sparkline, laju NET/Disk, dan PIDs. +func (m Model) statsLines(rows, w int) []string { + if len(m.stats) == 0 { + return []string{dim(i18n.T("hint.statsWaiting"))} + } + cur := m.stats[len(m.stats)-1] + + out := []string{ + fieldW("CPU", 6) + pctBar(cur.CPUPercent, 20) + dim(fmt.Sprintf(" %5.1f%%", cur.CPUPercent)), + fieldW("RAM", 6) + pctBar(cur.MemPercent, 20) + + dim(fmt.Sprintf(" %s / %s", humanKBs(cur.MemUsed/1024), humanKBs(cur.MemLimit/1024))), + "", + dim(i18n.T("stats.history")), + } + + vals := make([]float64, 0, len(m.stats)) + for _, s := range m.stats { + vals = append(vals, s.CPUPercent) + } + out = append(out, theme.Style(theme.Current().Accent).Render(components.Sparkline(vals, min(w, statsHistory))), "") + + if len(m.stats) >= 2 { + prev := m.stats[len(m.stats)-2] + dt := cur.At.Sub(prev.At).Seconds() + if dt <= 0 { + dt = 1 + } + rate := func(curV, prevV uint64) uint64 { + if curV < prevV { // counter reset (kontainer restart) + return 0 + } + return uint64(float64(curV-prevV) / dt) + } + out = append(out, + text("NET ")+dim("↑ "+humanBytes(int64(rate(cur.NetRx, prev.NetRx)))+"/s ↓ "+humanBytes(int64(rate(cur.NetTx, prev.NetTx)))+"/s"), + text("Disk ")+dim(i18n.T("stats.read")+" "+humanBytes(int64(rate(cur.BlkRead, prev.BlkRead)))+"/s · "+ + i18n.T("stats.write")+" "+humanBytes(int64(rate(cur.BlkWrite, prev.BlkWrite)))+"/s"), + ) + } + out = append(out, text("PIDs ")+dim(fmt.Sprintf("%d", cur.PIDs))) + + if len(out) > rows { + out = out[:rows] + } + return out +} + // colorizeLog — warnai token level (PRD §5.4: INFO=info, WARN=warning, ERRO=danger). func colorizeLog(line string) string { th := theme.Current() diff --git a/internal/app/home.go b/internal/app/home.go index d55a286..3798b7e 100644 --- a/internal/app/home.go +++ b/internal/app/home.go @@ -95,7 +95,7 @@ func (m Model) hostPanel(w, h int) string { lines := []string{ fieldW(i18n.T("field.host"), 9) + dim(m.hostname), fieldW(i18n.T("field.cpu"), 9) + dim(i18n.Tf("fmt.coreLoad", cpus, s.Load1)), - fieldW(i18n.T("field.ram"), 9) + dim(i18n.Tf("fmt.ramUsage", pct)) + memBar(pct, 10) + + fieldW(i18n.T("field.ram"), 9) + dim(i18n.Tf("fmt.ramUsage", pct)) + pctBar(float64(pct), 10) + dim(fmt.Sprintf(" %s/%s", humanKBs(s.MemTotal-s.MemAvail), humanKBs(s.MemTotal))), fieldW(i18n.T("field.uptime"), 9) + dim(humanDuration(s.Uptime)), } @@ -184,9 +184,9 @@ func humanDuration(sec float64) string { } } -// memBar — bar RAM; warna ikut beban (hijau <60% · kuning 60–85% · merah >85%). -func memBar(pct, w int) string { - fill := pct * w / 100 +// pctBar — bar persentase; warna ikut beban (hijau <60% · kuning 60–85% · merah >85%). +func pctBar(pct float64, w int) string { + fill := int(pct * float64(w) / 100) if fill > w { fill = w } diff --git a/internal/app/logstream.go b/internal/app/logstream.go index 156c2b0..3781f1b 100644 --- a/internal/app/logstream.go +++ b/internal/app/logstream.go @@ -25,6 +25,29 @@ type logBatchMsg struct { // logTickMsg — tidak ada data dalam window idle; re-arm tanpa state change. type logTickMsg struct{ id int64 } +// statsMsg / statsTickMsg — snapshot metrik kontainer (tab Statistik). +type statsMsg struct { + id int64 + st docker.Stats + err error +} +type statsTickMsg struct{ id int64 } + +const statsInterval = time.Second + +func fetchStats(id int64, cl *docker.Client, cid string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := cl.StatsOnce(ctx, cid) + return statsMsg{id: id, st: st, err: err} + } +} + +func statsNext(id int64) tea.Cmd { + return tea.Tick(statsInterval, func(time.Time) tea.Msg { return statsTickMsg{id: id} }) +} + const ( logBatchMax = 200 // maksimum baris per batch logIdle = 200 * time.Millisecond // tunggu baris pertama diff --git a/internal/docker/stats.go b/internal/docker/stats.go new file mode 100644 index 0000000..cc9e4ef --- /dev/null +++ b/internal/docker/stats.go @@ -0,0 +1,96 @@ +package docker + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/docker/docker/api/types/container" +) + +// Stats — snapshot metrik kontainer (kumulatif untuk net/disk; app menghitung +// laju dari selisih antar snapshot). +type Stats struct { + CPUPercent float64 + MemUsed uint64 // usage − cache (seperti `docker stats`) + MemLimit uint64 + MemPercent float64 + NetRx uint64 // kumulatif + NetTx uint64 // kumulatif + BlkRead uint64 // kumulatif + BlkWrite uint64 // kumulatif + PIDs int + At time.Time +} + +// StatsOnce mengambil satu snapshot statistik (one-shot, tanpa stream). +func (c *Client) StatsOnce(ctx context.Context, id string) (Stats, error) { + resp, err := c.cli.ContainerStatsOneShot(ctx, id) + if err != nil { + return Stats{}, fmt.Errorf("docker: stats: %w", err) + } + defer resp.Body.Close() + + var raw container.StatsResponse + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return Stats{}, fmt.Errorf("docker: decode stats: %w", err) + } + return computeStats(&raw), nil +} + +// computeStats — rumus yang sama dengan `docker stats` (CPU dari delta +// pre/cpu-stats, memori usage − cache), dipisah agar bisa diuji tanpa daemon. +func computeStats(s *container.StatsResponse) Stats { + // CPU% = (Δcpu / Δsystem) × onlineCPU × 100 + cpuDelta := float64(s.CPUStats.CPUUsage.TotalUsage) - float64(s.PreCPUStats.CPUUsage.TotalUsage) + sysDelta := float64(s.CPUStats.SystemUsage) - float64(s.PreCPUStats.SystemUsage) + online := s.CPUStats.OnlineCPUs + if online == 0 { + online = uint32(len(s.CPUStats.CPUUsage.PercpuUsage)) + } + cpuPct := 0.0 + if sysDelta > 0 && cpuDelta > 0 { + cpuPct = cpuDelta / sysDelta * float64(online) * 100 + } + + memUsed := s.MemoryStats.Usage + if cache, ok := s.MemoryStats.Stats["cache"]; ok && cache < memUsed { + memUsed -= cache + } + memPct := 0.0 + if s.MemoryStats.Limit > 0 { + memPct = float64(memUsed) / float64(s.MemoryStats.Limit) * 100 + } + + // Net: jumlahkan semua interface (kumulatif). + var rx, tx uint64 + for _, n := range s.Networks { + rx += n.RxBytes + tx += n.TxBytes + } + + // Blok I/O: jumlahkan per operasi (kumulatif). + var rd, wr uint64 + for _, b := range s.BlkioStats.IoServiceBytesRecursive { + switch b.Op { + case "Read", "read": + rd += b.Value + case "Write", "write": + wr += b.Value + } + } + + return Stats{ + CPUPercent: cpuPct, + MemUsed: memUsed, + MemLimit: s.MemoryStats.Limit, + MemPercent: memPct, + NetRx: rx, + NetTx: tx, + BlkRead: rd, + BlkWrite: wr, + PIDs: int(s.PidsStats.Current), + At: time.Now(), + } +} diff --git a/internal/docker/stats_test.go b/internal/docker/stats_test.go new file mode 100644 index 0000000..b8dc7e9 --- /dev/null +++ b/internal/docker/stats_test.go @@ -0,0 +1,67 @@ +package docker + +import ( + "testing" + + "github.com/docker/docker/api/types/container" +) + +// TestComputeStats — verifikasi rumus tanpa daemon, angka dibuat persis. +func TestComputeStats(t *testing.T) { + raw := &container.StatsResponse{ + CPUStats: container.CPUStats{ + CPUUsage: container.CPUUsage{TotalUsage: 300_000_000}, + SystemUsage: 1_000_000_000, + OnlineCPUs: 4, + }, + PreCPUStats: container.CPUStats{ + CPUUsage: container.CPUUsage{TotalUsage: 100_000_000}, + SystemUsage: 500_000_000, + }, + MemoryStats: container.MemoryStats{ + Usage: 200 * 1024 * 1024, + Limit: 500 * 1024 * 1024, + Stats: map[string]uint64{"cache": 50 * 1024 * 1024}, + }, + Networks: map[string]container.NetworkStats{ + "eth0": {RxBytes: 1000, TxBytes: 2000}, + "eth1": {RxBytes: 500, TxBytes: 300}, + }, + BlkioStats: container.BlkioStats{ + IoServiceBytesRecursive: []container.BlkioStatEntry{ + {Op: "Read", Value: 4096}, + {Op: "Write", Value: 8192}, + {Op: "Sync", Value: 999}, + }, + }, + PidsStats: container.PidsStats{Current: 17}, + } + + s := computeStats(raw) + + // CPU: (300−100)/(1000−500) × 4 × 100 = 160% + if s.CPUPercent < 159.9 || s.CPUPercent > 160.1 { + t.Fatalf("CPU%% = %v, mau ~160", s.CPUPercent) + } + // Mem: (200−50) MB dari limit 500 MB = 30% + if s.MemUsed != 150*1024*1024 || s.MemPercent < 29.9 || s.MemPercent > 30.1 { + t.Fatalf("mem salah: used=%d pct=%v", s.MemUsed, s.MemPercent) + } + if s.NetRx != 1500 || s.NetTx != 2300 { + t.Fatalf("net salah: rx=%d tx=%d", s.NetRx, s.NetTx) + } + if s.BlkRead != 4096 || s.BlkWrite != 8192 { + t.Fatalf("blk salah: r=%d w=%d", s.BlkRead, s.BlkWrite) + } + if s.PIDs != 17 { + t.Fatalf("pids=%d", s.PIDs) + } +} + +// TestComputeStatsZeroGuard — delta nol / data kosong tidak boleh NaN/panic. +func TestComputeStatsZeroGuard(t *testing.T) { + s := computeStats(&container.StatsResponse{}) + if s.CPUPercent != 0 || s.MemPercent != 0 || s.MemUsed != 0 { + t.Fatalf("guard gagal: %+v", s) + } +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 182bdde..e89049b 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -23,6 +23,11 @@ tab: env: "Env" top: "Top" +stats: + history: "CPU history (60s)" + read: "Read" + write: "Write" + host: local: "local" @@ -85,6 +90,7 @@ hint: tabComing: "This tab is coming (Phase 2)." logsWaiting: "waiting for logs…" logsEnd: "log stream ended" + statsWaiting: "fetching stats…" footerHome: "[1/enter] containers · [t] toast · [T] theme · [a] reduced-motion · [q] quit" footerContainers: "[1-4] sections · [j/k] select · [Tab] focus · [h/l] tab · [r] reload · [0] home · [T] theme · [q] quit" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index cd9ac47..93d441c 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -23,6 +23,11 @@ tab: env: "Env" top: "Proses" +stats: + history: "Riwayat CPU (60 dtk)" + read: "Baca" + write: "Tulis" + host: local: "lokal" @@ -85,6 +90,7 @@ hint: tabComing: "Tab ini menyusul (Phase 2)." logsWaiting: "menunggu log…" logsEnd: "stream log berakhir" + statsWaiting: "mengambil statistik…" footerHome: "[1/enter] kontainer · [t] toast · [T] tema · [a] reduced-motion · [q] keluar" footerContainers: "[1-4] bagian · [j/k] pilih · [Tab] fokus · [h/l] tab · [r] muat ulang · [0] beranda · [T] tema · [q] keluar" diff --git a/internal/ui/components/sparkline.go b/internal/ui/components/sparkline.go new file mode 100644 index 0000000..1a89d14 --- /dev/null +++ b/internal/ui/components/sparkline.go @@ -0,0 +1,38 @@ +package components + +import "math" + +// sparkChars — delapan level blok (PRD F-005). +var sparkChars = []rune("▁▂▃▄▅▆▇█") + +// Sparkline — grafik mini dari nilai (normalisasi ke maksimum). +// w = lebar maksimum; nilai terbaru ada di kanan. Kosong → "". +func Sparkline(vals []float64, w int) string { + if len(vals) == 0 || w <= 0 { + return "" + } + if len(vals) > w { + vals = vals[len(vals)-w:] + } + maxV := 0.0 + for _, v := range vals { + if v > maxV { + maxV = v + } + } + if maxV <= 0 { + maxV = 1 + } + out := make([]rune, len(vals)) + for i, v := range vals { + idx := int(math.Round(v / maxV * float64(len(sparkChars)-1))) + if idx < 0 { + idx = 0 + } + if idx >= len(sparkChars) { + idx = len(sparkChars) - 1 + } + out[i] = sparkChars[idx] + } + return string(out) +} diff --git a/internal/ui/components/sparkline_test.go b/internal/ui/components/sparkline_test.go new file mode 100644 index 0000000..b12dc44 --- /dev/null +++ b/internal/ui/components/sparkline_test.go @@ -0,0 +1,24 @@ +package components + +import "testing" + +func TestSparkline(t *testing.T) { + // nol semua → level terendah + if got := Sparkline([]float64{0, 0, 0}, 10); got != "▁▁▁" { + t.Fatalf("nol: %q", got) + } + // maksimum di tengah → blok penuh di sana (3.5 dibulatkan ke atas → ▅) + got := Sparkline([]float64{0, 50, 100, 50, 0}, 10) + want := "▁▅█▅▁" + if got != want { + t.Fatalf("grafik: %q, mau %q", got, want) + } + // pemotongan ke w terakhir (data terbaru di kanan) + got = Sparkline([]float64{100, 0, 0, 0}, 3) + if got != "▁▁▁" { + t.Fatalf("potong: %q", got) + } + if Sparkline(nil, 5) != "" || Sparkline([]float64{1}, 0) != "" { + t.Fatal("kasus kosong harus \"\"") + } +}