Files
kjol/go/webui/autotable_test.go

1963 lines
66 KiB
Go

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