Files
MujicaUI/data_table_test.go
T

338 lines
9.2 KiB
Go

package mujica
import (
"cmp"
"fmt"
"strconv"
"testing"
"github.com/egoist/mygo/ui"
)
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
picker bool
c *ui.Context
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})
h.c, h.seen = c, map[string]int{}
ui.Column(c).Padding(10).Gap(8).Children(func() {
if h.picker {
DataTableColumnPicker(c, &h.st, h.opts.Columns)
}
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.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) {
t.Fatalf("marks chose %d", h.st.Selection.Len())
}
_ = 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())
}
}
func TestDataTableColumns(t *testing.T) {
h := newTableHarness(relics())
h.picker = true
tt := ui.NewTester(h.view, 400, 400)
h.st.Layout.Order = []string{"age", "name"}
tt.Frame()
age, _ := tt.Find("Age")
name, _ := tt.Find("Name")
if age.X >= name.X {
t.Fatalf("order not applied: age %v name %v", age.X, name.X)
}
if err := tt.Click("Columns"); err != nil {
t.Fatal(err)
}
ages := 0
for _, s := range tt.Texts() {
if s == "Age" {
ages++
}
}
if ages != 2 { // header and picker
t.Fatalf("picker missing: %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if !h.st.Hidden["name"] || tt.HasText("Censer") {
t.Fatalf("name not hidden: %v %q", h.st.Hidden, tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if h.st.Hidden["age"] {
t.Fatal("hid the last visible column")
}
}
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).Size(600, 260)
})
})
}