Files
MujicaUI/data_table_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

404 lines
11 KiB
Go

package mujica
import (
"cmp"
"fmt"
"image/color"
"strconv"
"testing"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
type relic struct {
ID int
Name string
Age int
}
type tableHarness struct {
rows []relic
st DataTableState[int]
opts DataTableOptions[relic, int]
compares int
changes int
sorts int
c *ui.Context
density theme.Density
seen map[string]int
}
func newTableHarness(rows []relic) *tableHarness {
h := &tableHarness{rows: rows}
h.opts = DataTableOptions[relic, int]{
Key: func(r relic) int { return r.ID },
Columns: []DataColumn[relic]{
{ID: "name", Title: "Name", Sortable: true, Text: func(r relic) string { return r.Name },
Compare: func(a, b relic) int { h.compares++; return cmp.Compare(a.Name, b.Name) }},
{ID: "age", Title: "Age", Width: 80, Align: ui.End, Sortable: true, Text: func(r relic) string { return strconv.Itoa(r.Age) },
Compare: func(a, b relic) int { h.compares++; return cmp.Compare(a.Age, b.Age) }},
},
}
return h
}
func (h *tableHarness) view(c *ui.Context) {
Use(c, Settings{Mode: Light, Density: h.density})
h.c, h.seen = c, map[string]int{}
ui.Column(c).Padding(10).Gap(8).Children(func() {
cols := h.opts
for i := range cols.Columns {
text := cols.Columns[i].Text
cols.Columns[i].Text = func(r relic) string { h.seen[r.Name]++; return text(r) }
}
v := DataTable(c, &h.st, h.rows, cols)
v.Element.Size(360, 300)
if v.Changed() {
h.changes++
}
if v.SortChanged() {
h.sorts++
}
})
}
func relics() []relic {
return []relic{{1, "Censer", 300}, {2, "Altar", 120}, {3, "Bell", 80}, {4, "Diptych", 450}}
}
func (h *tableHarness) order(tt *ui.Tester) []string {
var names []string
for _, s := range tt.Texts() {
for _, r := range h.rows {
// Rows are labeled with their name too: skip the repeat.
if r.Name == s && (len(names) == 0 || names[len(names)-1] != s) {
names = append(names, s)
}
}
}
return names
}
func TestDataTableSortsOnce(t *testing.T) {
h := newTableHarness(relics())
tt := ui.NewTester(h.view, 400, 360)
if got := fmt.Sprint(h.order(tt)); got != "[Censer Altar Bell Diptych]" {
t.Fatalf("initial order %s", got)
}
if err := tt.Click("Name"); err != nil {
t.Fatal(err)
}
if got := fmt.Sprint(h.order(tt)); got != "[Altar Bell Censer Diptych]" || h.st.Sort != (ui.SortOrder{Column: "name"}) || h.sorts != 1 {
t.Fatalf("ascending %s sort %+v sorts %d", got, h.st.Sort, h.sorts)
}
n := h.compares
tt.Frame()
tt.Frame()
if h.compares != n {
t.Fatalf("sorted again without a change: %d compares", h.compares-n)
}
if err := tt.Click("Age"); err != nil {
t.Fatal(err)
}
if err := tt.Click("Age"); err != nil {
t.Fatal(err)
}
if got := fmt.Sprint(h.order(tt)); got != "[Diptych Censer Altar Bell]" || !h.st.Sort.Descending {
t.Fatalf("age descending %s", got)
}
}
func TestDataTableSelectionFollowsRow(t *testing.T) {
h := newTableHarness(relics())
tt := ui.NewTester(h.view, 400, 360)
if err := tt.Click("Bell"); err != nil {
t.Fatal(err)
}
if k, ok := h.st.Selected(); !ok || k != 3 || h.changes != 1 {
t.Fatalf("selected %d %v changes %d", k, ok, h.changes)
}
_ = tt.Click("Name")
_ = tt.Click("Name")
if k, _ := h.st.Selected(); k != 3 || h.rows[h.st.cache.view[h.st.cursor]].Name != "Bell" {
t.Fatalf("after sort selected %d at row %d", k, h.st.cursor)
}
tt.Key(0, ui.KeyDown)
if k, _ := h.st.Selected(); k != 2 { // descending: Diptych Censer Bell Altar
t.Fatalf("Down chose %d", k)
}
h.st.Filter = "ce"
tt.Frame()
if h.st.Rows() != 1 || h.st.cursor != -1 {
t.Fatalf("filter rows %d cursor %d", h.st.Rows(), h.st.cursor)
}
h.st.Filter = ""
tt.Frame()
if k, ok := h.st.Selected(); !ok || k != 2 || h.rows[h.st.cache.view[h.st.cursor]].Name != "Altar" {
t.Fatalf("selection lost through filter: %d %v", k, ok)
}
h.rows = []relic{{1, "Censer", 300}}
tt.Frame()
if _, ok := h.st.Selected(); ok {
t.Fatal("removed row stays selected")
}
}
func TestDataTableMultipleMarks(t *testing.T) {
h := newTableHarness(relics())
h.opts.Multiple = true
tt := ui.NewTester(h.view, 400, 360)
table, _ := tt.Find("Name")
for _, name := range []string{"Altar", "Diptych"} {
r, _ := tt.Find(name)
tt.ClickAt(table.X-28, r.Y+r.H/2) // the mark column sits before Name
}
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(2) || !h.st.Selection.Has(4) || h.changes != 2 {
t.Fatalf("marks chose %d, changes %d", h.st.Selection.Len(), h.changes)
}
if _, ok := tt.Find("Select row"); !ok {
t.Fatal("row check box has no name")
}
_ = tt.Click("Age")
if h.st.Selection.Len() != 2 || !h.st.Selection.Has(4) {
t.Fatal("selection changed by sorting")
}
h.rows = h.rows[:2] // Censer, Altar
tt.Frame()
if h.st.Selection.Len() != 1 || !h.st.Selection.Has(2) {
t.Fatalf("prune kept %d", h.st.Selection.Len())
}
}
// tabTo presses Tab until the element labeled label has the focus.
func tabTo(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
for range 60 {
if tt.Focused(label) {
return
}
tt.Key(0, ui.KeyTab)
}
t.Fatalf("Tab never reached %q", label)
}
func TestDataTableColumnMenuKeyboard(t *testing.T) {
h := newTableHarness(relics())
tt := ui.NewTester(h.view, 420, 480)
tabTo(t, tt, "Columns")
tt.Key(0, ui.KeyEnter)
tabTo(t, tt, "Sort Age descending")
tt.Key(0, ui.KeyEnter)
if h.st.Sort != (ui.SortOrder{Column: "age", Descending: true}) || h.sorts != 1 {
t.Fatalf("sort %+v sorts %d", h.st.Sort, h.sorts)
}
tabTo(t, tt, "Move Age left")
tt.Key(0, ui.KeyEnter)
if fmt.Sprint(h.st.Layout.Order) != "[age name]" {
t.Fatalf("order %v", h.st.Layout.Order)
}
tabTo(t, tt, "Make Age wider")
tt.Key(0, ui.KeyEnter)
if h.st.Layout.Widths["age"] != 96 {
t.Fatalf("widths %v", h.st.Layout.Widths)
}
tabTo(t, tt, "Name")
tt.Key(0, ui.KeySpace)
if !h.st.Hidden["name"] {
t.Fatalf("name not hidden: %v", h.st.Hidden)
}
tabTo(t, tt, "Age")
tt.Key(0, ui.KeySpace)
if h.st.Hidden["age"] {
t.Fatal("hid the last visible column")
}
tt.Key(0, ui.KeyEscape)
if tt.HasText("Censer") || !tt.HasText("Age") {
t.Fatalf("table did not follow the menu: %q", tt.Texts())
}
}
func TestDataTableCustomCellAndControlled(t *testing.T) {
h := newTableHarness(relics())
h.opts.Controlled = true
h.opts.Columns[0].Cell = func(r relic) { ui.Text(h.c, "◆ "+r.Name) }
tt := ui.NewTester(h.view, 400, 360)
if !tt.HasText("◆ Altar") || tt.HasText("Altar") && !tt.HasText("◆ Altar") {
t.Fatalf("custom cell missing: %q", tt.Texts())
}
_ = tt.Click("Name")
first := ""
for _, s := range tt.Texts() {
if len(s) > 4 && s[:4] == "◆ " {
first = s
break
}
}
if h.sorts != 1 || h.compares != 0 || first != "◆ Censer" {
t.Fatalf("controlled sorted locally: sorts %d compares %d first %q", h.sorts, h.compares, first)
}
}
func TestDataTableBadOptions(t *testing.T) {
for name, mutate := range map[string]func(o *DataTableOptions[relic, int]){
"no key": func(o *DataTableOptions[relic, int]) { o.Key = nil },
"no compare": func(o *DataTableOptions[relic, int]) { o.Columns[0].Compare = nil },
"duplicate id": func(o *DataTableOptions[relic, int]) { o.Columns[1].ID = "name" },
} {
h := newTableHarness(relics())
mutate(&h.opts)
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(h.view, 300, 300)
}()
}
h := newTableHarness([]relic{{1, "A", 1}, {1, "B", 2}})
defer func() {
if recover() == nil {
t.Error("duplicate row keys: no panic")
}
}()
ui.NewTester(h.view, 300, 300)
}
func TestDataTableStatuses(t *testing.T) {
h := newTableHarness(nil)
tt := ui.NewTester(h.view, 400, 360)
if !tt.HasText("No items") {
t.Fatalf("empty: %q", tt.Texts())
}
h.rows = relics()
h.st.Filter = "zzz"
tt.Frame()
if !tt.HasText("No matching rows") {
t.Fatalf("no match: %q", tt.Texts())
}
h.st.Filter = ""
h.opts.Status = DataFailed
tt.Frame()
if !tt.HasText("Could not load") || !tt.HasText("Retry") || !tt.HasText("Bell") {
t.Fatalf("failed append: %q", tt.Texts())
}
}
func TestDataTableLargeIsVirtualized(t *testing.T) {
rows := make([]relic, 100_000)
for i := range rows {
rows[i] = relic{ID: i, Name: fmt.Sprintf("Relic %06d", (i*7919)%100_000), Age: i % 997}
}
h := newTableHarness(rows)
for i := 3; i <= 10; i++ {
id := fmt.Sprintf("c%d", i)
h.opts.Columns = append(h.opts.Columns, DataColumn[relic]{ID: id, Title: id, Width: 60, Text: func(r relic) string { return id }})
}
tt := ui.NewTester(h.view, 400, 360)
// Rows whose cells ran in the last frame: 300 DIPs show about 7 rows of 36 DIPs, plus overscan.
const bound = 16
if len(h.seen) == 0 || len(h.seen) > bound {
t.Fatalf("built %d rows, bound %d", len(h.seen), bound)
}
if err := tt.Click("Name"); err != nil {
t.Fatal(err)
}
if len(h.seen) > bound || !tt.HasText("Relic 000000") || !tt.HasText("Relic 000001") {
t.Fatalf("after sort built %d rows, texts %q", len(h.seen), tt.Texts())
}
if err := tt.Click("Relic 000001"); err != nil {
t.Fatal(err)
}
n := h.compares
_ = tt.Click("Name") // descending: the row moves 99,998 places
tt.Frame()
if k, _ := h.st.Selected(); k != rowWithName(rows, "Relic 000001") {
t.Fatalf("selection lost: %d", k)
}
m := h.compares
if m == n {
t.Fatal("descending did not sort")
}
tt.Frame()
tt.Frame()
if h.compares != m {
t.Fatal("re-sorted each frame")
}
}
func rowWithName(rows []relic, name string) int {
for _, r := range rows {
if r.Name == name {
return r.ID
}
}
panic(name)
}
func TestDataTableSnapshot(t *testing.T) {
snapshot(t, "data_table", 640, 300, func(c *ui.Context) {
h := ui.Local(c.Root(), "h", func() *tableHarness {
h := newTableHarness(relics())
h.opts.Multiple = true
h.st.Sort = ui.SortOrder{Column: "name"}
h.st.Selection.Add(3)
return h
})
ui.Column(c).Padding(16).Children(func() {
DataTable(c, &(*h).st, (*h).rows, (*h).opts).Element.Size(600, 260)
})
})
}
func TestDataTableSelectionColors(t *testing.T) {
h := newTableHarness(relics())
tt := ui.NewTester(h.view, 400, 360)
if err := tt.Click("Bell"); err != nil {
t.Fatal(err)
}
r, _ := tt.Find("Bell")
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)
}
}
func TestDataTableCompactLargeText(t *testing.T) {
h := newTableHarness(relics())
h.opts.Multiple = true
h.density = theme.Compact
tt := ui.NewTester(h.view, 600, 700)
tt.SetPreferences(ui.Preferences{TextScale: 2})
head, _ := tt.Find("Name")
row, _ := tt.Find("Censer") // the first row, labeled by its first column
if head.Y+head.H > row.Y-1 {
t.Fatalf("header text %v runs past the header into row %v", head, row)
}
box, ok := tt.Find("Select row")
if !ok || box.W < theme.MinHitSize || box.H < theme.MinHitSize || box.X < row.X {
t.Fatalf("check box hit area %v in row %v", box, row)
}
if name, _ := tt.Find("Name"); box.X+box.W > name.X {
t.Fatalf("check box %v overlaps the Name column at %v", box, name.X)
}
}
func TestDataTableCheckBoxHitWidth(t *testing.T) {
for _, d := range []theme.Density{theme.Compact, theme.Default} {
h := newTableHarness(relics())
h.opts.Multiple = true
h.density = d
tt := ui.NewTester(h.view, 400, 360)
box, _ := tt.Find("Select row")
if box.W < theme.MinHitSize || box.H < theme.MinHitSize {
t.Fatalf("%v: check box hit area %v", d, box)
}
tt.ClickAt(box.X+1, box.Y+box.H/2) // its left edge still toggles
if h.st.Selection.Len() != 1 {
t.Fatalf("%v: left edge of the hit area missed", d)
}
}
}