Add fonts, autotable, autotable examples

This commit is contained in:
2026-07-13 13:01:26 -04:00
parent ea3d2a6d03
commit cf8342f8d4
71 changed files with 16084 additions and 1443 deletions

View File

@@ -0,0 +1,492 @@
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, "&lt;script&gt;") {
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")
}
}