Add Bubble Tea TUI with interactive treemap
Cell canvas renderer with frame cache and a perceptual 256-colour quantizer; responsive geometry (large/medium/small/too-small); squarified treemap with nested previews, LOD grouping, category colouring and adaptive labels; spatial arrow navigation with stable NodeID selection; zoom and breadcrumbs; modal system (info, help, confirm, save); list, file-type, top-N, scan-info, duplicate and comparison views; live search/filter; mouse support; move-to-trash.
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/scan"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
const hostile = "evil\x1b]0;pwned\x07\x1b[2J.iso"
|
||||
|
||||
func testTree() *inventory.Tree {
|
||||
t := inventory.New("/srv", inventory.KindDir)
|
||||
add := func(p inventory.NodeID, name string, size int64) inventory.NodeID {
|
||||
id := t.Add(p, name, inventory.KindFile)
|
||||
n := t.Node(id)
|
||||
n.Size, n.Alloc = size, size
|
||||
n.Cat = inventory.CategoryFor(inventory.Ext(name), false, false)
|
||||
n.MTime = 1_700_000_000_000_000_000
|
||||
return id
|
||||
}
|
||||
dir := func(p inventory.NodeID, name string) inventory.NodeID {
|
||||
id := t.Add(p, name, inventory.KindDir)
|
||||
t.Node(id).Flags |= inventory.FlagScanned
|
||||
return id
|
||||
}
|
||||
backups := dir(0, "backups")
|
||||
for i := range 40 {
|
||||
add(backups, fmt.Sprintf("dump-%02d.tar.gz", i), int64(1+i)<<30)
|
||||
}
|
||||
vms := dir(0, "vms")
|
||||
add(vms, "ubuntu.qcow2", 86<<30)
|
||||
add(vms, "win.vmdk", 40<<30)
|
||||
media := dir(0, "media")
|
||||
photos := dir(media, "photos 日本語")
|
||||
for i := range 300 {
|
||||
add(photos, fmt.Sprintf("img%03d.jpg", i), int64(i+1)<<20)
|
||||
}
|
||||
add(media, hostile, 9<<30)
|
||||
add(media, "👨👩👧 family.mp4", 12<<30)
|
||||
tiny := dir(0, "tiny")
|
||||
for i := range 2000 {
|
||||
add(tiny, fmt.Sprintf("f%d", i), 100)
|
||||
}
|
||||
add(0, "README", 10)
|
||||
t.Recompute()
|
||||
t.Stats.Complete = true
|
||||
return t
|
||||
}
|
||||
|
||||
func newTestModel(t *testing.T) *Model {
|
||||
t.Helper()
|
||||
tr := testTree()
|
||||
m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }},
|
||||
nil, tr, func() {})
|
||||
m.profile = colorprofile.TrueColor
|
||||
return m
|
||||
}
|
||||
|
||||
func send(m *Model, msgs ...tea.Msg) {
|
||||
for _, msg := range msgs {
|
||||
m.Update(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func key(s string) tea.KeyPressMsg {
|
||||
switch s {
|
||||
case "up":
|
||||
return tea.KeyPressMsg{Code: tea.KeyUp}
|
||||
case "down":
|
||||
return tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
case "left":
|
||||
return tea.KeyPressMsg{Code: tea.KeyLeft}
|
||||
case "right":
|
||||
return tea.KeyPressMsg{Code: tea.KeyRight}
|
||||
case "enter":
|
||||
return tea.KeyPressMsg{Code: tea.KeyEnter}
|
||||
case "esc":
|
||||
return tea.KeyPressMsg{Code: tea.KeyEscape}
|
||||
case "space":
|
||||
return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
|
||||
case "backspace":
|
||||
return tea.KeyPressMsg{Code: tea.KeyBackspace}
|
||||
case "home":
|
||||
return tea.KeyPressMsg{Code: tea.KeyHome}
|
||||
case "tab":
|
||||
return tea.KeyPressMsg{Code: tea.KeyTab}
|
||||
}
|
||||
r := []rune(s)[0]
|
||||
return tea.KeyPressMsg{Code: r, Text: s}
|
||||
}
|
||||
|
||||
func size(w, h int) tea.WindowSizeMsg { return tea.WindowSizeMsg{Width: w, Height: h} }
|
||||
|
||||
// checkFrame verifies the rendered frame exactly fills w×h cells and that no
|
||||
// filename control sequence leaked through.
|
||||
func checkFrame(t *testing.T, m *Model, w, h int) string {
|
||||
t.Helper()
|
||||
content := m.View().Content
|
||||
if strings.Contains(content, "\x1b]") || strings.Contains(content, "\x07") || strings.Contains(content, "\x1b[2J") {
|
||||
t.Fatalf("terminal control sequence from a filename leaked into the frame")
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) != h {
|
||||
t.Fatalf("%dx%d: frame has %d lines", w, h, len(lines))
|
||||
}
|
||||
for i, l := range lines {
|
||||
if got := ansi.StringWidth(l); got != w {
|
||||
t.Fatalf("%dx%d: line %d has width %d: %q", w, h, i, got, ansi.Strip(l))
|
||||
}
|
||||
}
|
||||
return ansi.Strip(content)
|
||||
}
|
||||
|
||||
func TestResizeTransitionsPreserveState(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(120, 40))
|
||||
checkFrame(t, m, 120, 40)
|
||||
// Zoom into media and select something that is not the default.
|
||||
m.tree.RLock()
|
||||
media := m.kidsOf(0)
|
||||
m.tree.RUnlock()
|
||||
var mediaID inventory.NodeID
|
||||
for _, id := range media.ids {
|
||||
if m.tree.Node(id).Name == "media" {
|
||||
mediaID = id
|
||||
}
|
||||
}
|
||||
m.sel, m.userSel = int64(mediaID), true
|
||||
send(m, key("space"))
|
||||
if m.zoom != mediaID {
|
||||
t.Fatalf("zoom = %d want media %d", m.zoom, mediaID)
|
||||
}
|
||||
checkFrame(t, m, 120, 40)
|
||||
send(m, key("right"))
|
||||
sel := m.sel
|
||||
for _, sz := range [][2]int{{80, 24}, {120, 40}, {300, 80}, {70, 20}, {180, 50}, {40, 10}, {39, 9}, {200, 60}} {
|
||||
send(m, size(sz[0], sz[1]))
|
||||
checkFrame(t, m, sz[0], sz[1])
|
||||
if m.zoom != mediaID {
|
||||
t.Fatalf("%v: zoom lost", sz)
|
||||
}
|
||||
if m.sel != sel && !isGroup(m.sel) {
|
||||
t.Fatalf("%v: selection changed from %d to %d", sz, sel, m.sel)
|
||||
}
|
||||
}
|
||||
// The layout must be recomputed for the new geometry, not stretched.
|
||||
if m.tm == nil || m.tm.rect.W > 200 || m.tm.rect.H > 60 {
|
||||
t.Fatalf("layout not recomputed for final size: %+v", m.tm.rect)
|
||||
}
|
||||
for _, b := range m.tm.blocks {
|
||||
r := m.tm.rect
|
||||
if b.X < r.X || b.Y < r.Y || b.X+b.W > r.X+r.W || b.Y+b.H > r.Y+r.H {
|
||||
t.Fatalf("block %+v outside treemap %+v", b, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeStorm(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
r := rand.New(rand.NewSource(1))
|
||||
send(m, size(120, 40))
|
||||
m.View()
|
||||
send(m, key("right"), key("down"))
|
||||
sel := m.sel
|
||||
for i := 0; i < 300; i++ {
|
||||
send(m, size(20+r.Intn(300), 5+r.Intn(90)))
|
||||
if i%7 == 0 {
|
||||
m.View() // renders interleave with resizes like the real event loop
|
||||
}
|
||||
}
|
||||
send(m, size(150, 45))
|
||||
checkFrame(t, m, 150, 45)
|
||||
if m.sel != sel {
|
||||
t.Fatalf("selection lost in resize storm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModalSurvivesResize(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(120, 40), key("enter"))
|
||||
if len(m.modals) != 1 {
|
||||
t.Fatal("Enter should open the info modal")
|
||||
}
|
||||
for _, sz := range [][2]int{{120, 40}, {60, 14}, {41, 10}, {300, 80}, {45, 12}} {
|
||||
send(m, size(sz[0], sz[1]))
|
||||
out := checkFrame(t, m, sz[0], sz[1])
|
||||
if !strings.Contains(out, "ITEM INFORMATION") {
|
||||
t.Fatalf("%v: modal title missing:\n%s", sz, out)
|
||||
}
|
||||
f := m.modals[0].(*modalFrame)
|
||||
if f.viewH < 1 || f.viewH > sz[1] {
|
||||
t.Fatalf("%v: modal viewport %d", sz, f.viewH)
|
||||
}
|
||||
}
|
||||
// Scrolling in a tiny terminal must stay in range.
|
||||
send(m, size(45, 12))
|
||||
for range 100 {
|
||||
send(m, key("down"))
|
||||
}
|
||||
checkFrame(t, m, 45, 12)
|
||||
f := m.modals[0].(*modalFrame)
|
||||
if f.scroll < 0 || f.scroll > f.contentN {
|
||||
t.Fatalf("scroll out of range: %d of %d", f.scroll, f.contentN)
|
||||
}
|
||||
send(m, key("esc"))
|
||||
if len(m.modals) != 0 {
|
||||
t.Fatal("Esc should close the modal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpatialNavigationUsesGeometry(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(160, 45))
|
||||
m.View()
|
||||
blocks := m.tm.blocks
|
||||
for _, dir := range []string{"right", "down", "left", "up"} {
|
||||
before := m.sel
|
||||
send(m, key(dir))
|
||||
m.View()
|
||||
if m.sel == before {
|
||||
continue // edge of the map
|
||||
}
|
||||
var a, b = findBlock(blocks, before), findBlock(blocks, m.sel)
|
||||
switch dir {
|
||||
case "right":
|
||||
if b.X < a.X+a.W {
|
||||
t.Fatalf("right moved to a block that is not to the right: %+v -> %+v", a, b)
|
||||
}
|
||||
case "left":
|
||||
if b.X+b.W > a.X {
|
||||
t.Fatalf("left moved wrongly: %+v -> %+v", a, b)
|
||||
}
|
||||
case "down":
|
||||
if b.Y < a.Y+a.H {
|
||||
t.Fatalf("down moved wrongly: %+v -> %+v", a, b)
|
||||
}
|
||||
case "up":
|
||||
if b.Y+b.H > a.Y {
|
||||
t.Fatalf("up moved wrongly: %+v -> %+v", a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findBlock(bs []treemap.Block, id int64) treemap.Block {
|
||||
for _, b := range bs {
|
||||
if b.ID == id {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return treemap.Block{}
|
||||
}
|
||||
|
||||
func TestZoomBreadcrumbsAndHome(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(100, 30))
|
||||
m.tree.RLock()
|
||||
var media, photos inventory.NodeID
|
||||
m.tree.Walk(0, func(id inventory.NodeID, n *inventory.Node) bool {
|
||||
switch n.Name {
|
||||
case "media":
|
||||
media = id
|
||||
case "photos 日本語":
|
||||
photos = id
|
||||
}
|
||||
return true
|
||||
})
|
||||
m.tree.RUnlock()
|
||||
m.sel, m.userSel = int64(media), true
|
||||
send(m, key("space"))
|
||||
m.sel = int64(photos)
|
||||
send(m, key("space"))
|
||||
if m.zoom != photos {
|
||||
t.Fatal("did not zoom into photos")
|
||||
}
|
||||
out := checkFrame(t, m, 100, 30)
|
||||
if !strings.Contains(out, "media › photos 日本語") {
|
||||
t.Fatalf("breadcrumbs missing:\n%s", out)
|
||||
}
|
||||
// Narrow terminal: breadcrumbs truncate from the left, keeping the tail.
|
||||
send(m, size(42, 12))
|
||||
out = checkFrame(t, m, 42, 12)
|
||||
if !strings.Contains(out, "photos") {
|
||||
t.Fatalf("breadcrumb tail missing:\n%s", out)
|
||||
}
|
||||
send(m, key("backspace"))
|
||||
if m.zoom != media || m.sel != int64(photos) {
|
||||
t.Fatalf("backspace: zoom %d sel %d", m.zoom, m.sel)
|
||||
}
|
||||
send(m, key("home"))
|
||||
if m.zoom != 0 {
|
||||
t.Fatal("home did not return to root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterTransformsMap(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(120, 40), key("/"))
|
||||
for _, r := range "ext = gz" {
|
||||
send(m, tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
if m.search.err != "" || m.search.query == nil {
|
||||
t.Fatalf("query not parsed: %q", m.search.err)
|
||||
}
|
||||
msg := m.applyFilter()()
|
||||
send(m, msg, key("enter"))
|
||||
if m.search.result == nil {
|
||||
t.Fatal("no filter result")
|
||||
}
|
||||
out := checkFrame(t, m, 120, 40)
|
||||
if strings.Contains(out, "vms/") || !strings.Contains(out, "backups/") {
|
||||
t.Fatalf("filter not applied to the map:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "FILTER") {
|
||||
t.Fatal("filter indicator missing")
|
||||
}
|
||||
send(m, key("esc"))
|
||||
if m.search.query != nil || m.search.result != nil {
|
||||
t.Fatal("Esc should clear the filter")
|
||||
}
|
||||
out = checkFrame(t, m, 120, 40)
|
||||
if !strings.Contains(out, "vms/") {
|
||||
t.Fatalf("map not restored after clearing filter:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostileNameSanitisedInModal(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(120, 40))
|
||||
var id inventory.NodeID
|
||||
m.tree.Walk(0, func(i inventory.NodeID, n *inventory.Node) bool {
|
||||
if n.Name == hostile {
|
||||
id = i
|
||||
}
|
||||
return true
|
||||
})
|
||||
m.openModal(newInfoModal(m, id))
|
||||
out := checkFrame(t, m, 120, 40)
|
||||
if !strings.Contains(out, `\x1b]0;pwned\x07`) {
|
||||
t.Fatalf("escaped name not shown:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewsRenderAtManySizes(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
for range m.views {
|
||||
for _, sz := range [][2]int{{40, 10}, {80, 24}, {150, 45}, {91, 22}} {
|
||||
send(m, size(sz[0], sz[1]))
|
||||
checkFrame(t, m, sz[0], sz[1])
|
||||
send(m, key("down"), key("down"))
|
||||
checkFrame(t, m, sz[0], sz[1])
|
||||
}
|
||||
send(m, key("tab"))
|
||||
}
|
||||
send(m, key("?"))
|
||||
send(m, size(60, 15))
|
||||
checkFrame(t, m, 60, 15)
|
||||
}
|
||||
|
||||
func TestLevelOfDetailGroupsTinyFiles(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(100, 30))
|
||||
m.tree.RLock()
|
||||
var tiny inventory.NodeID
|
||||
m.tree.Children(0, func(id inventory.NodeID, n *inventory.Node) {
|
||||
if n.Name == "tiny" {
|
||||
tiny = id
|
||||
}
|
||||
})
|
||||
m.tree.RUnlock()
|
||||
m.zoomTo(tiny)
|
||||
m.View()
|
||||
if len(m.tm.blocks) > 100*30/topMinArea+1 {
|
||||
t.Fatalf("%d blocks for 2000 equal files: LOD not applied", len(m.tm.blocks))
|
||||
}
|
||||
if _, ok := m.tm.groups[groupID(tiny)]; !ok {
|
||||
t.Fatal("expected a group block for small items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlCQuitsWhenIdle(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
send(m, size(80, 24))
|
||||
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
|
||||
if cmd == nil || !m.quit {
|
||||
t.Fatal("Ctrl+C should quit when no scan runs")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRenderFrame(b *testing.B) {
|
||||
tr := testTree()
|
||||
m := newModel(Options{}, nil, tr, func() {})
|
||||
m.profile = colorprofile.TrueColor
|
||||
send(m, size(200, 60))
|
||||
for b.Loop() {
|
||||
m.dirty = true
|
||||
m.View()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkResizeRelayout(b *testing.B) {
|
||||
tr := testTree()
|
||||
m := newModel(Options{}, nil, tr, func() {})
|
||||
i := 0
|
||||
for b.Loop() {
|
||||
send(m, size(100+i%100, 30+i%30))
|
||||
m.View()
|
||||
i++
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user