// Tests for autotable.go. package webui import ( "bytes" "encoding/csv" "errors" "math" "reflect" "strconv" "strings" "testing" "time" ) // ========================================================================== // Data pipeline: filter, sort, paginate // ========================================================================== type person struct { Name string Email string Age int Salary string Status string Hired time.Time Rank string `json:"rank_label"` } func people() []any { return []any{ person{Name: "Ada", Email: "ada@x.com", Age: 36, Salary: "$1,200.50", Status: "active", Hired: date("2020-03-01"), Rank: "Item 10"}, person{Name: "Grace", Email: "grace@y.com", Age: 45, Salary: "$980.00", Status: "inactive", Hired: date("2018-07-15"), Rank: "Item 2"}, person{Name: "alan", Email: "alan@x.com", Age: 41, Salary: "$1,500.00", Status: "active", Hired: date("2021-01-20"), Rank: "Item 1"}, person{Name: "", Email: "ghost@z.com", Age: 0, Salary: "", Status: "", Hired: time.Time{}, Rank: ""}, } } func date(s string) time.Time { t, _ := time.Parse("2006-01-02", s) return t } func names(rows []any) []string { out := make([]string, len(rows)) for i, r := range rows { out[i] = r.(person).Name } return out } // The default (single value, not exact) is a case-insensitive SUBSTRING match. func TestSearchContains(t *testing.T) { got := ApplySearchFilters(people(), []AutoTableSearchEntry{ {Identifier: "Name", Values: []string{"a"}}, }, nil) if want := []string{"Ada", "Grace", "alan"}; !reflect.DeepEqual(names(got), want) { t.Errorf("contains: got %v, want %v", names(got), want) } // Case-insensitive both ways. got = ApplySearchFilters(people(), []AutoTableSearchEntry{ {Identifier: "Name", Values: []string{"ALAN"}}, }, nil) if want := []string{"alan"}; !reflect.DeepEqual(names(got), want) { t.Errorf("case-insensitive: got %v, want %v", names(got), want) } } // Exact means equality, not substring. func TestSearchExact(t *testing.T) { got := ApplySearchFilters(people(), []AutoTableSearchEntry{ {Identifier: "Status", Values: []string{"active"}, Exact: true}, }, nil) if want := []string{"Ada", "alan"}; !reflect.DeepEqual(names(got), want) { t.Errorf("exact: got %v, want %v", names(got), want) } // Without Exact, "active" would also match "inactive" — the distinction that // makes Exact worth having. got = ApplySearchFilters(people(), []AutoTableSearchEntry{ {Identifier: "Status", Values: []string{"active"}}, }, nil) if len(got) != 3 { t.Errorf("substring search matched %d rows, want 3 (active + inactive)", len(got)) } } // Several values in one entry = an IN-set test (what a multi-select produces), // and it is exact, not substring. func TestSearchInSet(t *testing.T) { got := ApplySearchFilters(people(), []AutoTableSearchEntry{ {Identifier: "Status", Values: []string{"active", "inactive"}}, }, nil) if len(got) != 3 { t.Errorf("IN-set matched %d rows, want 3", len(got)) } if got := ApplySearchFilters(people(), []AutoTableSearchEntry{ {Identifier: "Name", Values: []string{"Ada", "Grace"}}, }, nil); len(got) != 2 { t.Errorf("IN-set on Name matched %d, want 2", len(got)) } } // A multi-search identifier ORs across fields — the global search box. func TestMultiSearchOrsAcrossFields(t *testing.T) { id := MultiSearchIdentifier("Name", "Email") got := ApplySearchFilters(people(), []AutoTableSearchEntry{ {Identifier: id, Values: []string{"y.com"}}, }, nil) if want := []string{"Grace"}; !reflect.DeepEqual(names(got), want) { t.Errorf("multi-search on email: got %v, want %v", names(got), want) } // Matches on EITHER field. got = ApplySearchFilters(people(), []AutoTableSearchEntry{ {Identifier: id, Values: []string{"ada"}}, }, nil) if want := []string{"Ada"}; !reflect.DeepEqual(names(got), want) { t.Errorf("multi-search on name: got %v, want %v", names(got), want) } } // Entries AND together. func TestSearchEntriesAnd(t *testing.T) { got := ApplySearchFilters(people(), []AutoTableSearchEntry{ {Identifier: "Status", Values: []string{"active"}, Exact: true}, {Identifier: "Name", Values: []string{"ad"}}, }, nil) if want := []string{"Ada"}; !reflect.DeepEqual(names(got), want) { t.Errorf("got %v, want %v", names(got), want) } } // An empty search box must not filter everything away. func TestEmptySearchIsInert(t *testing.T) { for _, s := range [][]AutoTableSearchEntry{ nil, {{Identifier: "Name", Values: nil}}, {{Identifier: "Name", Values: []string{""}}}, {{Identifier: "Name", Values: []string{" "}}}, {{Identifier: "", Values: []string{"x"}}}, } { if got := ApplySearchFilters(people(), s, nil); len(got) != 4 { t.Errorf("search %+v filtered to %d rows, want all 4", s, len(got)) } } } func TestFieldReaderStructTagsAndCase(t *testing.T) { row := people()[0] if got := DefaultFieldReader(row, "Name"); got != "Ada" { t.Errorf("by field name: %v", got) } if got := DefaultFieldReader(row, "rank_label"); got != "Item 10" { t.Errorf("by json tag: %v", got) } if got := DefaultFieldReader(row, "name"); got != "Ada" { t.Errorf("case-insensitive: %v", got) } if got := DefaultFieldReader(row, "nope"); got != nil { t.Errorf("missing field: %v, want nil", got) } if got := DefaultFieldReader(map[string]any{"k": 1}, "k"); got != 1 { t.Errorf("map: %v", got) } } func TestSortStringsAndDirection(t *testing.T) { cols := []AutoTableColumn{{SortIdentifier: "Name", Sortable: true}} asc := SortRows(people(), AutoTableOrderBy{Identifier: "Name"}, cols, nil) // Case-insensitive: "Ada" < "alan" < "Grace". The blank sorts last. if want := []string{"Ada", "alan", "Grace", ""}; !reflect.DeepEqual(names(asc), want) { t.Errorf("asc: got %v, want %v", names(asc), want) } desc := SortRows(people(), AutoTableOrderBy{Identifier: "Name", Descending: true}, cols, nil) // Reversed — but the blank STILL sorts last, not first. if want := []string{"Grace", "alan", "Ada", ""}; !reflect.DeepEqual(names(desc), want) { t.Errorf("desc: got %v, want %v", names(desc), want) } } // The empties-last rule is the one people get wrong, so pin it explicitly. func TestEmptiesAlwaysSortLast(t *testing.T) { cols := []AutoTableColumn{{SortIdentifier: "Salary", SortType: SortTypeMoney}} for _, desc := range []bool{false, true} { got := SortRows(people(), AutoTableOrderBy{Identifier: "Salary", Descending: desc}, cols, nil) if last := got[len(got)-1].(person).Name; last != "" { t.Errorf("descending=%v: last row is %q, want the empty one", desc, last) } } } func TestSortNumeric(t *testing.T) { cols := []AutoTableColumn{{SortIdentifier: "Rank", SortType: SortTypeNumeric}} got := SortRows(people(), AutoTableOrderBy{Identifier: "Rank"}, cols, nil) // Item 1 < Item 2 < Item 10. A plain string sort gives 1, 10, 2 — and so did the // TSX, whose parseInt returns NaN on anything not starting with a digit. want := []string{"alan", "Grace", "Ada", ""} if !reflect.DeepEqual(names(got), want) { t.Errorf("numeric sort: got %v, want %v", names(got), want) } } // Natural ordering, pinned directly — this is where we deliberately diverge from // the TSX, so it is worth being explicit about what it does. func TestCompareNatural(t *testing.T) { cases := []struct { a, b string want int }{ {"2", "10", -1}, // plain numbers: agrees with the TSX's parseInt {"Item 2", "Item 10", -1}, // embedded numbers: the TSX got this backwards {"A9", "A10", -1}, {"a", "b", -1}, {"file10", "file9", 1}, {"x", "x", 0}, {"item", "item2", -1}, // a prefix sorts first // "1.5" splits into the runs 1 | . | 5, so it compares like a VERSION, not a // decimal: 1.5 < 1.10. Decimal strings want SortTypeMoney (or a real numeric // field) — this is inherent to natural ordering, not a bug. {"1.5", "1.10", -1}, } for _, c := range cases { if got := sign(compareNatural(c.a, c.b)); got != c.want { t.Errorf("compareNatural(%q, %q) = %d, want %d", c.a, c.b, got, c.want) } if got := sign(compareNatural(c.b, c.a)); got != -c.want { t.Errorf("compareNatural(%q, %q) = %d, want %d (not antisymmetric)", c.b, c.a, got, -c.want) } } } func sign(n int) int { switch { case n < 0: return -1 case n > 0: return 1 } return 0 } func TestSortMoney(t *testing.T) { cols := []AutoTableColumn{{SortIdentifier: "Salary", SortType: SortTypeMoney}} got := SortRows(people(), AutoTableOrderBy{Identifier: "Salary"}, cols, nil) // $980 < $1,200.50 < $1,500 — a string sort would put "$1,200.50" first. if want := []string{"Grace", "Ada", "alan", ""}; !reflect.DeepEqual(names(got), want) { t.Errorf("money sort: got %v, want %v", names(got), want) } } func TestSortNativeTypes(t *testing.T) { cols := []AutoTableColumn{{SortIdentifier: "Age"}, {SortIdentifier: "Hired"}} got := SortRows(people(), AutoTableOrderBy{Identifier: "Age"}, cols, nil) if want := []string{"", "Ada", "alan", "Grace"}; !reflect.DeepEqual(names(got), want) { // Age 0 is not "empty" for an int — only nil/""/zero-time are. So it sorts // numerically first, which is right: 0 is a real age reading. t.Errorf("int sort: got %v, want %v", names(got), want) } got = SortRows(people(), AutoTableOrderBy{Identifier: "Hired"}, cols, nil) if want := []string{"Grace", "Ada", "alan", ""}; !reflect.DeepEqual(names(got), want) { t.Errorf("time sort: got %v, want %v (zero time sorts last)", names(got), want) } } func TestSortValueOverride(t *testing.T) { // Sort by a key that is not a field at all: a status rank. rank := map[string]int{"active": 0, "inactive": 1, "": 2} cols := []AutoTableColumn{{ SortIdentifier: "status_rank", SortValue: func(row any) any { return rank[row.(person).Status] }, }} got := SortRows(people(), AutoTableOrderBy{Identifier: "status_rank"}, cols, nil) if first := got[0].(person).Status; first != "active" { t.Errorf("SortValue override ignored: first row status = %q", first) } } func TestPositionalIdentifier(t *testing.T) { cols := []AutoTableColumn{{SortIdentifier: PositionalIdentifier(0)}} got := SortRows(people(), AutoTableOrderBy{Identifier: PositionalIdentifier(0)}, cols, nil) // Field 0 is Name. if want := []string{"Ada", "alan", "Grace", ""}; !reflect.DeepEqual(names(got), want) { t.Errorf("positional sort: got %v, want %v", names(got), want) } } func TestSortIsStable(t *testing.T) { rows := []any{ person{Name: "b", Status: "x"}, person{Name: "a", Status: "1"}, person{Name: "a", Status: "2"}, } got := SortRows(rows, AutoTableOrderBy{Identifier: "Name"}, nil, nil) if got[0].(person).Status != "1" || got[1].(person).Status != "2" { t.Error("equal rows lost their original relative order") } } func TestPaginate(t *testing.T) { rows := make([]any, 12) for i := range rows { rows[i] = person{Name: string(rune('a' + i))} } page, p := Paginate(rows, AutoTablePagination{CurrentPage: 2, MaxItemsPerPage: 5}) if len(page) != 5 || page[0].(person).Name != "f" { t.Errorf("page 2 = %v", names(page)) } if p.TotalPages != 3 || p.TotalItems != 12 { t.Errorf("totals: pages=%d items=%d, want 3/12", p.TotalPages, p.TotalItems) } if p.ViewRangeLower != 6 || p.ViewRangeUpper != 10 { t.Errorf("view range = %d-%d, want 6-10", p.ViewRangeLower, p.ViewRangeUpper) } // The last page is short. page, p = Paginate(rows, AutoTablePagination{CurrentPage: 3, MaxItemsPerPage: 5}) if len(page) != 2 || p.ViewRangeUpper != 12 { t.Errorf("last page: %d rows, upper=%d", len(page), p.ViewRangeUpper) } // "All". page, p = Paginate(rows, AutoTablePagination{CurrentPage: 1, MaxItemsPerPage: PageSizeAll}) if len(page) != 12 || p.TotalPages != 1 { t.Errorf("PageSizeAll: %d rows over %d pages, want 12/1", len(page), p.TotalPages) } } // A filter that shrinks the result set can strand CurrentPage past the end. // Showing an empty table there would look like "no results" — clamp instead. func TestPaginateClampsAnOutOfRangePage(t *testing.T) { rows := []any{person{Name: "a"}, person{Name: "b"}} page, p := Paginate(rows, AutoTablePagination{CurrentPage: 9, MaxItemsPerPage: 5}) if p.CurrentPage != 1 || len(page) != 2 { t.Errorf("page %d with %d rows, want page 1 with 2 rows", p.CurrentPage, len(page)) } } func TestPaginateEmpty(t *testing.T) { page, p := Paginate(nil, AutoTablePagination{CurrentPage: 1, MaxItemsPerPage: 10}) if len(page) != 0 || p.TotalPages != 1 || p.ViewRangeLower != 0 || p.ViewRangeUpper != 0 { t.Errorf("empty: %d rows, pages=%d, range=%d-%d", len(page), p.TotalPages, p.ViewRangeLower, p.ViewRangeUpper) } } // The whole pipeline, and the reason allFiltered is returned separately: export // must see everything that matched, not just the page on screen. func TestProcessLocally(t *testing.T) { cols := []AutoTableColumn{{SortIdentifier: "Name", Sortable: true}} filter := AutoTableFilter{ Search: []AutoTableSearchEntry{{Identifier: "Status", Values: []string{"active"}, Exact: true}}, OrderBy: AutoTableOrderBy{Identifier: "Name", Descending: true}, Pagination: AutoTablePagination{CurrentPage: 1, MaxItemsPerPage: 1}, } page, all, p := ProcessLocally(people(), filter, cols, nil) if len(all) != 2 { t.Fatalf("allFiltered = %d rows, want 2 (both active)", len(all)) } if len(page) != 1 || page[0].(person).Name != "alan" { t.Errorf("page = %v, want [alan] (descending)", names(page)) } if p.TotalPages != 2 || p.TotalItems != 2 { t.Errorf("pagination = %d pages / %d items, want 2/2", p.TotalPages, p.TotalItems) } } func TestBuildQueryString(t *testing.T) { filter := AutoTableFilter{ Search: []AutoTableSearchEntry{ {Identifier: "status", Values: []string{"active", "pending"}}, {Identifier: "name", Values: []string{"ada"}, Exact: true}, {Identifier: "blank", Values: []string{" "}}, }, OrderBy: AutoTableOrderBy{Identifier: "name", Descending: true}, Pagination: AutoTablePagination{CurrentPage: 3, MaxItemsPerPage: 25}, } got := BuildQueryString(filter, false) for _, want := range []string{ "status=active", "status=pending", "name=ada", "name_exact=true", "order_by=name", "order_desc=true", "page_num=3", "items_per_page=25", } { if !contains(got, want) { t.Errorf("query %q missing %q", got, want) } } if contains(got, "blank") { t.Errorf("query %q included a blank search value", got) } // Export: the whole filtered set, not the current page. got = BuildQueryString(filter, true) if !contains(got, "items_per_page=-1") || contains(got, "page_num") { t.Errorf("export query = %q, want no paging", got) } } func contains(haystack, needle string) bool { return len(haystack) >= len(needle) && (func() bool { for i := 0; i+len(needle) <= len(haystack); i++ { if haystack[i:i+len(needle)] == needle { return true } } return false })() } // ========================================================================== // Column management and export column selection // ========================================================================== func testCols() []AutoTableColumn { return []AutoTableColumn{ {Key: "name", DisplayName: "Name"}, // not toggleable: pinned on {Key: "email", DisplayName: "Email", Toggleable: true}, {Key: "age", DisplayName: "Age", Toggleable: true}, {Key: "notes", DisplayName: "Notes", Toggleable: true, HiddenByDefault: true}, } } func newCols() *AutoTableState { return NewAutoTableState(testCols(), AutoTableStateOptions{ Columns: AutoTableColumnOptions{Toggleable: true, Draggable: true, Resizable: true}, }) } func keys(cols []AutoTableColumn) []string { out := make([]string, len(cols)) for i, c := range cols { out[i] = ColumnKey(c, i) } return out } func TestVisibleColumnsRespectsHiddenByDefault(t *testing.T) { s := newCols() if want := []string{"name", "email", "age"}; !reflect.DeepEqual(keys(s.VisibleColumns()), want) { t.Errorf("got %v, want %v (notes is HiddenByDefault)", keys(s.VisibleColumns()), want) } } func TestToggleColumn(t *testing.T) { s := newCols() s.ToggleColumn("email") if want := []string{"name", "age"}; !reflect.DeepEqual(keys(s.VisibleColumns()), want) { t.Errorf("after hiding email: got %v, want %v", keys(s.VisibleColumns()), want) } s.ToggleColumn("email") if want := []string{"name", "email", "age"}; !reflect.DeepEqual(keys(s.VisibleColumns()), want) { t.Errorf("after re-showing email: got %v, want %v", keys(s.VisibleColumns()), want) } // A non-toggleable column cannot be hidden — hiding the identifying column would // leave rows unrecognisable. s.ToggleColumn("name") if !contains2(keys(s.VisibleColumns()), "name") { t.Error("a non-toggleable column was hidden") } } // Moving an item that sits BEFORE its destination is the off-by-one trap: removing // it first shifts the destination left by one. func TestMoveColumn(t *testing.T) { cases := []struct { name string from, to string want []string }{ {"forward", "name", "age", []string{"email", "age", "name", "notes"}}, {"backward", "age", "name", []string{"age", "name", "email", "notes"}}, {"onto itself", "email", "email", []string{"name", "email", "age", "notes"}}, {"to the end", "name", "notes", []string{"email", "age", "notes", "name"}}, {"to the front", "notes", "name", []string{"notes", "name", "email", "age"}}, {"adjacent forward", "name", "email", []string{"email", "name", "age", "notes"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { s := newCols() s.MoveColumn(c.from, c.to) if got := s.currentOrder(); !reflect.DeepEqual(got, c.want) { t.Errorf("move %s -> %s: got %v, want %v", c.from, c.to, got, c.want) } }) } } func TestMoveColumnIgnoresUnknownKeys(t *testing.T) { s := newCols() before := s.currentOrder() s.MoveColumn("nope", "name") s.MoveColumn("name", "nope") s.MoveColumn("", "") if got := s.currentOrder(); !reflect.DeepEqual(got, before) { t.Errorf("order changed on an unknown key: %v", got) } } // A persisted order outlives the code that wrote it. A column deleted since must // not resurrect, and one added since must still appear. func TestVisibleColumnsToleratesStalePersistedOrder(t *testing.T) { s := newCols() s.order.Set([]string{"age", "gone", "name"}) // "gone" no longer exists; "email"/"notes" are new got := keys(s.VisibleColumns()) if contains2(got, "gone") { t.Errorf("a deleted column came back from storage: %v", got) } if !contains2(got, "email") { t.Errorf("a column missing from the saved order was dropped: %v", got) } // The saved order still governs the columns it does name. if got[0] != "age" || got[1] != "name" { t.Errorf("saved order not honoured: %v", got) } } func TestResetColumns(t *testing.T) { s := newCols() s.MoveColumn("age", "name") s.ToggleColumn("email") s.widths.Set(map[string]float64{"name": 200}) s.ResetColumns() if want := []string{"name", "email", "age"}; !reflect.DeepEqual(keys(s.VisibleColumns()), want) { t.Errorf("after reset: got %v, want %v", keys(s.VisibleColumns()), want) } if s.ColumnWidth("name") != 0 { t.Errorf("width survived the reset: %v", s.ColumnWidth("name")) } // HiddenByDefault is part of the DEFAULT, so reset restores it rather than // showing everything. if !s.HiddenColumns()["notes"] { t.Error("reset unhid a HiddenByDefault column") } } // The order and hidden state drive what an export writes, so VisibleColumns has to // be the single source of truth for both rendering and export. func TestVisibleColumnsAfterReorderAndHide(t *testing.T) { s := newCols() s.ToggleColumn("notes") // show it s.MoveColumn("notes", "name") s.ToggleColumn("age") // hide it if want := []string{"notes", "name", "email"}; !reflect.DeepEqual(keys(s.VisibleColumns()), want) { t.Errorf("got %v, want %v", keys(s.VisibleColumns()), want) } } func contains2(xs []string, x string) bool { for _, v := range xs { if v == x { return true } } return false } // An export must write what the user is looking at: the VISIBLE columns, in the // order they dragged them into, plus any calculated columns — not the declared // column list. Exporting a hidden column, or losing a running total, is a bug you // only discover once the spreadsheet is open. func TestExportUsesVisibleAndCalculatedColumns(t *testing.T) { type rec struct{ Name, Secret, Amount string } cols := []AutoTableColumn{ {Key: "name", DisplayName: "Name", SortIdentifier: "Name", CSV: true}, {Key: "secret", DisplayName: "Secret", SortIdentifier: "Secret", CSV: true, Toggleable: true}, {Key: "amount", DisplayName: "Amount", SortIdentifier: "Amount", CSV: true}, } s := NewAutoTableState(cols, AutoTableStateOptions{ PerPage: 1, // only one row is ON SCREEN; the export must still write both Columns: AutoTableColumnOptions{Toggleable: true}, Calculated: []UserCalculatedColumn{{ ID: "running", DisplayName: "Running", Fn: CALC_FN_CUSTOM, Formula: "SUM({Amount:1:ROW()})", DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0), }}, }) s.SetRows([]any{ rec{"Ada", "hush", "10"}, rec{"Grace", "hush", "5"}, }) s.ToggleColumn("secret") // hide it s.Render() // resolves FilteredRows got := string(ExportCSV(s.ExportColumns(), s.FilteredRows(), s.read)) if strings.Contains(got, "Secret") || strings.Contains(got, "hush") { t.Errorf("a hidden column was exported:\n%s", got) } if !strings.Contains(got, "Running") { t.Errorf("the calculated column was not exported:\n%s", got) } // Both rows, not just the one page. if !strings.Contains(got, "Ada") || !strings.Contains(got, "Grace") { t.Errorf("export covered only the current page:\n%s", got) } // The running total must actually accumulate — 10, then 15. An index-free // CSVValue would have written the same number twice. if !strings.Contains(got, ",10\n") || !strings.Contains(got, ",15\n") { t.Errorf("running total did not accumulate across exported rows:\n%s", got) } } // ========================================================================== // The formula engine // ========================================================================== // The fixture every reference test resolves against: three rows, one non-numeric // column (Label) so blank/unreadable values get exercised too. // // Revenue: 100 200 300 (sum 600) // Cost: 60 150 100 // Qty: 2 4 4 (mode 4, median 4) type sale struct { Revenue float64 Cost float64 Qty int Label string } func saleRows() []any { return []any{ sale{Revenue: 100, Cost: 60, Qty: 2, Label: "a"}, sale{Revenue: 200, Cost: 150, Qty: 4, Label: "b"}, sale{Revenue: 300, Cost: 100, Qty: 4, Label: "c"}, } } func saleColumns() []AutoTableColumn { return []AutoTableColumn{ {DisplayName: "Revenue", SortIdentifier: "Revenue"}, {DisplayName: "Cost", SortIdentifier: "Cost"}, {DisplayName: "Qty", SortIdentifier: "Qty"}, {DisplayName: "Label", SortIdentifier: "Label"}, } } // saleCtx is a context over the fixture with no current row (a summary's view). func saleCtx(calcs ...UserCalculatedColumn) *CalcContext { return NewCalcContext(saleRows(), saleColumns(), calcs, nil) } // nearly compares results tolerantly, and treats NaN as equal to NaN (which is // how the engine reports "not computable"). func nearly(a, b float64) bool { if math.IsNaN(a) || math.IsNaN(b) { return math.IsNaN(a) && math.IsNaN(b) } return math.Abs(a-b) <= 1e-9*math.Max(1, math.Abs(b)) } // evalOK evaluates and fails the test on any error. func evalOK(t *testing.T, src string, ctx FormulaContext) float64 { t.Helper() n, err := EvalFormula(src, ctx) if err != nil { t.Fatalf("EvalFormula(%q): unexpected error: %v", src, err) } return n } func TestFormulaOperatorPrecedence(t *testing.T) { tests := []struct { src string want float64 }{ {"1 + 2 * 3", 7}, {"(1 + 2) * 3", 9}, {"10 - 2 - 3", 5}, // + - are left-associative {"100 / 5 / 2", 10}, // so are * / % {"8 / 2 * 3", 12}, {"2 ^ 3 ^ 2", 512}, // ^ is right-associative {"4 ^ 0.5", 2}, {"2 ^ -1", 0.5}, {"10 % 3", 1}, // % is the remainder, not a percentage {"-10 % 3", -1}, // ... and takes the sign of the dividend {"2 + 3 * 4 ^ 2 - 1", 49}, {"1 + 2 < 4", 1}, // comparison binds loosest {"2 * 3 = 6", 1}, // {"1 < 2 < 3", 1}, // (1<2) -> 1, then 1 < 3 {"2 * (3 + 4)", 14}, } for _, tt := range tests { if got := evalOK(t, tt.src, nil); !nearly(got, tt.want) { t.Errorf("%s = %v, want %v", tt.src, got, tt.want) } } } func TestFormulaUnaryMinus(t *testing.T) { tests := []struct { src string want float64 }{ {"-5", -5}, {"- -4", 4}, {"+5", 5}, {"-(2 + 3)", -5}, {"3 - -2", 5}, {"-2 ^ 2", 4}, // unary binds TIGHTER than ^, as in Excel: (-2)^2 {"-(2 ^ 2)", -4}, {"-PI", -math.Pi}, {"-ABS(-3)", -3}, } for _, tt := range tests { if got := evalOK(t, tt.src, nil); !nearly(got, tt.want) { t.Errorf("%s = %v, want %v", tt.src, got, tt.want) } } } func TestFormulaComparisons(t *testing.T) { tests := []struct { src string want float64 }{ {"5 > 3", 1}, {"5 < 3", 0}, {"5 = 5", 1}, {"5 = 4", 0}, {"5 <> 4", 1}, {"5 <> 5", 0}, {"5 >= 5", 1}, {"5 <= 4", 0}, {"(3 > 1) * 10", 10}, // 1/0, so it feeds straight back into arithmetic } for _, tt := range tests { if got := evalOK(t, tt.src, nil); !nearly(got, tt.want) { t.Errorf("%s = %v, want %v", tt.src, got, tt.want) } } // A NaN operand makes a comparison FALSE (0), not NaN: a blank cell fails a // test rather than poisoning the whole formula. ctx := saleCtx().ForRow(0) if got := evalOK(t, "[Label] = [Label]", ctx); got != 0 { t.Errorf("[Label] = [Label] = %v, want 0 (NaN compares false)", got) } if got := evalOK(t, "[Label] > 0", ctx); got != 0 { t.Errorf("[Label] > 0 = %v, want 0", got) } } func TestFormulaFunctions(t *testing.T) { ctx := saleCtx().ForRow(1) // Revenue 200, Cost 150, Qty 4 -- ROW() is 2 tests := []struct { src string want float64 }{ // Aggregates: they flatten {Column} arrays and skip non-numbers. {"SUM({Revenue})", 600}, {"SUM(1, 2, 3)", 6}, {"SUM({Revenue}, 400)", 1000}, {"SUM()", math.NaN()}, {"AVERAGE({Revenue})", 200}, {"AVG(2, 4)", 3}, // AVERAGE's alias {"MEDIAN(1, 2, 3, 4)", 2.5}, {"MEDIAN({Qty})", 4}, {"MODE(1, 2, 2, 3)", 2}, {"MODE(1, 2, 3)", math.NaN()}, // nothing repeats -> no mode {"MIN({Revenue})", 100}, {"MAX({Revenue})", 300}, {"COUNT({Revenue})", 3}, {"COUNT({Label})", 0}, // no numbers in the column {"COUNT()", 0}, // ... and COUNT is the one aggregate that is 0, not NaN {"COUNT(1, [Label], 3)", 2}, // Math. {"ABS(-3)", 3}, {"ABS()", math.NaN()}, // a missing argument is NaN {"ROUND(3.7)", 4}, {"ROUND(3.14159, 3)", 3.142}, {"ROUND(2.5)", 3}, {"ROUND(-2.5)", -2}, // JS's Math.round: a half goes UP, not away from zero {"FLOOR(2.7)", 2}, {"FLOOR(-2.1)", -3}, {"CEILING(2.1)", 3}, {"CEIL(2.1)", 3}, // CEILING's alias {"SQRT(16)", 4}, {"POWER(2, 10)", 1024}, {"MOD(7, 3)", 1}, {"MOD(-7, 3)", -1}, {"EXP(0)", 1}, {"EXP(1)", math.E}, {"LN(E)", 1}, {"LOG(100)", 2}, // base 10 by default, as in Excel {"LOG(8, 2)", 3}, // Trigonometry (radians). {"SIN(0)", 0}, {"COS(0)", 1}, {"TAN(0)", 0}, {"ASIN(1)", math.Pi / 2}, {"ACOS(1)", 0}, {"ATAN(1)", math.Pi / 4}, {"ATAN2(0, 1)", math.Pi / 2}, // ATAN2(x, y) -- Excel's order, not Go's {"ATAN2(1, 0)", 0}, {"SINH(0)", 0}, {"COSH(0)", 1}, {"TANH(0)", 0}, {"PI()", math.Pi}, {"RADIANS(180)", math.Pi}, {"DEGREES(PI)", 180}, // Logic. Any nonzero, non-NaN value is true. {"IF(1, 10, 20)", 10}, {"IF(0, 10, 20)", 20}, {"IF(0, 10)", 0}, // no else branch -> 0 {"IF([Label], 1, 2)", 2}, {"IF([Revenue] > 150, 1, 0)", 1}, {"AND(1, 1)", 1}, {"AND(1, 0)", 0}, {"AND(1, [Label])", 0}, // NaN is not true {"AND()", 1}, {"OR(0, 0)", 0}, {"OR(0, 1)", 1}, {"OR()", 0}, {"NOT(0)", 1}, {"NOT(5)", 0}, {"NOT([Label])", 1}, // Row. {"ROW()", 2}, } for _, tt := range tests { got, err := EvalFormula(tt.src, ctx) if err != nil { t.Errorf("%s: unexpected error: %v", tt.src, err) continue } if !nearly(got, tt.want) { t.Errorf("%s = %v, want %v", tt.src, got, tt.want) } } } // The untaken branch of an IF is not evaluated, so guarding a division works. func TestFormulaIfShortCircuits(t *testing.T) { n, err := EvalFormula("IF(0 <> 0, 1 / 0, 42)", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if n != 42 { t.Errorf("got %v, want 42", n) } } func TestFormulaConstants(t *testing.T) { tests := []struct { src string want float64 }{ {"PI", math.Pi}, {"E", math.E}, {"TAU", 2 * math.Pi}, {"PHI", (1 + math.Sqrt(5)) / 2}, {"SQRT2", math.Sqrt2}, {"pi", math.Pi}, // names are matched case-insensitively {"2 * PI", 2 * math.Pi}, {"TAU = 2 * PI", 1}, } for _, tt := range tests { if got := evalOK(t, tt.src, nil); !nearly(got, tt.want) { t.Errorf("%s = %v, want %v", tt.src, got, tt.want) } } } func TestFormulaCellRefs(t *testing.T) { tests := []struct { src string row int want float64 }{ {"[Revenue]", 0, 100}, {"[Revenue] - [Cost]", 0, 40}, {"[Revenue] - [Cost]", 1, 50}, {"[revenue]", 2, 300}, // names are matched case-insensitively {"[ Revenue ]", 2, 300}, // ... and trimmed {"[Label]", 0, math.NaN()}, // a non-numeric cell is NaN {"[Revenue] + [Label]", 0, math.NaN()}, // ... and it propagates through arithmetic {"([Revenue] - [Cost]) / [Revenue] * 100", 0, 40}, } for _, tt := range tests { ctx := saleCtx().ForRow(tt.row) if got := evalOK(t, tt.src, ctx); !nearly(got, tt.want) { t.Errorf("row %d: %s = %v, want %v", tt.row, tt.src, got, tt.want) } } // With no current row (a summary), a cell reference is NaN -- but it is not an // error, because the reference itself is valid. n, err := EvalFormula("[Revenue]", saleCtx()) if err != nil { t.Errorf("summary [Revenue]: unexpected error: %v", err) } if !math.IsNaN(n) { t.Errorf("summary [Revenue] = %v, want NaN", n) } } func TestFormulaColumnAggregates(t *testing.T) { tests := []struct { src string row int want float64 }{ {"SUM({Revenue})", 0, 600}, {"AVERAGE({Qty})", 0, 10.0 / 3}, {"MIN({Cost})", 0, 60}, {"MAX({Cost})", 0, 150}, {"[Revenue] / SUM({Revenue}) * 100", 1, 100.0 / 3}, // % of the column total {"[Revenue] - AVERAGE({Revenue})", 2, 100}, // deviation from the mean {"SUM({Revenue}) - SUM({Cost})", 0, 290}, {"{Revenue} + 1", 0, math.NaN()}, // an array in scalar position is NaN {"COUNT({Label})", 0, 0}, } for _, tt := range tests { ctx := saleCtx().ForRow(tt.row) if got := evalOK(t, tt.src, ctx); !nearly(got, tt.want) { t.Errorf("row %d: %s = %v, want %v", tt.row, tt.src, got, tt.want) } } } func TestFormulaColumnIndexing(t *testing.T) { tests := []struct { src string row int want float64 }{ {"{Revenue:1}", 0, 100}, // 1-based {"{Revenue:2}", 0, 200}, {"{Revenue:3}", 0, 300}, {"{Revenue:0}", 0, math.NaN()}, // out of range is NaN, not an error {"{Revenue:4}", 0, math.NaN()}, {"{Revenue:2.9}", 0, 200}, // the index is truncated {"{Revenue:ROW()}", 1, 200}, {"{Revenue:ROW()}", 2, 300}, {"{Revenue:ROW()-1}", 2, 200}, // the previous row {"{Revenue:ROW()-1}", 0, math.NaN()}, // ... which does not exist on the first {"{Revenue:1+1}", 0, 200}, // the index is an expression {"{Revenue:{Qty:1}}", 0, 200}, // ... which may itself be a reference {"[Revenue] - {Revenue:ROW()-1}", 1, 100}, {"{ Revenue : 1 }", 0, 100}, // whitespace is trimmed } for _, tt := range tests { ctx := saleCtx().ForRow(tt.row) if got := evalOK(t, tt.src, ctx); !nearly(got, tt.want) { t.Errorf("row %d: %s = %v, want %v", tt.row, tt.src, got, tt.want) } } // With no current row, ROW() is NaN and so is any index built on it. if got := evalOK(t, "{Revenue:ROW()}", saleCtx()); !math.IsNaN(got) { t.Errorf("summary {Revenue:ROW()} = %v, want NaN", got) } } func TestFormulaColumnRanges(t *testing.T) { tests := []struct { src string row int want float64 }{ {"SUM({Revenue:1:2})", 0, 300}, {"SUM({Revenue:2:3})", 0, 500}, {"COUNT({Revenue:1:3})", 0, 3}, {"SUM({Revenue:3:1})", 0, 600}, // reversed bounds are swapped {"SUM({Revenue:0:10})", 0, 600}, // ... and clipped to the column {"AVERAGE({Revenue:1:2})", 0, 150}, // The running total: the range's end is the current row. {"SUM({Revenue:1:ROW()})", 0, 100}, {"SUM({Revenue:1:ROW()})", 1, 300}, {"SUM({Revenue:1:ROW()})", 2, 600}, // A trailing moving average: both bounds are expressions. {"AVERAGE({Revenue:ROW()-1:ROW()})", 2, 250}, {"AVERAGE({Revenue:ROW()-2:ROW()})", 2, 200}, {"COUNT({Revenue:ROW()-2:ROW()})", 0, 1}, // the range runs off the top and is clipped {"SUM({Revenue:ROW():ROW()})", 1, 200}, } for _, tt := range tests { ctx := saleCtx().ForRow(tt.row) if got := evalOK(t, tt.src, ctx); !nearly(got, tt.want) { t.Errorf("row %d: %s = %v, want %v", tt.row, tt.src, got, tt.want) } } // A NaN bound yields an empty range (SUM of nothing is NaN, COUNT is 0). summary := saleCtx() if got := evalOK(t, "COUNT({Revenue:1:ROW()})", summary); got != 0 { t.Errorf("summary COUNT({Revenue:1:ROW()}) = %v, want 0", got) } } // margin/pctCol are two calculated columns that build on each other: // Margin = Revenue - Cost, and Margin % = Margin / Revenue * 100. func marginCol() UserCalculatedColumn { return UserCalculatedColumn{ ID: "m1", DisplayName: "Margin", Fn: CALC_FN_CUSTOM, Formula: "[Revenue] - [Cost]", DataType: CALC_TYPE_MONEY, } } func TestFormulaCalcColumnReferencesCalcColumn(t *testing.T) { margin := marginCol() pct := UserCalculatedColumn{ ID: "m2", DisplayName: "Margin %", Fn: CALC_FN_CUSTOM, Formula: "[Margin] / [Revenue] * 100", DataType: CALC_TYPE_PERCENT, } ctx := saleCtx(margin, pct) // Row 0: margin 40 of 100 -> 40%. Row 1: 50 of 200 -> 25%. for _, tt := range []struct { row int margin, pct float64 }{ {0, 40, 40}, {1, 50, 25}, {2, 200, 200.0 / 3}, } { got, err := ComputeCalculatedColumn(margin, ctx.ForRow(tt.row)) if err != nil || !nearly(got, tt.margin) { t.Errorf("row %d Margin = %v (err %v), want %v", tt.row, got, err, tt.margin) } got, err = ComputeCalculatedColumn(pct, ctx.ForRow(tt.row)) if err != nil || !nearly(got, tt.pct) { t.Errorf("row %d Margin %% = %v (err %v), want %v", tt.row, got, err, tt.pct) } } // A calculated column is also a whole COLUMN: {Margin} spans the rows. if got := evalOK(t, "SUM({Margin})", ctx.ForRow(0)); !nearly(got, 290) { t.Errorf("SUM({Margin}) = %v, want 290", got) } // ... including inside a range, which is the running total of a derived column. if got := evalOK(t, "SUM({Margin:1:ROW()})", ctx.ForRow(1)); !nearly(got, 90) { t.Errorf("SUM({Margin:1:ROW()}) = %v, want 90", got) } // The same chain through the predefined functions and _calc_ operands. basic := UserCalculatedColumn{ ID: "b1", DisplayName: "Basic Margin", Fn: CALC_FN_SUBTRACT, Operands: []string{"Revenue", "Cost"}, } ratio := UserCalculatedColumn{ ID: "b2", DisplayName: "Basic Ratio", Fn: CALC_FN_DIVIDE, Operands: []string{CalcRef("b1"), "Revenue"}, } bctx := saleCtx(basic, ratio) if got, err := ComputeCalculatedColumn(ratio, bctx.ForRow(0)); err != nil || !nearly(got, 0.4) { t.Errorf("Basic Ratio row 0 = %v (err %v), want 0.4", got, err) } // ... and a formula can reach a predefined column by display name. if got := evalOK(t, "[Basic Margin] * 2", bctx.ForRow(1)); !nearly(got, 100) { t.Errorf("[Basic Margin] * 2 = %v, want 100", got) } } func TestFormulaCycleDetection(t *testing.T) { // Direct: a formula that names its own column. self := UserCalculatedColumn{ ID: "s1", DisplayName: "Self", Fn: CALC_FN_CUSTOM, Formula: "[Self] + 1", } got, err := ComputeCalculatedColumn(self, saleCtx(self).ForRow(0)) if !errors.Is(err, ErrFormulaCycle) { t.Errorf("self-reference: err = %v, want ErrFormulaCycle", err) } if !math.IsNaN(got) { t.Errorf("self-reference = %v, want NaN", got) } // Transitive: A -> B -> A. a := UserCalculatedColumn{ID: "a", DisplayName: "A", Fn: CALC_FN_CUSTOM, Formula: "[B] + 1"} b := UserCalculatedColumn{ID: "b", DisplayName: "B", Fn: CALC_FN_CUSTOM, Formula: "[A] + 1"} got, err = ComputeCalculatedColumn(a, saleCtx(a, b).ForRow(0)) if !errors.Is(err, ErrFormulaCycle) { t.Errorf("A -> B -> A: err = %v, want ErrFormulaCycle", err) } if !math.IsNaN(got) { t.Errorf("A -> B -> A = %v, want NaN", got) } // Through a column reference rather than a cell reference, which recurses over // every row and would blow the stack fastest. ca := UserCalculatedColumn{ID: "ca", DisplayName: "CA", Fn: CALC_FN_CUSTOM, Formula: "SUM({CB})"} cb := UserCalculatedColumn{ID: "cb", DisplayName: "CB", Fn: CALC_FN_CUSTOM, Formula: "SUM({CA})"} if _, err := ComputeCalculatedColumn(ca, saleCtx(ca, cb).ForRow(0)); !errors.Is(err, ErrFormulaCycle) { t.Errorf("{CA} <-> {CB}: err = %v, want ErrFormulaCycle", err) } // And through the predefined operands. pa := UserCalculatedColumn{ID: "pa", DisplayName: "PA", Fn: CALC_FN_SUM, Operands: []string{CalcRef("pa")}} if _, err := ComputeCalculatedColumn(pa, saleCtx(pa).ForRow(0)); !errors.Is(err, ErrFormulaCycle) { t.Errorf("operand self-reference: err = %v, want ErrFormulaCycle", err) } // Two SIBLING references to the same column are not a cycle: the visiting set // tracks the chain above, not everything seen. m := marginCol() twice := UserCalculatedColumn{ ID: "t1", DisplayName: "Twice", Fn: CALC_FN_CUSTOM, Formula: "[Margin] + [Margin]", } if got, err := ComputeCalculatedColumn(twice, saleCtx(m, twice).ForRow(0)); err != nil || !nearly(got, 80) { t.Errorf("[Margin] + [Margin] = %v (err %v), want 80", got, err) } } func TestFormulaUnknownColumn(t *testing.T) { ctx := saleCtx().ForRow(0) for _, src := range []string{"[Nope]", "SUM({Nope})", "{Nope:1}", "SUM({Nope:1:2})"} { got, err := EvalFormula(src, ctx) if !errors.Is(err, ErrUnknownColumn) { t.Errorf("%s: err = %v, want ErrUnknownColumn", src, err) } if !math.IsNaN(got) { t.Errorf("%s = %v, want NaN", src, got) } } // A column that exists but holds nothing numeric is NOT an unknown column. if _, err := EvalFormula("[Label]", ctx); err != nil { t.Errorf("[Label]: unexpected error: %v", err) } } func TestFormulaDivisionByZero(t *testing.T) { ctx := saleCtx().ForRow(0) for _, src := range []string{ "1 / 0", "5 % 0", "MOD(5, 0)", "[Revenue] / ([Cost] - [Cost])", "[Revenue] / ([Revenue] - [Revenue])", } { got, err := EvalFormula(src, ctx) if !errors.Is(err, ErrDivideByZero) { t.Errorf("%s: err = %v, want ErrDivideByZero", src, err) } if !math.IsNaN(got) { t.Errorf("%s = %v, want NaN (never Inf)", src, got) } } // The predefined divide is the same: NaN, never an infinity. if got := ApplyCalcFunction(CALC_FN_DIVIDE, []float64{1, 0}); !math.IsNaN(got) { t.Errorf("divide by zero = %v, want NaN", got) } } func TestFormulaMalformedInput(t *testing.T) { tests := []struct { src string want error }{ {"", ErrEmptyFormula}, {" ", ErrEmptyFormula}, {"1 +", ErrFormulaSyntax}, {"(1", ErrFormulaSyntax}, {"1)", ErrFormulaSyntax}, {"1 2", ErrFormulaSyntax}, {"[Revenue", ErrFormulaSyntax}, {"{Revenue", ErrFormulaSyntax}, {"1 @ 2", ErrFormulaSyntax}, {"1.2.3", ErrFormulaSyntax}, // a malformed literal, where JS's parseFloat reads 1.2 {"SUM(1,)", ErrFormulaSyntax}, {"SUM(1 2)", ErrFormulaSyntax}, {"{Revenue:}", ErrEmptyFormula}, // the index is compiled as its own formula {"FOO(1)", ErrUnknownFunction}, {"NOPE", ErrUnknownName}, {"2 * BAR", ErrUnknownName}, } for _, tt := range tests { f, err := CompileFormula(tt.src) if !errors.Is(err, tt.want) { t.Errorf("CompileFormula(%q): err = %v, want %v", tt.src, err, tt.want) } if f != nil { t.Errorf("CompileFormula(%q): got a formula, want nil", tt.src) } } // A valid formula compiles once and evaluates many times. f, err := CompileFormula("[Revenue] * 2") if err != nil { t.Fatalf("unexpected error: %v", err) } if f.Source() != "[Revenue] * 2" { t.Errorf("Source() = %q", f.Source()) } ctx := saleCtx() for i, want := range []float64{200, 400, 600} { if got, _ := f.Eval(ctx.ForRow(i)); !nearly(got, want) { t.Errorf("row %d = %v, want %v", i, got, want) } } } func TestFormulaApplyCalcFunction(t *testing.T) { nan := math.NaN() tests := []struct { fn CalculatedFunction operands []float64 want float64 }{ // The aggregates skip NaN operands (a blank cell is not a zero). {CALC_FN_SUM, []float64{1, 2, 3}, 6}, {CALC_FN_SUM, []float64{1, nan, 3}, 4}, {CALC_FN_SUM, []float64{nan}, nan}, {CALC_FN_SUM, nil, nan}, {CALC_FN_AVERAGE, []float64{1, 2, 3, 10}, 4}, {CALC_FN_AVERAGE, []float64{2, nan, 4}, 3}, // the NaN is not counted in the divisor {CALC_FN_MEDIAN, []float64{3, 1, 2}, 2}, {CALC_FN_MEDIAN, []float64{4, 1, 3, 2}, 2.5}, {CALC_FN_MEDIAN, []float64{nan}, nan}, {CALC_FN_MODE, []float64{1, 2, 2, 3}, 2}, {CALC_FN_MODE, []float64{1, 2, 3}, nan}, // nothing repeats {CALC_FN_MODE, nil, nan}, {CALC_FN_MIN, []float64{3, 1, 2}, 1}, {CALC_FN_MIN, []float64{nan, 5}, 5}, {CALC_FN_MAX, []float64{3, 1, 2}, 3}, {CALC_FN_COUNT, []float64{1, nan, 3}, 2}, {CALC_FN_COUNT, nil, 0}, // the one aggregate that is 0 rather than NaN {CALC_FN_COUNT, []float64{nan}, 0}, // The arithmetic ones propagate NaN: a row missing an operand shows nothing // rather than a plausible-looking wrong number. {CALC_FN_SUBTRACT, []float64{10, 3, 2}, 5}, {CALC_FN_SUBTRACT, []float64{10, nan}, nan}, {CALC_FN_MULTIPLY, []float64{2, 3, 4}, 24}, {CALC_FN_MULTIPLY, []float64{2, nan}, nan}, {CALC_FN_DIVIDE, []float64{100, 5, 2}, 10}, {CALC_FN_DIVIDE, []float64{100, 0}, nan}, {CALC_FN_DIVIDE, []float64{100, nan}, nan}, {CALC_FN_DIVIDE, []float64{100}, 100}, // nothing to divide by } for _, tt := range tests { if got := ApplyCalcFunction(tt.fn, tt.operands); !nearly(got, tt.want) { t.Errorf("%s(%v) = %v, want %v", tt.fn, tt.operands, got, tt.want) } } } func TestFormulaPredefinedCalcColumns(t *testing.T) { // The ten predefined functions, each over the same two operand columns // (Revenue 100 / Cost 60 on row 0) so the arithmetic is easy to read. tests := []struct { fn CalculatedFunction want float64 }{ {CALC_FN_SUM, 160}, {CALC_FN_SUBTRACT, 40}, {CALC_FN_MULTIPLY, 6000}, {CALC_FN_DIVIDE, 100.0 / 60}, {CALC_FN_AVERAGE, 80}, {CALC_FN_MEDIAN, 80}, {CALC_FN_MODE, math.NaN()}, // 100 and 60 each appear once {CALC_FN_MIN, 60}, {CALC_FN_MAX, 100}, {CALC_FN_COUNT, 2}, } ctx := saleCtx().ForRow(0) for _, tt := range tests { uc := UserCalculatedColumn{ ID: "x", DisplayName: "X", Fn: tt.fn, Operands: []string{"Revenue", "Cost"}, } got, err := ComputeCalculatedColumn(uc, ctx) if err != nil { t.Errorf("%s: unexpected error: %v", tt.fn, err) continue } if !nearly(got, tt.want) { t.Errorf("%s = %v, want %v", tt.fn, got, tt.want) } } // A non-numeric operand: the aggregates skip it, the arithmetic ones give up. sum := UserCalculatedColumn{ID: "s", DisplayName: "S", Fn: CALC_FN_SUM, Operands: []string{"Revenue", "Label"}} if got, _ := ComputeCalculatedColumn(sum, ctx); !nearly(got, 100) { t.Errorf("sum with a blank operand = %v, want 100", got) } sub := UserCalculatedColumn{ID: "d", DisplayName: "D", Fn: CALC_FN_SUBTRACT, Operands: []string{"Revenue", "Label"}} if got, _ := ComputeCalculatedColumn(sub, ctx); !math.IsNaN(got) { t.Errorf("subtract with a blank operand = %v, want NaN", got) } } func TestFormulaSummaryRows(t *testing.T) { margin := marginCol() ctx := saleCtx(margin) tests := []struct { name string row UserSummaryRow want string }{ {"custom total", UserSummaryRow{ Label: "Total", Fn: CALC_FN_CUSTOM, Formula: "SUM({Revenue})", DataType: CALC_TYPE_MONEY, }, "$600.00"}, {"custom margin over a calc column", UserSummaryRow{ Label: "Margin", Fn: CALC_FN_CUSTOM, Formula: "SUM({Margin})", DataType: CALC_TYPE_MONEY, }, "$290.00"}, {"custom ratio", UserSummaryRow{ Label: "Margin %", Fn: CALC_FN_CUSTOM, Formula: "SUM({Margin}) / SUM({Revenue}) * 100", DataType: CALC_TYPE_PERCENT, }, "48.33%"}, {"basic sum", UserSummaryRow{ Label: "Revenue", Fn: CALC_FN_SUM, Operands: []string{"Revenue"}, DataType: CALC_TYPE_MONEY, }, "$600.00"}, {"basic average", UserSummaryRow{ Label: "Qty", Fn: CALC_FN_AVERAGE, Operands: []string{"Qty"}, DataType: CALC_TYPE_DECIMAL, }, "3.33"}, {"basic count", UserSummaryRow{ Label: "Rows", Fn: CALC_FN_COUNT, Operands: []string{"Revenue"}, DataType: CALC_TYPE_INTEGER, }, "3"}, {"basic over a calc column", UserSummaryRow{ Label: "Margin", Fn: CALC_FN_MAX, Operands: []string{CalcRef("m1")}, DataType: CALC_TYPE_MONEY, }, "$200.00"}, // A summary has no current row, so a cell reference and ROW() are NaN and // the whole line is the empty value. {"cell reference", UserSummaryRow{ Label: "Nope", Fn: CALC_FN_CUSTOM, Formula: "[Revenue] * 2", }, CalcEmptyValue}, {"row reference", UserSummaryRow{ Label: "Nope", Fn: CALC_FN_CUSTOM, Formula: "ROW()", }, CalcEmptyValue}, {"broken formula", UserSummaryRow{ Label: "Nope", Fn: CALC_FN_CUSTOM, Formula: "SUM({", }, CalcEmptyValue}, {"no operand", UserSummaryRow{ Label: "Nope", Fn: CALC_FN_SUM, }, CalcEmptyValue}, } for _, tt := range tests { if got := FormatSummaryRow(tt.row, ctx); got != tt.want { t.Errorf("%s: got %q, want %q", tt.name, got, tt.want) } } } func TestFormulaFormatCalcResult(t *testing.T) { p := CalcPrecision tests := []struct { name string result float64 dataType CalculatedDataType precision *int want string }{ {"money", 1234.5, CALC_TYPE_MONEY, nil, "$1,234.50"}, {"money precision", 1234.4, CALC_TYPE_MONEY, p(0), "$1,234"}, // The "$" leads and the sign follows it. Ugly, but it is what the TSX emits // (prefix + "$" + text), and a calculated column has no accounting format. {"money negative", -99.5, CALC_TYPE_MONEY, nil, "$-99.50"}, {"decimal", 1234.5678, CALC_TYPE_DECIMAL, nil, "1,234.57"}, {"decimal precision", 1234.5678, CALC_TYPE_DECIMAL, p(3), "1,234.568"}, {"integer", 1234.6, CALC_TYPE_INTEGER, nil, "1,235"}, {"integer rounds down", 1234.4, CALC_TYPE_INTEGER, nil, "1,234"}, {"integer ignores precision", 1234.6, CALC_TYPE_INTEGER, p(2), "1,235"}, {"percent", 12.3456, CALC_TYPE_PERCENT, nil, "12.35%"}, {"percent precision", 12.3456, CALC_TYPE_PERCENT, p(0), "12%"}, {"percent has no grouping", 1234.5, CALC_TYPE_PERCENT, p(1), "1234.5%"}, {"number", 1234.5, CALC_TYPE_NUMBER, nil, "1,234.5"}, {"number precision", 1234.5, CALC_TYPE_NUMBER, p(2), "1,234.50"}, {"number precision zero", 1234.4, CALC_TYPE_NUMBER, p(0), "1,234"}, {"plain", 1234.5, CALC_TYPE_PLAIN, nil, "1234.5"}, {"plain precision", 1234.56, CALC_TYPE_PLAIN, p(1), "1234.6"}, {"unset data type is plain", 1234.5, "", nil, "1234.5"}, // Not a finite number -> the empty value. {"NaN", math.NaN(), CALC_TYPE_MONEY, nil, CalcEmptyValue}, {"+Inf", math.Inf(1), CALC_TYPE_MONEY, nil, CalcEmptyValue}, {"-Inf", math.Inf(-1), CALC_TYPE_PLAIN, nil, CalcEmptyValue}, // Negative zero is never rendered: rounded to the display precision, a tiny // negative like SIN(2*PI) would otherwise show as "-0" / "-0.00". {"negative zero plain", math.Copysign(0, -1), CALC_TYPE_PLAIN, nil, "0"}, {"negative zero plain precision", -1e-16, CALC_TYPE_PLAIN, p(0), "0"}, {"negative zero percent", -0.0001, CALC_TYPE_PERCENT, p(2), "0.00%"}, {"negative zero decimal", -0.0001, CALC_TYPE_DECIMAL, nil, "0.00"}, {"negative zero money", -0.0001, CALC_TYPE_MONEY, nil, "$0.00"}, {"negative zero integer", -0.4, CALC_TYPE_INTEGER, nil, "0"}, {"negative zero number", -1e-16, CALC_TYPE_NUMBER, p(2), "0.00"}, // The rule is about the RENDERED text, not the value: a real negative that // rounds away to zero at the display precision also loses its sign... {"a negative that rounds to zero", -0.004, CALC_TYPE_PERCENT, p(2), "0.00%"}, // ... while one that survives the rounding keeps it. {"a negative that survives", -0.006, CALC_TYPE_PERCENT, p(2), "-0.01%"}, } for _, tt := range tests { got := FormatCalcResult(tt.result, tt.dataType, tt.precision, "", "", "") if got != tt.want { t.Errorf("%s: got %q, want %q", tt.name, got, tt.want) } } // prefix/suffix wrap the formatted text, outside the "$" and the "%". if got := FormatCalcResult(12.5, CALC_TYPE_MONEY, nil, "~", " ea", ""); got != "~$12.50 ea" { t.Errorf("prefix/suffix: got %q", got) } if got := FormatCalcResult(math.NaN(), CALC_TYPE_MONEY, nil, "~", " ea", "n/a"); got != "n/a" { t.Errorf("empty value: got %q, want %q (prefix/suffix do not apply)", got, "n/a") } } // The negative zero the suppression exists for, arrived at the way a user would. func TestFormulaNegativeZeroEndToEnd(t *testing.T) { // SIN(2*PI) is about -2.4e-16: the classic way to grow a "-0.00". n, err := EvalFormula("SIN(2 * PI)", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } if n >= 0 { t.Fatalf("SIN(2*PI) = %v, want a tiny negative", n) } for _, dt := range []CalculatedDataType{CALC_TYPE_MONEY, CALC_TYPE_DECIMAL, CALC_TYPE_PERCENT, CALC_TYPE_NUMBER, CALC_TYPE_INTEGER, CALC_TYPE_PLAIN} { got := FormatCalcResult(n, dt, CalcPrecision(2), "", "", "") if got != "" && got[0] == '-' { t.Errorf("%s: got %q, want no leading '-'", dt, got) } } } func TestFormulaFormatCalculatedColumn(t *testing.T) { margin := marginCol() // money ctx := saleCtx(margin) want := []string{"$40.00", "$50.00", "$200.00"} for i, w := range want { if got := FormatCalculatedColumn(margin, ctx.ForRow(i)); got != w { t.Errorf("row %d: got %q, want %q", i, got, w) } } // A broken formula renders the empty value rather than failing the render. broken := UserCalculatedColumn{ID: "z", DisplayName: "Z", Fn: CALC_FN_CUSTOM, Formula: "1 +"} if got := FormatCalculatedColumn(broken, saleCtx(broken).ForRow(0)); got != CalcEmptyValue { t.Errorf("broken formula: got %q, want %q", got, CalcEmptyValue) } } func TestFormulaToCalcNumber(t *testing.T) { tests := []struct { in any want float64 }{ {42, 42}, {42.5, 42.5}, {"42.5", 42.5}, {"$1,234.56", 1234.56}, // "$", "," and "%" are stripped {"12%", 12}, {" 7 ", 7}, {"-3.5", -3.5}, {"1e3", 1000}, {"12abc", 12}, // JS parseFloat reads the leading number {"abc", math.NaN()}, {"", math.NaN()}, {nil, math.NaN()}, {true, math.NaN()}, } for _, tt := range tests { if got := ToCalcNumber(tt.in); !nearly(got, tt.want) { t.Errorf("ToCalcNumber(%#v) = %v, want %v", tt.in, got, tt.want) } } } // A custom FieldReader is how a formula reaches a value that is not a plain field // — the FieldReader abstraction the rest of the pipeline already uses. func TestFormulaCustomFieldReader(t *testing.T) { read := func(row any, field string) any { if field == "Doubled" { return row.(sale).Revenue * 2 } return DefaultFieldReader(row, field) } cols := append(saleColumns(), AutoTableColumn{DisplayName: "Doubled", SortIdentifier: "Doubled"}) ctx := NewCalcContext(saleRows(), cols, nil, read) if got := evalOK(t, "[Doubled] - [Revenue]", ctx.ForRow(2)); !nearly(got, 300) { t.Errorf("got %v, want 300", got) } if got := evalOK(t, "SUM({Doubled})", ctx.ForRow(0)); !nearly(got, 1200) { t.Errorf("got %v, want 1200", got) } } // Rows may be maps, not just structs -- DefaultFieldReader handles both. func TestFormulaMapRows(t *testing.T) { rows := []any{ map[string]any{"amount": "$1,000.00"}, map[string]any{"amount": "$2,500.50"}, } cols := []AutoTableColumn{{DisplayName: "Amount", SortIdentifier: "amount"}} ctx := NewCalcContext(rows, cols, nil, nil) if got := evalOK(t, "SUM({Amount})", ctx); !nearly(got, 3500.5) { t.Errorf("SUM({Amount}) = %v, want 3500.5", got) } if got := evalOK(t, "[Amount]", ctx.ForRow(1)); !nearly(got, 2500.5) { t.Errorf("[Amount] = %v, want 2500.5", got) } } func TestFormulaEmptyRowSet(t *testing.T) { ctx := NewCalcContext(nil, saleColumns(), nil, nil) tests := []struct { src string want float64 }{ {"SUM({Revenue})", math.NaN()}, // nothing to sum {"COUNT({Revenue})", 0}, {"{Revenue:1}", math.NaN()}, {"COUNT({Revenue:1:3})", 0}, } for _, tt := range tests { if got := evalOK(t, tt.src, ctx); !nearly(got, tt.want) { t.Errorf("%s = %v, want %v", tt.src, got, tt.want) } } } // ========================================================================== // Export: CSV, PDF, print // ========================================================================== type exportRow struct { Name string Status string Amount float64 Note string } // exportCols exercises every way a column can (or cannot) produce an export value: // an explicit CSVValue, a fallback to the field named by SortIdentifier, a column // that opts out, and a column that opts in but has no way to produce anything. func exportCols() []AutoTableColumn { return []AutoTableColumn{ {DisplayName: "Name", SortIdentifier: "Name", CSV: true, CSVValue: func(r any) string { return r.(exportRow).Name }}, {DisplayName: "Status", SortIdentifier: "Status", CSV: true}, // fallback to the field {DisplayName: "Amount", SortIdentifier: "Amount", CSV: true, DisplayPosition: COL_POS_RIGHT, CSVValue: func(r any) string { return strconv.FormatFloat(r.(exportRow).Amount, 'f', 2, 64) }}, {DisplayName: "Actions", CSV: false, // not exported CSVValue: func(any) string { return "never" }}, {DisplayName: "Orphan", CSV: true}, // opted in, but no value source: dropped } } func exportRows() []any { return []any{ exportRow{Name: "Ada", Status: "active", Amount: 1234.5, Note: "x"}, exportRow{Name: "Bob", Status: "inactive", Amount: -99, Note: "y"}, } } // parseCSV reads the export back with the stdlib reader — which is the real // assertion about escaping: if the quoting is wrong, this either errors or gives // back the wrong fields. func parseCSV(t *testing.T, data []byte) [][]string { t.Helper() recs, err := csv.NewReader(bytes.NewReader(data)).ReadAll() if err != nil { t.Fatalf("the export does not parse as CSV: %v\n%s", err, data) } return recs } func TestExportCSVColumnSelection(t *testing.T) { recs := parseCSV(t, ExportCSV(exportCols(), exportRows(), DefaultFieldReader)) want := [][]string{ {"Name", "Status", "Amount"}, {"Ada", "active", "1234.50"}, {"Bob", "inactive", "-99.00"}, } if len(recs) != len(want) { t.Fatalf("got %d records, want %d: %q", len(recs), len(want), recs) } for i := range want { if strings.Join(recs[i], "|") != strings.Join(want[i], "|") { t.Errorf("record %d = %q, want %q", i, recs[i], want[i]) } } } // The Status column has no CSVValue: its text comes from the field named by // SortIdentifier, read through the FieldReader. func TestExportCSVFallsBackToSortIdentifier(t *testing.T) { cols := []AutoTableColumn{{DisplayName: "Status", SortIdentifier: "Status", CSV: true}} recs := parseCSV(t, ExportCSV(cols, exportRows(), DefaultFieldReader)) if recs[1][0] != "active" || recs[2][0] != "inactive" { t.Fatalf("fallback read the wrong field: %q", recs) } // A nil reader must still work — it defaults, exactly as the pipeline does. recs = parseCSV(t, ExportCSV(cols, exportRows(), nil)) if recs[1][0] != "active" { t.Fatalf("nil FieldReader did not default: %q", recs) } } func TestExportCSVEscaping(t *testing.T) { cols := []AutoTableColumn{ {DisplayName: "He, llo", SortIdentifier: "Name", CSV: true, CSVValue: func(r any) string { return r.(exportRow).Name }}, } rows := []any{ exportRow{Name: `a,b`}, exportRow{Name: `say "hi"`}, exportRow{Name: "line1\nline2"}, exportRow{Name: `mixed "q", and,`}, } data := ExportCSV(cols, rows, DefaultFieldReader) raw := string(data) // The literal bytes: a comma-bearing field is quoted, an embedded quote doubled. if !strings.Contains(raw, `"He, llo"`) { t.Errorf("a header with a comma was not quoted:\n%s", raw) } if !strings.Contains(raw, `"say ""hi"""`) { t.Errorf("an embedded quote was not doubled:\n%s", raw) } // And the round trip: the values come back byte-identical, newline and all. recs := parseCSV(t, data) want := []string{`a,b`, `say "hi"`, "line1\nline2", `mixed "q", and,`} if len(recs) != len(want)+1 { t.Fatalf("got %d records, want %d: %q", len(recs), len(want)+1, recs) } for i, w := range want { if recs[i+1][0] != w { t.Errorf("row %d round-tripped as %q, want %q", i, recs[i+1][0], w) } } } // A cell beginning with =, +, -, @ is a FORMULA to a spreadsheet. It gets a // leading quote — unless it is merely a negative number, which must survive // untouched or every financial export is wrong. func TestCSVFormulaInjectionGuard(t *testing.T) { cases := []struct{ in, want string }{ {`=cmd|'/c calc'!A1`, `'=cmd|'/c calc'!A1`}, {`=1+1`, `'=1+1`}, {`@SUM(A1)`, `'@SUM(A1)`}, {`+cmd`, `'+cmd`}, {"-hyphen-lead", "'-hyphen-lead"}, {"\tsneaky", "'\tsneaky"}, {"\rsneaky", "'\rsneaky"}, // A phone number IS guarded: it is not a number, and Excel would try to // evaluate a leading +. The quote is what keeps it displaying as typed. {"+1 (555) 010-9999", "'+1 (555) 010-9999"}, // Numbers, however dressed, are left alone — this is the carve-out that // keeps a money column from being mangled into text. {"-99", "-99"}, {"-1,234.50", "-1,234.50"}, {"-$1,234.50", "-$1,234.50"}, {"-12.5%", "-12.5%"}, {"+42", "+42"}, {"-1.5e3", "-1.5e3"}, // And so is anything that never started with a sigil. {"Ada", "Ada"}, {"(1,234.50)", "(1,234.50)"}, {"", ""}, } for _, c := range cases { if got := csvSafe(c.in); got != c.want { t.Errorf("csvSafe(%q) = %q, want %q", c.in, got, c.want) } } } // …and it survives the actual export, quoting and all. func TestExportCSVAppliesTheGuard(t *testing.T) { cols := []AutoTableColumn{ {DisplayName: "V", SortIdentifier: "Name", CSV: true, CSVValue: func(r any) string { return r.(exportRow).Name }}, } rows := []any{exportRow{Name: `=cmd|'/c calc'!A1`}, exportRow{Name: "-1,234.50"}} recs := parseCSV(t, ExportCSV(cols, rows, DefaultFieldReader)) if recs[1][0] != `'=cmd|'/c calc'!A1` { t.Errorf("the formula was not defused: %q", recs[1][0]) } if recs[2][0] != "-1,234.50" { t.Errorf("a negative number was mangled: %q", recs[2][0]) } } func TestExportCSVNothingToExport(t *testing.T) { if got := ExportCSV(exportCols(), nil, DefaultFieldReader); got != nil { t.Errorf("no rows should export nothing, got %q", got) } noCSV := []AutoTableColumn{{DisplayName: "X"}, {DisplayName: "Y", CSV: true}} // Y has no value source if got := ExportCSV(noCSV, exportRows(), DefaultFieldReader); got != nil { t.Errorf("no exportable columns should export nothing, got %q", got) } } func TestWithExt(t *testing.T) { for _, c := range []struct{ in, want string }{ {"report", "report.csv"}, {"report.csv", "report.csv"}, {"REPORT.CSV", "REPORT.CSV"}, {"", "export.csv"}, } { if got := withExt(c.in, ".csv"); got != c.want { t.Errorf("withExt(%q) = %q, want %q", c.in, got, c.want) } } } // ---- PDF ----------------------------------------------------------------- func manyRows(n int) []any { rows := make([]any, n) for i := range rows { rows[i] = exportRow{ Name: "Row " + strconv.Itoa(i), Status: "active", Amount: float64(i) * 1.5, } } return rows } func fixedHeader() AutoTablePDFHeader { return AutoTablePDFHeader{ Title: "Quarterly Report", Subtitle: "All accounts", ShowDate: true, Date: time.Date(2026, 7, 13, 0, 0, 0, 0, time.UTC), } } func TestExportPDFIsAValidDocument(t *testing.T) { data := ExportPDF(exportCols(), exportRows(), DefaultFieldReader, fixedHeader()) if data == nil { t.Fatal("no PDF produced") } info := mustCheckPDF(t, data) if info.pageObjs != 1 { t.Fatalf("two rows should fit on one page, got %d", info.pageObjs) } // The header block, the column headers, the data, and the footer are all there. for _, want := range []string{ "(Quarterly Report)", "(All accounts)", "(July 13, 2026)", "(Name)", "(Status)", "(Amount)", "(Ada)", "(inactive)", "(1234.50)", "(Page 1 of 1)", } { if !bytes.Contains(data, []byte(want)) { t.Errorf("the PDF is missing %s", want) } } // The column that opted out must not appear. if bytes.Contains(data, []byte("(never)")) || bytes.Contains(data, []byte("(Actions)")) { t.Error("a non-CSV column leaked into the PDF") } } func TestExportPDFPaginatesAndRepeatsTheHeaderRow(t *testing.T) { const n = 200 data := ExportPDF(exportCols(), manyRows(n), DefaultFieldReader, fixedHeader()) info := mustCheckPDF(t, data) if info.pageObjs < 2 { t.Fatalf("%d rows landed on %d page(s); the table is not paginating", n, info.pageObjs) } // The whole point of a repeated header row: it appears once per page. if got := bytes.Count(data, []byte("(Status)")); got != info.pageObjs { t.Errorf("the header row appears on %d of %d pages", got, info.pageObjs) } // Every row made it, exactly once. for _, i := range []int{0, 1, n / 2, n - 1} { want := []byte("(Row " + strconv.Itoa(i) + ")") if got := bytes.Count(data, want); got != 1 { t.Errorf("row %d appears %d times, want 1", i, got) } } // And each page is footed with its own number. for i := 1; i <= info.pageObjs; i++ { want := []byte("(Page " + strconv.Itoa(i) + " of " + strconv.Itoa(info.pageObjs) + ")") if !bytes.Contains(data, want) { t.Errorf("missing footer %s", want) } } } // The page break must happen at a specific row, not "eventually": one row past // the capacity of page 1 must open page 2, and not one row earlier. func TestExportPDFBreaksAtTheRightRow(t *testing.T) { pages := func(n int) int { data := ExportPDF(exportCols(), manyRows(n), DefaultFieldReader, fixedHeader()) return mustCheckPDF(t, data).pageObjs } // Find the last row count that still fits on one page. capacity := 0 for n := 1; n <= 60; n++ { if pages(n) > 1 { capacity = n - 1 break } } if capacity == 0 { t.Fatal("never found the first-page capacity (does it paginate at all?)") } if got := pages(capacity); got != 1 { t.Fatalf("%d rows (the computed capacity) took %d pages", capacity, got) } if got := pages(capacity + 1); got != 2 { t.Fatalf("%d rows took %d pages, want exactly 2", capacity+1, got) } // Landscape Letter: 612pt tall, 40pt margins, a title block and a 22pt column // header on page 1, 18pt rows. Roughly two dozen rows — a sanity check that the // capacity is in the right universe, so a layout bug that fits one row per page // (or a thousand) is caught. if capacity < 15 || capacity > 30 { t.Errorf("first-page capacity is %d rows, which is not plausible for Letter landscape", capacity) } } func TestExportPDFPortraitIsNarrower(t *testing.T) { h := fixedHeader() h.Orientation = PDF_ORIENTATION_PORTRAIT data := ExportPDF(exportCols(), exportRows(), DefaultFieldReader, h) mustCheckPDF(t, data) if !bytes.Contains(data, []byte("/MediaBox [0 0 612 792]")) { t.Error("portrait orientation did not reach the MediaBox") } } func TestExportPDFSummariesAndBelowTable(t *testing.T) { h := fixedHeader() h.Summaries = []AutoTablePDFSummary{{Label: "Total", Value: "1,135.50"}} var got PDFBelowTableContext called := 0 h.BelowTable = func(ctx PDFBelowTableContext) { called++ got = ctx ctx.PDF.Text(ctx.Margin, ctx.Y-20, "below-the-table", PDFTextStyle{Size: 9, Color: ctx.Muted}) ctx.AddPage() ctx.PDF.Text(ctx.Margin, 100, "on-a-fresh-page", PDFTextStyle{Size: 9, Color: ctx.Text}) } data := ExportPDF(exportCols(), exportRows(), DefaultFieldReader, h) info := mustCheckPDF(t, data) if called != 1 { t.Fatalf("BelowTable ran %d times, want 1", called) } if got.PDF == nil || got.ContentWidth <= 0 || got.PageWidth != 792 { t.Fatalf("BelowTable got a malformed context: %+v", got) } for _, want := range []string{"(Total)", "(1,135.50)", "(below-the-table)", "(on-a-fresh-page)"} { if !bytes.Contains(data, []byte(want)) { t.Errorf("the PDF is missing %s", want) } } // The page BelowTable added is footed too, and the totals agree. if info.pageObjs != 2 { t.Fatalf("page count = %d, want 2 (BelowTable added one)", info.pageObjs) } if !bytes.Contains(data, []byte("(Page 2 of 2)")) { t.Error("the page added by BelowTable was not footed") } } func TestExportPDFNothingToExport(t *testing.T) { if got := ExportPDF(exportCols(), nil, DefaultFieldReader, AutoTablePDFHeader{}); got != nil { t.Error("no rows should export no PDF") } if got := ExportPDF(nil, exportRows(), DefaultFieldReader, AutoTablePDFHeader{}); got != nil { t.Error("no columns should export no PDF") } } // A long value must not run past its column: it is truncated to fit, measured. func TestExportPDFTruncatesToTheColumn(t *testing.T) { cols := []AutoTableColumn{ {DisplayName: "A", SortIdentifier: "Name", CSV: true, CSVValue: func(r any) string { return r.(exportRow).Name }}, {DisplayName: "B", SortIdentifier: "Status", CSV: true}, } long := strings.Repeat("wide ", 400) data := ExportPDF(cols, []any{exportRow{Name: long, Status: "ok"}}, DefaultFieldReader, AutoTablePDFHeader{}) mustCheckPDF(t, data) if !bytes.Contains(data, []byte(`\205)`)) { // the ellipsis, WinAnsi 0x85 = \205 t.Error("an over-wide cell was not ellipsized") } if bytes.Contains(data, []byte(long)) { t.Error("the full over-wide value was drawn") } } // ---- the controller's half ------------------------------------------------ // An export writes what the FILTER selected — every matching row, across all // pages — not the page the user happens to be looking at. func TestAutoTableStateExportsEveryFilteredRowNotJustThePage(t *testing.T) { cols := []AutoTableColumn{ {DisplayName: "Name", SortIdentifier: "Name", CSV: true, CSVValue: func(r any) string { return r.(exportRow).Name }}, {DisplayName: "Status", SortIdentifier: "Status", CSV: true}, } rows := make([]any, 40) for i := range rows { status := "active" if i%2 == 1 { status = "inactive" } rows[i] = exportRow{Name: "Row " + strconv.Itoa(i), Status: status} } s := NewAutoTableState(cols, AutoTableStateOptions{PerPage: 5}) s.SetRows(rows) s.SetSearchValue("Status", "active", true) // 20 of the 40 page, _ := s.Process() if len(page) != 5 { t.Fatalf("the page holds %d rows, want 5", len(page)) } recs := parseCSV(t, s.ExportCSVBytes()) if len(recs) != 21 { // header + 20 matching rows t.Fatalf("the CSV has %d records, want 21 (header + every filtered row)", len(recs)) } for _, r := range recs[1:] { if r[1] != "active" { t.Fatalf("an unfiltered row reached the export: %q", r) } } pdf := s.ExportPDFBytes(fixedHeader()) mustCheckPDF(t, pdf) if !bytes.Contains(pdf, []byte("(Row 38)")) { // the last match, well past page 1 t.Error("the PDF export stopped at the current page") } }