493 lines
17 KiB
Go
493 lines
17 KiB
Go
package webui
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Calculated columns, and the distinction this originally got wrong:
|
|
//
|
|
// - a calculated COLUMN with a predefined function combines its operand columns
|
|
// ACROSS THE ROW — sum over [Revenue, Cost] is revenue + cost, per row;
|
|
// - a SUMMARY ROW aggregates ONE operand column DOWN the table.
|
|
//
|
|
// And operands are column KEYS (a column's SortIdentifier), not display names —
|
|
// formulas are the ones that use display names, as [Gross revenue].
|
|
|
|
type calcRec struct {
|
|
Name string
|
|
Revenue string
|
|
Cost string
|
|
}
|
|
|
|
// The display names deliberately differ from the field names, so anything that
|
|
// confuses a display name for a key fails loudly instead of passing by luck.
|
|
func newCalcTable() *AutoTableState {
|
|
s := NewAutoTableState([]AutoTableColumn{
|
|
{Key: "name", DisplayName: "Client", SortIdentifier: "Name", CSV: true},
|
|
{Key: "revenue", DisplayName: "Gross revenue", SortIdentifier: "Revenue", CSV: true},
|
|
{Key: "cost", DisplayName: "Direct cost", SortIdentifier: "Cost", CSV: true},
|
|
}, AutoTableStateOptions{})
|
|
s.SetRows([]any{
|
|
calcRec{"a", "100", "40"},
|
|
calcRec{"b", "200", "50"},
|
|
})
|
|
return s
|
|
}
|
|
|
|
// calcValues formats a calculated column for every row, the way the table does.
|
|
func calcValues(t *testing.T, s *AutoTableState, id string) []string {
|
|
t.Helper()
|
|
s.Render()
|
|
rows := s.FilteredRows()
|
|
ctx := NewCalcContext(rows, s.Columns(), s.Calculated(), nil)
|
|
|
|
var uc UserCalculatedColumn
|
|
for _, c := range s.Calculated() {
|
|
if c.ID == id {
|
|
uc = c
|
|
}
|
|
}
|
|
out := make([]string, len(rows))
|
|
for i := range rows {
|
|
out[i] = FormatCalculatedColumn(uc, ctx.ForRow(i))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- the model ----
|
|
|
|
// This is the whole ballgame: sum over Revenue and Cost is 140 and 250 — the row
|
|
// sums — NOT the column totals 300 and 90.
|
|
func TestCalculatedColumnCombinesOperandsAcrossTheRow(t *testing.T) {
|
|
s := newCalcTable()
|
|
s.AddCalculated(UserCalculatedColumn{
|
|
ID: "total", DisplayName: "Total", Fn: CALC_FN_SUM,
|
|
Operands: []string{"Revenue", "Cost"}, // KEYS, not display names
|
|
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
|
|
})
|
|
if got, want := calcValues(t, s, "total"), []string{"140", "250"}; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("sum across the row = %v, want %v (a down-column sum would give 300/90)", got, want)
|
|
}
|
|
}
|
|
|
|
// subtract and divide are binary AND ORDERED: swapping the operands changes the
|
|
// answer, so the order the user picked has to survive.
|
|
func TestBinaryCalculatedColumnsRespectOperandOrder(t *testing.T) {
|
|
s := newCalcTable()
|
|
s.AddCalculated(UserCalculatedColumn{
|
|
ID: "profit", DisplayName: "Profit", Fn: CALC_FN_SUBTRACT,
|
|
Operands: []string{"Revenue", "Cost"},
|
|
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
|
|
})
|
|
if got, want := calcValues(t, s, "profit"), []string{"60", "150"}; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("Revenue - Cost = %v, want %v", got, want)
|
|
}
|
|
|
|
// Reversed, it must become Cost - Revenue.
|
|
s.AddCalculated(UserCalculatedColumn{
|
|
ID: "profit", DisplayName: "Profit", Fn: CALC_FN_SUBTRACT,
|
|
Operands: []string{"Cost", "Revenue"},
|
|
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
|
|
})
|
|
if got, want := calcValues(t, s, "profit"), []string{"-60", "-150"}; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("Cost - Revenue = %v, want %v (operand order was lost)", got, want)
|
|
}
|
|
}
|
|
|
|
// A summary row is the other direction: one column, aggregated down the table.
|
|
func TestSummaryRowAggregatesDownTheColumn(t *testing.T) {
|
|
s := newCalcTable()
|
|
s.AddSummaryRow(UserSummaryRow{
|
|
ID: "total", Label: "Total revenue", Fn: CALC_FN_SUM,
|
|
Operands: []string{"Revenue"},
|
|
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
|
|
})
|
|
s.Render()
|
|
ctx := NewCalcContext(s.FilteredRows(), s.Columns(), s.Calculated(), nil)
|
|
if got := FormatSummaryRow(s.SummaryRows()[0], ctx); got != "300" {
|
|
t.Errorf("summary = %s, want 300 (100 + 200, down the column)", got)
|
|
}
|
|
}
|
|
|
|
// ---- the editor ----
|
|
|
|
// The Basic editor must offer operand KEYS. Offering display names works only while
|
|
// a display name happens to equal its field name — which is exactly the bug.
|
|
func TestEditorOperandOptionsAreKeysNotLabels(t *testing.T) {
|
|
s := newCalcTable()
|
|
opts := s.operandOptions("")
|
|
|
|
byLabel := map[string]string{}
|
|
for _, o := range opts {
|
|
byLabel[o.Label] = o.Key
|
|
}
|
|
if byLabel["Gross revenue"] != "Revenue" {
|
|
t.Errorf("operand for %q = %q, want the key %q", "Gross revenue", byLabel["Gross revenue"], "Revenue")
|
|
}
|
|
if len(opts) != 3 {
|
|
t.Errorf("got %d operand options, want 3", len(opts))
|
|
}
|
|
}
|
|
|
|
// A calculated column must not be offered as an operand of itself: that is a cycle,
|
|
// and the engine would refuse to evaluate it.
|
|
func TestEditorExcludesTheColumnBeingEdited(t *testing.T) {
|
|
s := newCalcTable()
|
|
s.AddCalculated(UserCalculatedColumn{ID: "profit", DisplayName: "Profit",
|
|
Fn: CALC_FN_SUBTRACT, Operands: []string{"Revenue", "Cost"}})
|
|
|
|
for _, o := range s.operandOptions("profit") {
|
|
if o.Key == CalcRef("profit") {
|
|
t.Error("the column being edited was offered as an operand of itself")
|
|
}
|
|
}
|
|
// It IS offered when editing something else.
|
|
found := false
|
|
for _, o := range s.operandOptions("other") {
|
|
if o.Key == CalcRef("profit") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("an existing calculated column should be referenceable from another one")
|
|
}
|
|
}
|
|
|
|
func TestEditorBasicModeSavesAColumn(t *testing.T) {
|
|
s := newCalcTable()
|
|
e := s.calcEditorState()
|
|
|
|
e.name.Set("Total")
|
|
e.fn.Set(string(CALC_FN_SUM))
|
|
e.operands.Set([]string{"Revenue", "Cost"})
|
|
e.precision.Set("0")
|
|
if msg := s.calcValidate(e, false); msg != "" {
|
|
t.Fatalf("valid form rejected: %s", msg)
|
|
}
|
|
s.saveCalc(e, false)
|
|
|
|
if len(s.Calculated()) != 1 {
|
|
t.Fatalf("saved %d columns, want 1", len(s.Calculated()))
|
|
}
|
|
uc := s.Calculated()[0]
|
|
if uc.Fn != CALC_FN_SUM || !reflect.DeepEqual(uc.Operands, []string{"Revenue", "Cost"}) {
|
|
t.Errorf("saved the wrong spec: %+v", uc)
|
|
}
|
|
if got, want := calcValues(t, s, uc.ID), []string{"140", "250"}; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("the saved column computes %v, want %v", got, want)
|
|
}
|
|
if e.name.Get() != "" || e.editingID.Get() != "" {
|
|
t.Error("the form did not reset after saving")
|
|
}
|
|
}
|
|
|
|
// Advanced mode saves fn "custom", with no stale basic spec left beside it — two
|
|
// contradictory sources of truth would be worse than either.
|
|
func TestEditorAdvancedModeSavesAFormula(t *testing.T) {
|
|
s := newCalcTable()
|
|
e := s.calcEditorState()
|
|
|
|
e.name.Set("Margin")
|
|
e.advanced.Set(true)
|
|
// Formulas reference columns by DISPLAY name, unlike operands.
|
|
e.formula.Set("([Gross revenue] - [Direct cost]) / [Gross revenue] * 100")
|
|
e.precision.Set("0")
|
|
e.operands.Set([]string{"Revenue"}) // left over from basic mode; must be dropped
|
|
s.saveCalc(e, false)
|
|
|
|
uc := s.Calculated()[0]
|
|
if uc.Fn != CALC_FN_CUSTOM {
|
|
t.Errorf("Fn = %q, want custom", uc.Fn)
|
|
}
|
|
if len(uc.Operands) != 0 {
|
|
t.Errorf("a stale basic operand list survived: %v", uc.Operands)
|
|
}
|
|
if got, want := calcValues(t, s, uc.ID), []string{"60", "75"}; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("margin = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// The operand rules belong to the model, not the form.
|
|
func TestEditorValidation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
setup func(*calcEditor)
|
|
want string
|
|
}{
|
|
{"no name", func(e *calcEditor) { e.operands.Set([]string{"Revenue"}) }, "Enter a name."},
|
|
{"no operands", func(e *calcEditor) { e.name.Set("X") }, "Pick at least one column."},
|
|
{"binary needs two", func(e *calcEditor) {
|
|
e.name.Set("X")
|
|
e.fn.Set(string(CALC_FN_SUBTRACT))
|
|
e.operands.Set([]string{"Revenue"})
|
|
}, "Pick both columns."},
|
|
{"empty formula", func(e *calcEditor) {
|
|
e.name.Set("X")
|
|
e.advanced.Set(true)
|
|
}, "Enter a formula."},
|
|
{"ok", func(e *calcEditor) {
|
|
e.name.Set("X")
|
|
e.operands.Set([]string{"Revenue", "Cost"})
|
|
}, ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
s := newCalcTable()
|
|
e := s.calcEditorState()
|
|
c.setup(e)
|
|
if got := s.calcValidate(e, false); got != c.want {
|
|
t.Errorf("calcValidate = %q, want %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A formula that does not compile reports the compiler's own error, and does not
|
|
// save: it would add a column of dashes and leave the user with no idea why.
|
|
s := newCalcTable()
|
|
e := s.calcEditorState()
|
|
e.name.Set("X")
|
|
e.advanced.Set(true)
|
|
e.formula.Set("[Gross revenue] * ")
|
|
if s.calcValidate(e, false) == "" {
|
|
t.Error("an unparseable formula passed validation")
|
|
}
|
|
s.saveCalc(e, false)
|
|
if len(s.Calculated()) != 0 {
|
|
t.Error("an invalid formula was saved anyway")
|
|
}
|
|
if e.errorMsg.Get() == "" {
|
|
t.Error("saving an invalid form set no error message")
|
|
}
|
|
}
|
|
|
|
// Editing loads the existing spec and REPLACES it, rather than adding a second one.
|
|
func TestEditorEditAndRemove(t *testing.T) {
|
|
s := newCalcTable()
|
|
e := s.calcEditorState()
|
|
|
|
e.name.Set("Total")
|
|
e.operands.Set([]string{"Revenue", "Cost"})
|
|
s.saveCalc(e, false)
|
|
id := s.Calculated()[0].ID
|
|
|
|
s.loadColumn(e, s.Calculated()[0])
|
|
if e.editingID.Get() != id || e.advanced.Get() {
|
|
t.Fatalf("loadColumn did not restore basic mode: id=%q advanced=%v", e.editingID.Get(), e.advanced.Get())
|
|
}
|
|
if !reflect.DeepEqual(e.operands.Get(), []string{"Revenue", "Cost"}) {
|
|
t.Errorf("operands not loaded: %v", e.operands.Get())
|
|
}
|
|
|
|
e.fn.Set(string(CALC_FN_SUBTRACT))
|
|
s.saveCalc(e, false)
|
|
if len(s.Calculated()) != 1 {
|
|
t.Fatalf("editing added a duplicate: %d columns", len(s.Calculated()))
|
|
}
|
|
if s.Calculated()[0].Fn != CALC_FN_SUBTRACT {
|
|
t.Errorf("the edit was not applied: %+v", s.Calculated()[0])
|
|
}
|
|
|
|
s.RemoveCalculated(id)
|
|
if len(s.Calculated()) != 0 {
|
|
t.Error("RemoveCalculated left the column behind")
|
|
}
|
|
}
|
|
|
|
// Deleting a column the table is SORTED BY must drop the sort — a sort pointing at a
|
|
// column that no longer exists silently stops sorting.
|
|
func TestRemoveCalculatedDropsItsSort(t *testing.T) {
|
|
s := newCalcTable()
|
|
s.AddCalculated(UserCalculatedColumn{ID: "profit", DisplayName: "Profit",
|
|
Fn: CALC_FN_SUBTRACT, Operands: []string{"Revenue", "Cost"}})
|
|
|
|
s.ToggleSort(CalcRef("profit"))
|
|
if s.OrderBy().Identifier != CalcRef("profit") {
|
|
t.Fatal("could not sort by the calculated column")
|
|
}
|
|
s.RemoveCalculated("profit")
|
|
if s.OrderBy().Identifier != "" {
|
|
t.Errorf("sort still points at the deleted column: %q", s.OrderBy().Identifier)
|
|
}
|
|
}
|
|
|
|
func TestEditorSavesASummaryRow(t *testing.T) {
|
|
s := newCalcTable()
|
|
e := s.calcEditorState()
|
|
|
|
e.view.Set("summary")
|
|
e.name.Set("Total revenue")
|
|
e.fn.Set(string(CALC_FN_SUM))
|
|
e.operands.Set([]string{"Revenue"})
|
|
e.precision.Set("0")
|
|
s.saveCalc(e, true)
|
|
|
|
if len(s.SummaryRows()) != 1 || len(s.Calculated()) != 0 {
|
|
t.Fatalf("summary=%d calculated=%d, want 1 and 0", len(s.SummaryRows()), len(s.Calculated()))
|
|
}
|
|
s.Render()
|
|
ctx := NewCalcContext(s.FilteredRows(), s.Columns(), s.Calculated(), nil)
|
|
if got := FormatSummaryRow(s.SummaryRows()[0], ctx); got != "300" {
|
|
t.Errorf("summary = %s, want 300", got)
|
|
}
|
|
}
|
|
|
|
// A calculated column may reference another one — the engine resolves _calc_<id>
|
|
// through the same operand path.
|
|
func TestCalculatedColumnCanReferenceAnother(t *testing.T) {
|
|
s := newCalcTable()
|
|
s.AddCalculated(UserCalculatedColumn{
|
|
ID: "profit", DisplayName: "Profit", Fn: CALC_FN_SUBTRACT,
|
|
Operands: []string{"Revenue", "Cost"},
|
|
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
|
|
})
|
|
s.AddCalculated(UserCalculatedColumn{
|
|
ID: "double", DisplayName: "Double profit", Fn: CALC_FN_SUM,
|
|
Operands: []string{CalcRef("profit"), CalcRef("profit")},
|
|
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
|
|
})
|
|
if got, want := calcValues(t, s, "double"), []string{"120", "300"}; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("calc-in-calc = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// ---- syntax highlighting ----
|
|
|
|
// The highlighter is LEXICAL, not a parse: it has to colour a half-typed formula
|
|
// that does not compile yet, which is exactly when the colours earn their keep.
|
|
func TestHighlightFormula(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
src string
|
|
want []string // fragments that must appear
|
|
}{
|
|
{"cell ref", "[Revenue]", []string{`<span class="text-sky-600">[Revenue]</span>`}},
|
|
{"column ref", "{Revenue}", []string{`<span class="text-violet-600">{Revenue}</span>`}},
|
|
{"nested braces stay one span", "{Revenue:1:ROW()}",
|
|
[]string{`<span class="text-violet-600">{Revenue:1:ROW()}</span>`}},
|
|
{"function", "SUM(",
|
|
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
|
|
{"function with a space before the paren", "SUM (",
|
|
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
|
|
{"number", "12.5", []string{`<span class="text-amber-600">12.5</span>`}},
|
|
{"constant", "PI", []string{`<span class="text-amber-600">PI</span>`}},
|
|
{"operators", "1 + 2", []string{`<span class="text-neutral-400">+</span>`}},
|
|
{"a bare word is not coloured", "Revenue", []string{"Revenue"}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := HighlightFormula(c.src)
|
|
for _, want := range c.want {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("HighlightFormula(%q) = %s\nmissing %s", c.src, got, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// It runs on every keystroke, so it must survive a formula mid-type: an unclosed
|
|
// bracket, a trailing operator, a lone brace.
|
|
func TestHighlightFormulaToleratesHalfTypedInput(t *testing.T) {
|
|
for _, src := range []string{"[Reven", "{Rev", "SUM({Revenue}", "1 +", "((", "[", "{", ""} {
|
|
if got := HighlightFormula(src); got == "" && src != "" {
|
|
t.Errorf("HighlightFormula(%q) produced nothing", src)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The overlay is injected as raw HTML, so a column name containing markup must not
|
|
// escape into the DOM.
|
|
func TestHighlightFormulaEscapesHTML(t *testing.T) {
|
|
got := HighlightFormula(`[<script>alert(1)</script>]`)
|
|
if strings.Contains(got, "<script>") {
|
|
t.Errorf("HighlightFormula did not escape markup: %s", got)
|
|
}
|
|
if !strings.Contains(got, "<script>") {
|
|
t.Errorf("expected escaped markup, got: %s", got)
|
|
}
|
|
}
|
|
|
|
// ---- the chooser ----
|
|
|
|
// A calculated column and a summary row are different objects, so the editor opens
|
|
// on a chooser rather than a single form with a switch.
|
|
func TestEditorOpensOnTheChooser(t *testing.T) {
|
|
s := newCalcTable()
|
|
e := s.calcEditorState()
|
|
if e.view.Get() != "menu" {
|
|
t.Errorf("editor opened on %q, want the chooser", e.view.Get())
|
|
}
|
|
|
|
// The panel's content is inside a portal, whose children the SERVER does not
|
|
// render (it does not own document.body) — so exercise the panel builders
|
|
// directly rather than through the closed popover.
|
|
html := renderNode(s.calcChooser(e))
|
|
for _, want := range []string{"Calculated column", "A new column computed for each row", "Summary row", "A total or aggregate shown in the footer"} {
|
|
if !strings.Contains(html, want) {
|
|
t.Errorf("the chooser is missing %q:\n%s", want, html)
|
|
}
|
|
}
|
|
|
|
// The column form and the summary form are different forms, not one form with a
|
|
// switch: the column has an Align field and the summary aggregates down a column.
|
|
col := renderNode(s.calcForm(e, false))
|
|
if !strings.Contains(col, "Column name") || !strings.Contains(col, "Align") {
|
|
t.Errorf("the column form is wrong:\n%s", col)
|
|
}
|
|
sum := renderNode(s.calcForm(e, true))
|
|
if !strings.Contains(sum, "Label") || !strings.Contains(sum, "Aggregate down this column") {
|
|
t.Errorf("the summary form is wrong:\n%s", sum)
|
|
}
|
|
if strings.Contains(sum, "Align") {
|
|
t.Error("a footer row has no column of its own to align")
|
|
}
|
|
|
|
// Both offer Basic / Advanced.
|
|
for _, want := range []string{"Basic", "Advanced"} {
|
|
if !strings.Contains(col, want) {
|
|
t.Errorf("the form is missing the %q mode", want)
|
|
}
|
|
}
|
|
|
|
// Advanced shows the highlighted overlay and the three insert menus.
|
|
e.advanced.Set(true)
|
|
e.formula.Set("[Gross revenue] * 12")
|
|
adv := renderNode(s.calcForm(e, false))
|
|
if !strings.Contains(adv, `<span class="text-sky-600">[Gross revenue]</span>`) {
|
|
t.Errorf("the formula is not syntax-highlighted:\n%s", adv)
|
|
}
|
|
for _, want := range []string{"Column", "Function", "Constant"} {
|
|
if !strings.Contains(adv, ">"+want+"<") {
|
|
t.Errorf("the %q insert menu is missing", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Editing an existing definition jumps straight to the right form, rather than
|
|
// making the user pick its kind again.
|
|
func TestEditingJumpsToTheRightForm(t *testing.T) {
|
|
s := newCalcTable()
|
|
e := s.calcEditorState()
|
|
|
|
s.AddCalculated(UserCalculatedColumn{ID: "c", DisplayName: "C", Fn: CALC_FN_SUM, Operands: []string{"Revenue"}})
|
|
s.AddSummaryRow(UserSummaryRow{ID: "r", Label: "R", Fn: CALC_FN_SUM, Operands: []string{"Revenue"}})
|
|
|
|
s.loadColumn(e, s.Calculated()[0])
|
|
if e.view.Get() != "column" {
|
|
t.Errorf("editing a column opened view %q, want \"column\"", e.view.Get())
|
|
}
|
|
s.loadSummary(e, s.SummaryRows()[0])
|
|
if e.view.Get() != "summary" {
|
|
t.Errorf("editing a summary row opened view %q, want \"summary\"", e.view.Get())
|
|
}
|
|
|
|
// Closing the popover abandons the draft, so reopening starts clean rather than
|
|
// resuming a half-written formula the user walked away from.
|
|
e.reset()
|
|
if e.view.Get() != "menu" || e.editingID.Get() != "" || e.formula.Get() != "" {
|
|
t.Error("reset did not return the editor to a clean chooser")
|
|
}
|
|
}
|