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) }) }) }