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_ // 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{`[Revenue]`}}, {"column ref", "{Revenue}", []string{`{Revenue}`}}, {"nested braces stay one span", "{Revenue:1:ROW()}", []string{`{Revenue:1:ROW()}`}}, {"function", "SUM(", []string{`SUM`}}, {"function with a space before the paren", "SUM (", []string{`SUM`}}, {"number", "12.5", []string{`12.5`}}, {"constant", "PI", []string{`PI`}}, {"operators", "1 + 2", []string{`+`}}, {"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(`[]`) if strings.Contains(got, "