1963 lines
66 KiB
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")
|
|
}
|
|
}
|