405 lines
12 KiB
Go
405 lines
12 KiB
Go
package data
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"image/color"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
|
"github.com/ZacharyZhang-NY/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) {
|
|
core.Use(c, core.Settings{Mode: core.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)
|
|
}
|
|
}
|
|
}
|