Files
MujicaUI/tree_test.go
T
ZacharyZhang-NY 8880865021 Refactor: results expose Element as a named field
Every component result type now names its element Element instead of
embedding *ui.Element. The eleven element-plus-changed results
(AccordionView, CarouselView, CollapsibleView, ImageViewerView,
PaginationResult, StepsResult, SidebarResult, TabsResult, RatingParts,
FileDropZoneParts, TransferParts) fold into ChoiceResult.

Fixes four Gallery demos where .Width(...).Changed() read the element's
flag instead of the component's: Accordion, MultiSelect, Cascader,
TreeSelect. TestDataAccordionDemoLogsChange covers the Accordion.
2026-10-05 13:35:53 -04:00

273 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mujica
import (
"fmt"
"image/color"
"slices"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
type treeHarness struct {
kids map[string][]string
status map[string]DataStatus
loads []string
st TreeState[string]
opts TreeOptions[string]
built map[string]bool
changes int
}
func newTreeHarness() *treeHarness {
h := &treeHarness{
kids: map[string][]string{
"Nave": {"Pulpit", "Pews"},
"Crypt": {}, // loaded lazily
"Pulpit": nil,
"Pews": nil,
"Sacristy": nil,
},
status: map[string]DataStatus{"Crypt": DataUnloaded},
}
h.opts = TreeOptions[string]{
Roots: []string{"Nave", "Crypt", "Sacristy"},
Children: func(n string) []string { return h.kids[n] },
Label: func(n string) string { return n },
ItemStatus: func(n string) DataStatus { return h.status[n] },
Load: func(n string) {
h.loads = append(h.loads, n)
h.status[n] = DataLoading
},
}
return h
}
func (h *treeHarness) view(c *ui.Context) {
Use(c, Settings{Mode: Light})
h.built = map[string]bool{}
opts := h.opts
label := opts.Label
opts.Label = func(n string) string { h.built[n] = true; return label(n) }
ui.Column(c).Padding(10).Children(func() {
v := Tree(c, &h.st, opts)
v.Element.Size(320, 360)
if v.Changed() {
h.changes++
}
})
}
func TestTreeExpandAndKeyboard(t *testing.T) {
h := newTreeHarness()
tt := ui.NewTester(h.view, 400, 400)
if tt.HasText("Pulpit") || h.built["Pulpit"] {
t.Fatal("closed subtree built")
}
if err := tt.Click("Nave"); err != nil {
t.Fatal(err)
}
if k, _ := h.st.Selected(); k != "Nave" || h.changes != 1 {
t.Fatalf("selected %q changes %d", k, h.changes)
}
tt.Key(0, ui.KeyRight)
if !h.st.IsOpen("Nave") || !tt.HasText("Pulpit") {
t.Fatalf("Right did not open: %q", tt.Texts())
}
tt.Key(0, ui.KeyRight)
if k, _ := h.st.Selected(); k != "Pulpit" {
t.Fatalf("Right to first child chose %q", k)
}
tt.Key(0, ui.KeyDown)
if k, _ := h.st.Selected(); k != "Pews" {
t.Fatalf("Down chose %q", k)
}
tt.Key(0, ui.KeyLeft)
if k, _ := h.st.Selected(); k != "Nave" {
t.Fatalf("Left to parent chose %q", k)
}
tt.Key(0, ui.KeyLeft)
if h.st.IsOpen("Nave") || tt.HasText("Pews") {
t.Fatal("Left did not close")
}
}
func TestTreeLazyLoad(t *testing.T) {
h := newTreeHarness()
tt := ui.NewTester(h.view, 400, 400)
if len(h.loads) != 0 {
t.Fatalf("loaded before opening: %v", h.loads)
}
h.st.SetOpen("Crypt", true)
tt.Frame()
if fmt.Sprint(h.loads) != "[Crypt]" || !tt.HasText("Loading") {
t.Fatalf("loads %v texts %q", h.loads, tt.Texts())
}
tt.Frame()
if len(h.loads) != 1 {
t.Fatalf("asked twice: %v", h.loads)
}
h.status["Crypt"] = DataFailed
tt.Frame()
if err := tt.Click("Retry"); err != nil {
t.Fatal(err)
}
if len(h.loads) != 2 {
t.Fatalf("retry did not load: %v", h.loads)
}
h.kids["Crypt"] = []string{"Ossuary"}
h.status["Crypt"] = DataReady
tt.Frame()
if !tt.HasText("Ossuary") || tt.HasText("Loading") {
t.Fatalf("children missing: %q", tt.Texts())
}
}
func TestTreeMultipleAndEmpty(t *testing.T) {
h := newTreeHarness()
h.opts.Multiple = true
tt := ui.NewTester(h.view, 400, 400)
_ = tt.Click("Nave")
_ = tt.ClickWith(ui.Cmd, "Sacristy")
if h.st.Selection.Len() != 2 || !h.st.Selection.Has("Sacristy") {
t.Fatalf("chose %d", h.st.Selection.Len())
}
h.opts.Roots = nil
tt.Frame()
if !tt.HasText("No items") {
t.Fatalf("empty: %q", tt.Texts())
}
}
func TestTreeBadOptions(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic without Children")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
var s TreeState[int]
Tree(c, &s, TreeOptions[int]{Roots: []int{1}})
}, 100, 100)
}
// bigTree has 100 roots of 999 children each: 100,000 nodes.
func bigTree() (roots []int, kids func(int) []int) {
for r := range 100 {
roots = append(roots, r*1000)
}
cache := map[int][]int{}
return roots, func(n int) []int {
if n%1000 != 0 {
return nil
}
if k, ok := cache[n]; ok {
return k
}
var k []int
for i := 1; i < 1000; i++ {
k = append(k, n+i)
}
cache[n] = k
return k
}
}
func TestTreeLargeIsVirtualized(t *testing.T) {
roots, kids := bigTree()
var st TreeState[int]
built := 0
view := func(c *ui.Context) {
Use(c, Settings{})
built = 0
Tree(c, &st, TreeOptions[int]{Roots: roots, Children: kids, Label: func(n int) string {
built++
return fmt.Sprintf("Node %d", n)
}}).Element.Size(320, 360)
}
tt := ui.NewTester(view, 400, 400)
// Two Label calls per row; a jump builds 2×(10 rows + 2 overscan) + 2 edges.
const bound = 2 * 26
if built == 0 || built > bound {
t.Fatalf("collapsed: label calls %d, bound %d", built, bound)
}
for _, r := range roots {
st.SetOpen(r, true)
}
tt.Frame()
if built > bound || st.tree.outline.Rows() != 100_000 {
t.Fatalf("open: label calls %d rows %d", built, st.tree.outline.Rows())
}
st.tree.outline.List.ScrollTo(64_000, ui.Start)
tt.Frame()
if built > bound || !tt.HasText(fmt.Sprintf("Node %d", st.tree.outline.Item(64_000))) {
t.Fatalf("scrolled: label calls %d texts %q", built, tt.Texts())
}
}
func TestTreeSnapshot(t *testing.T) {
snapshot(t, "tree", 400, 260, func(c *ui.Context) {
h := ui.Local(c.Root(), "h", func() *treeHarness {
h := newTreeHarness()
h.st.SetOpen("Nave", true)
h.st.SetOpen("Crypt", true)
h.status["Crypt"] = DataLoading
h.st.tree.started, h.st.tree.cursor, h.st.tree.asked = true, 1, map[string]bool{}
h.st.tree.sel, h.st.tree.has = "Pulpit", true
return h
})
ui.Column(c).Padding(16).Children(func() {
Tree(c, &(*h).st, (*h).opts).Element.Size(360, 228)
})
})
}
func TestTreeSelectionFollowsLargeReorder(t *testing.T) {
roots := make([]int, 3000)
for i := range roots {
roots[i] = i
}
var st TreeState[int]
view := func(c *ui.Context) {
Use(c, Settings{Mode: Light})
Tree(c, &st, TreeOptions[int]{Roots: roots, Children: func(int) []int { return nil },
Label: func(n int) string { return fmt.Sprintf("Node %d", n) }}).Element.Size(320, 360)
}
tt := ui.NewTester(view, 400, 400)
if err := tt.Click("Node 2"); err != nil {
t.Fatal(err)
}
r, _ := tt.Find("Node 2")
got := color.RGBAModel.Convert(tt.Image().At(int(r.X+r.W-6), int(r.Y+r.H/2))).(color.RGBA)
if want := toRGBA(theme.Light().Selection); got != want {
t.Fatalf("selected row background %v, want Selection %v", got, want)
}
roots = slices.Clone(roots)
slices.Reverse(roots)
tt.Frame()
if k, ok := st.Selected(); !ok || k != 2 || st.tree.cursor != 2997 {
t.Fatalf("after reversing: selected %d %v at row %d", k, ok, st.tree.cursor)
}
}
func TestTreeEmptyClearsSelection(t *testing.T) {
h := newTreeHarness()
tt := ui.NewTester(h.view, 400, 400)
if err := tt.Click("Sacristy"); err != nil {
t.Fatal(err)
}
h.opts.Roots = nil
tt.Frame()
if k, ok := h.st.Selected(); ok {
t.Fatalf("empty tree still selects %q", k)
}
h.opts.Roots = []string{"Nave", "Crypt", "Sacristy"}
tt.Frame()
if k, ok := h.st.Selected(); ok {
t.Fatalf("selection came back as %q", k)
}
}