@valentinkolb/cloud 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -8
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +119 -47
- package/src/_internal/runtime-context.ts +1 -0
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +15 -25
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +4 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +4 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
evaluateFormula,
|
|
4
|
+
formatValue,
|
|
5
|
+
isFormula,
|
|
6
|
+
isTotalRow,
|
|
7
|
+
parseProgressValue,
|
|
8
|
+
type EvalContext,
|
|
9
|
+
type EvalResult,
|
|
10
|
+
type ErrorCode,
|
|
11
|
+
} from "./formula";
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Helpers
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
const ctx = (headers: string[], rows: string[][], currentRow = 0, currentCol = 0): EvalContext => ({
|
|
18
|
+
headers,
|
|
19
|
+
rows,
|
|
20
|
+
currentRow,
|
|
21
|
+
currentCol,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const expectOk = (res: EvalResult, value: number | string | boolean) => {
|
|
25
|
+
expect(res.kind).toBe("ok");
|
|
26
|
+
if (res.kind === "ok") expect(res.value).toBe(value);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const expectError = (res: EvalResult, code: ErrorCode) => {
|
|
30
|
+
expect(res.kind).toBe("error");
|
|
31
|
+
if (res.kind === "error") expect(res.code).toBe(code);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const expectProgress = (res: EvalResult, ratio: number, label: string) => {
|
|
35
|
+
expect(res.kind).toBe("ok");
|
|
36
|
+
if (res.kind !== "ok") return;
|
|
37
|
+
const progress = parseProgressValue(res.value);
|
|
38
|
+
expect(progress).not.toBeNull();
|
|
39
|
+
expect(progress?.ratio).toBe(ratio);
|
|
40
|
+
expect(progress?.label).toBe(label);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// isFormula + formatValue
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
47
|
+
describe("helpers", () => {
|
|
48
|
+
test("isFormula recognises = prefix", () => {
|
|
49
|
+
expect(isFormula("=SUM(price)")).toBe(true);
|
|
50
|
+
expect(isFormula("100")).toBe(false);
|
|
51
|
+
expect(isFormula("")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("formatValue keeps integers integer", () => {
|
|
55
|
+
expect(formatValue(42)).toBe("42");
|
|
56
|
+
expect(formatValue(0)).toBe("0");
|
|
57
|
+
expect(formatValue(-5)).toBe("-5");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("formatValue strips trailing zeros from decimals", () => {
|
|
61
|
+
expect(formatValue(1.5)).toBe("1.5");
|
|
62
|
+
expect(formatValue(1.23456)).toBe("1.23456");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("formatValue handles infinity / NaN", () => {
|
|
66
|
+
expect(formatValue(Number.POSITIVE_INFINITY)).toBe("∞");
|
|
67
|
+
expect(formatValue(Number.NEGATIVE_INFINITY)).toBe("-∞");
|
|
68
|
+
expect(formatValue(Number.NaN)).toBe("NaN");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("formatValue passes strings through, booleans → 1/0", () => {
|
|
72
|
+
expect(formatValue("hello")).toBe("hello");
|
|
73
|
+
expect(formatValue(true)).toBe("1");
|
|
74
|
+
expect(formatValue(false)).toBe("0");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// Lexer / parser smoke tests via end-to-end eval
|
|
80
|
+
// =============================================================================
|
|
81
|
+
|
|
82
|
+
describe("lexer + parser", () => {
|
|
83
|
+
const c = ctx(["a"], [["10"]]);
|
|
84
|
+
|
|
85
|
+
test("rejects formula without leading =", () => {
|
|
86
|
+
expectError(evaluateFormula("SUM(a)", c), "PARSE_ERROR");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("rejects empty formula", () => {
|
|
90
|
+
expectError(evaluateFormula("=", c), "PARSE_ERROR");
|
|
91
|
+
expectError(evaluateFormula("= ", c), "PARSE_ERROR");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("rejects trailing garbage", () => {
|
|
95
|
+
expectError(evaluateFormula("=1 + 2 foo", c), "PARSE_ERROR");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("rejects unterminated string", () => {
|
|
99
|
+
expectError(evaluateFormula(`="hello`, c), "PARSE_ERROR");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("rejects unexpected character", () => {
|
|
103
|
+
expectError(evaluateFormula("=1 # 2", c), "PARSE_ERROR");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("number literals parse including decimals", () => {
|
|
107
|
+
expectOk(evaluateFormula("=42", c), 42);
|
|
108
|
+
expectOk(evaluateFormula("=3.14", c), 3.14);
|
|
109
|
+
expectOk(evaluateFormula("=.5", c), 0.5);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("string literals with escapes", () => {
|
|
113
|
+
expectOk(evaluateFormula(`="hello"`, c), "hello");
|
|
114
|
+
expectOk(evaluateFormula(`="he said \\"hi\\""`, c), 'he said "hi"');
|
|
115
|
+
expectOk(evaluateFormula(`="back\\\\slash"`, c), "back\\slash");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// =============================================================================
|
|
120
|
+
// Operator precedence + associativity
|
|
121
|
+
// =============================================================================
|
|
122
|
+
|
|
123
|
+
describe("operator precedence", () => {
|
|
124
|
+
const c = ctx(["a"], [["1"]]);
|
|
125
|
+
|
|
126
|
+
test("multiplication binds tighter than addition", () => {
|
|
127
|
+
expectOk(evaluateFormula("=2 + 3 * 4", c), 14);
|
|
128
|
+
expectOk(evaluateFormula("=10 - 2 * 3", c), 4);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("parens override precedence", () => {
|
|
132
|
+
expectOk(evaluateFormula("=(2 + 3) * 4", c), 20);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("left-associative subtraction", () => {
|
|
136
|
+
expectOk(evaluateFormula("=10 - 3 - 2", c), 5);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("comparison binds looser than arithmetic", () => {
|
|
140
|
+
expectOk(evaluateFormula("=1 + 1 == 2", c), 1);
|
|
141
|
+
expectOk(evaluateFormula("=2 * 3 > 5", c), 1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("unary minus", () => {
|
|
145
|
+
expectOk(evaluateFormula("=-5", c), -5);
|
|
146
|
+
expectOk(evaluateFormula("=10 + -3", c), 7);
|
|
147
|
+
expectOk(evaluateFormula("=-(2 + 3)", c), -5);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("division by zero", () => {
|
|
151
|
+
expectError(evaluateFormula("=10 / 0", c), "DIV_BY_ZERO");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// =============================================================================
|
|
156
|
+
// Column references
|
|
157
|
+
// =============================================================================
|
|
158
|
+
|
|
159
|
+
describe("column references", () => {
|
|
160
|
+
test("reference resolves to current row's value", () => {
|
|
161
|
+
const c = ctx(["price"], [["100"], ["200"]], 1);
|
|
162
|
+
expectOk(evaluateFormula("=price", c), 200);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("case-insensitive header match", () => {
|
|
166
|
+
const c = ctx(["Price"], [["100"]]);
|
|
167
|
+
expectOk(evaluateFormula("=price", c), 100);
|
|
168
|
+
expectOk(evaluateFormula("=PRICE", c), 100);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("non-numeric cell returns string", () => {
|
|
172
|
+
const c = ctx(["status"], [["active"]]);
|
|
173
|
+
expectOk(evaluateFormula("=status", c), "active");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("unknown column reports suggestion", () => {
|
|
177
|
+
const c = ctx(["price", "hours"], [["10", "5"]]);
|
|
178
|
+
const res = evaluateFormula("=prce", c);
|
|
179
|
+
expect(res.kind).toBe("error");
|
|
180
|
+
if (res.kind === "error") {
|
|
181
|
+
expect(res.code).toBe("UNKNOWN_COLUMN");
|
|
182
|
+
expect(res.suggestion).toBe("price");
|
|
183
|
+
expect(res.message).toContain("did you mean");
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("inline arithmetic with column refs", () => {
|
|
188
|
+
const c = ctx(["hours", "rate"], [["10", "50"]]);
|
|
189
|
+
expectOk(evaluateFormula("=hours * rate", c), 500);
|
|
190
|
+
expectOk(evaluateFormula("=price / 1.19", ctx(["price"], [["119"]])), 100);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// =============================================================================
|
|
195
|
+
// Column aggregates
|
|
196
|
+
// =============================================================================
|
|
197
|
+
|
|
198
|
+
describe("column aggregates", () => {
|
|
199
|
+
const c = ctx(
|
|
200
|
+
["price", "name"],
|
|
201
|
+
[
|
|
202
|
+
["10", "a"],
|
|
203
|
+
["20", "b"],
|
|
204
|
+
["30", "c"],
|
|
205
|
+
["", "d"],
|
|
206
|
+
["x", "e"],
|
|
207
|
+
],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
test("SUM skips empty + non-numeric", () => {
|
|
211
|
+
expectOk(evaluateFormula("=SUM(price)", c), 60);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("SUM skips the current formula cell when aggregating its own column", () => {
|
|
215
|
+
const ownColumn = ctx(["Hours"], [["10"], ["10"], ["=SUM(Hours)"]], 2, 0);
|
|
216
|
+
expectOk(evaluateFormula("=SUM(Hours)", ownColumn), 20);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("AVG counts only numeric cells", () => {
|
|
220
|
+
expectOk(evaluateFormula("=AVG(price)", c), 20);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("MEAN is alias for AVG", () => {
|
|
224
|
+
expectOk(evaluateFormula("=MEAN(price)", c), 20);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("MIN / MAX over numeric cells", () => {
|
|
228
|
+
expectOk(evaluateFormula("=MIN(price)", c), 10);
|
|
229
|
+
expectOk(evaluateFormula("=MAX(price)", c), 30);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("COUNT counts non-empty cells (incl non-numeric)", () => {
|
|
233
|
+
// 5 rows, one empty cell in price — counts the 4 non-empty (incl "x")
|
|
234
|
+
expectOk(evaluateFormula("=COUNT(price)", c), 4);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("COUNT skips the current formula cell when aggregating its own column", () => {
|
|
238
|
+
const ownColumn = ctx(["Name"], [["Ada"], ["Grace"], ["=COUNT(Name)"]], 2, 0);
|
|
239
|
+
expectOk(evaluateFormula("=COUNT(Name)", ownColumn), 2);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("aggregates on empty column return 0", () => {
|
|
243
|
+
const empty = ctx(["x"], []);
|
|
244
|
+
expectOk(evaluateFormula("=SUM(x)", empty), 0);
|
|
245
|
+
expectOk(evaluateFormula("=AVG(x)", empty), 0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("unknown column in aggregate", () => {
|
|
249
|
+
const res = evaluateFormula("=SUM(prce)", c);
|
|
250
|
+
expectError(res, "UNKNOWN_COLUMN");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("wrong arg count for aggregate", () => {
|
|
254
|
+
expectError(evaluateFormula("=SUM()", c), "WRONG_ARG_COUNT");
|
|
255
|
+
expectError(evaluateFormula("=SUM(price, name)", c), "WRONG_ARG_COUNT");
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("UNIQUE / COUNTIF / SUMIF / STDEV", () => {
|
|
260
|
+
test("UNIQUE counts distinct non-empty values", () => {
|
|
261
|
+
const c = ctx(["status"], [["done"], ["pending"], ["done"], [""], ["pending"], ["new"]]);
|
|
262
|
+
expectOk(evaluateFormula("=UNIQUE(status)", c), 3); // done, pending, new
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("UNIQUE skips the current formula cell when aggregating its own column", () => {
|
|
266
|
+
const c = ctx(["status"], [["done"], ["pending"], ["done"], ["=UNIQUE(status)"]], 3, 0);
|
|
267
|
+
expectOk(evaluateFormula("=UNIQUE(status)", c), 2);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("UNIQUE is case-sensitive", () => {
|
|
271
|
+
const c = ctx(["x"], [["A"], ["a"], ["A"]]);
|
|
272
|
+
expectOk(evaluateFormula("=UNIQUE(x)", c), 2);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("UNIQUE empty column returns 0", () => {
|
|
276
|
+
const c = ctx(["x"], []);
|
|
277
|
+
expectOk(evaluateFormula("=UNIQUE(x)", c), 0);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("UNIQUE arg count + unknown column", () => {
|
|
281
|
+
const c = ctx(["x"], [["1"]]);
|
|
282
|
+
expectError(evaluateFormula("=UNIQUE()", c), "WRONG_ARG_COUNT");
|
|
283
|
+
expectError(evaluateFormula("=UNIQUE(y)", c), "UNKNOWN_COLUMN");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("COUNTIF counts exact-string matches", () => {
|
|
287
|
+
const c = ctx(["status"], [["done"], ["pending"], ["done"], ["done"]]);
|
|
288
|
+
expectOk(evaluateFormula(`=COUNTIF(status, "done")`, c), 3);
|
|
289
|
+
expectOk(evaluateFormula(`=COUNTIF(status, "pending")`, c), 1);
|
|
290
|
+
expectOk(evaluateFormula(`=COUNTIF(status, "missing")`, c), 0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("COUNTIF skips the current formula cell when counting its own column", () => {
|
|
294
|
+
const c = ctx(["status"], [["done"], ["pending"], ["done"], [`=COUNTIF(status, "done")`]], 3, 0);
|
|
295
|
+
expectOk(evaluateFormula(`=COUNTIF(status, "done")`, c), 2);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("COUNTIF matches numbers as strings", () => {
|
|
299
|
+
const c = ctx(["price"], [["10"], ["20"], ["10"], ["30"]]);
|
|
300
|
+
expectOk(evaluateFormula("=COUNTIF(price, 10)", c), 2);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("COUNTIF arg count + unknown column", () => {
|
|
304
|
+
const c = ctx(["x"], [["1"]]);
|
|
305
|
+
expectError(evaluateFormula("=COUNTIF(x)", c), "WRONG_ARG_COUNT");
|
|
306
|
+
expectError(evaluateFormula(`=COUNTIF(y, "v")`, c), "UNKNOWN_COLUMN");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("SUMIF sums values where condition matches", () => {
|
|
310
|
+
const c = ctx(
|
|
311
|
+
["hours", "status"],
|
|
312
|
+
[
|
|
313
|
+
["8", "done"],
|
|
314
|
+
["4", "pending"],
|
|
315
|
+
["6", "done"],
|
|
316
|
+
["3", "done"],
|
|
317
|
+
["5", "pending"],
|
|
318
|
+
],
|
|
319
|
+
);
|
|
320
|
+
expectOk(evaluateFormula(`=SUMIF(hours, status, "done")`, c), 17); // 8 + 6 + 3
|
|
321
|
+
expectOk(evaluateFormula(`=SUMIF(hours, status, "pending")`, c), 9); // 4 + 5
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("SUMIF skips the current formula cell when summing its own column", () => {
|
|
325
|
+
const c = ctx(
|
|
326
|
+
["hours", "status"],
|
|
327
|
+
[
|
|
328
|
+
["10", "done"],
|
|
329
|
+
["10", "done"],
|
|
330
|
+
[`=SUMIF(hours, status, "done")`, "done"],
|
|
331
|
+
],
|
|
332
|
+
2,
|
|
333
|
+
0,
|
|
334
|
+
);
|
|
335
|
+
expectOk(evaluateFormula(`=SUMIF(hours, status, "done")`, c), 20);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("SUMIF skips non-numeric sum cells silently", () => {
|
|
339
|
+
const c = ctx(
|
|
340
|
+
["amount", "type"],
|
|
341
|
+
[
|
|
342
|
+
["10", "a"],
|
|
343
|
+
["x", "a"],
|
|
344
|
+
["20", "a"],
|
|
345
|
+
],
|
|
346
|
+
);
|
|
347
|
+
expectOk(evaluateFormula(`=SUMIF(amount, type, "a")`, c), 30);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("SUMIF no matches returns 0", () => {
|
|
351
|
+
const c = ctx(["x", "y"], [["1", "a"]]);
|
|
352
|
+
expectOk(evaluateFormula(`=SUMIF(x, y, "z")`, c), 0);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("SUMIF arg count + type errors", () => {
|
|
356
|
+
const c = ctx(["x", "y"], [["1", "a"]]);
|
|
357
|
+
expectError(evaluateFormula(`=SUMIF(x, y)`, c), "WRONG_ARG_COUNT");
|
|
358
|
+
expectError(evaluateFormula(`=SUMIF(1, y, "a")`, c), "TYPE_ERROR");
|
|
359
|
+
expectError(evaluateFormula(`=SUMIF(x, 1, "a")`, c), "TYPE_ERROR");
|
|
360
|
+
expectError(evaluateFormula(`=SUMIF(x, missing, "a")`, c), "UNKNOWN_COLUMN");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("STDEV computes sample standard deviation", () => {
|
|
364
|
+
const c = ctx(["n"], [["2"], ["4"], ["4"], ["4"], ["5"], ["5"], ["7"], ["9"]]);
|
|
365
|
+
// Sample stdev = sqrt(32/7) ≈ 2.138...
|
|
366
|
+
const r = evaluateFormula("=STDEV(n)", c);
|
|
367
|
+
expect(r.kind).toBe("ok");
|
|
368
|
+
if (r.kind === "ok") expect(r.value as number).toBeCloseTo(2.138, 2);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("STDEV returns 0 for fewer than 2 numbers", () => {
|
|
372
|
+
expectOk(evaluateFormula("=STDEV(x)", ctx(["x"], [])), 0);
|
|
373
|
+
expectOk(evaluateFormula("=STDEV(x)", ctx(["x"], [["5"]])), 0);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("STDEV arg count + unknown column", () => {
|
|
377
|
+
const c = ctx(["x"], [["1"]]);
|
|
378
|
+
expectError(evaluateFormula("=STDEV()", c), "WRONG_ARG_COUNT");
|
|
379
|
+
expectError(evaluateFormula("=STDEV(y)", c), "UNKNOWN_COLUMN");
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// =============================================================================
|
|
384
|
+
// Row aggregates
|
|
385
|
+
// =============================================================================
|
|
386
|
+
|
|
387
|
+
describe("row aggregates", () => {
|
|
388
|
+
test("ROWSUM sums numeric cells, excludes self column", () => {
|
|
389
|
+
const c = ctx(["a", "b", "c", "d"], [["10", "20", "30", "?"]], 0, 2);
|
|
390
|
+
// Excluding currentCol=2 ("30") and skipping non-numeric "?"
|
|
391
|
+
expectOk(evaluateFormula("=ROWSUM()", c), 30);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("ROWAVG averages numeric cells, excludes self", () => {
|
|
395
|
+
const c = ctx(["a", "b", "c"], [["10", "20", "30"]], 0, 2);
|
|
396
|
+
// Excluding "30", avg of [10, 20] = 15
|
|
397
|
+
expectOk(evaluateFormula("=ROWAVG()", c), 15);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("ROWMEAN is alias for ROWAVG", () => {
|
|
401
|
+
const c = ctx(["a", "b"], [["4", "6"]], 0, 0);
|
|
402
|
+
expectOk(evaluateFormula("=ROWMEAN()", c), 6);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("row aggregates take no arguments", () => {
|
|
406
|
+
const c = ctx(["a"], [["1"]]);
|
|
407
|
+
expectError(evaluateFormula("=ROWSUM(a)", c), "WRONG_ARG_COUNT");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("ROWAVG on row with no numeric cells returns 0", () => {
|
|
411
|
+
const c = ctx(["a", "b"], [["x", "y"]], 0, 0);
|
|
412
|
+
expectOk(evaluateFormula("=ROWAVG()", c), 0);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// =============================================================================
|
|
417
|
+
// Math functions
|
|
418
|
+
// =============================================================================
|
|
419
|
+
|
|
420
|
+
describe("ROUND + ABS", () => {
|
|
421
|
+
const c = ctx(["a"], [["1"]]);
|
|
422
|
+
|
|
423
|
+
test("ROUND with positive digits", () => {
|
|
424
|
+
expectOk(evaluateFormula("=ROUND(3.14159, 2)", c), 3.14);
|
|
425
|
+
expectOk(evaluateFormula("=ROUND(0.5, 0)", c), 1);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("ROUND with 0 digits", () => {
|
|
429
|
+
expectOk(evaluateFormula("=ROUND(2.7, 0)", c), 3);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("ABS handles negative + positive", () => {
|
|
433
|
+
expectOk(evaluateFormula("=ABS(-5)", c), 5);
|
|
434
|
+
expectOk(evaluateFormula("=ABS(5)", c), 5);
|
|
435
|
+
expectOk(evaluateFormula("=ABS(0)", c), 0);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("wrong arg count", () => {
|
|
439
|
+
expectError(evaluateFormula("=ROUND(1)", c), "WRONG_ARG_COUNT");
|
|
440
|
+
expectError(evaluateFormula("=ABS(1, 2)", c), "WRONG_ARG_COUNT");
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe("SQRT + POW + MOD", () => {
|
|
445
|
+
const c = ctx(["a"], [["1"]]);
|
|
446
|
+
|
|
447
|
+
test("SQRT computes square root", () => {
|
|
448
|
+
expectOk(evaluateFormula("=SQRT(9)", c), 3);
|
|
449
|
+
expectOk(evaluateFormula("=SQRT(2)", c), Math.SQRT2);
|
|
450
|
+
expectOk(evaluateFormula("=SQRT(0)", c), 0);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("SQRT rejects negative input", () => {
|
|
454
|
+
expectError(evaluateFormula("=SQRT(-1)", c), "NON_NUMERIC");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("SQRT wrong arg count", () => {
|
|
458
|
+
expectError(evaluateFormula("=SQRT()", c), "WRONG_ARG_COUNT");
|
|
459
|
+
expectError(evaluateFormula("=SQRT(1, 2)", c), "WRONG_ARG_COUNT");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("POW computes powers", () => {
|
|
463
|
+
expectOk(evaluateFormula("=POW(2, 10)", c), 1024);
|
|
464
|
+
expectOk(evaluateFormula("=POW(5, 0)", c), 1);
|
|
465
|
+
expectOk(evaluateFormula("=POW(4, 0.5)", c), 2);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("POW handles negative base", () => {
|
|
469
|
+
expectOk(evaluateFormula("=POW(-2, 3)", c), -8);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("POW wrong arg count", () => {
|
|
473
|
+
expectError(evaluateFormula("=POW(2)", c), "WRONG_ARG_COUNT");
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("MOD computes modulo", () => {
|
|
477
|
+
expectOk(evaluateFormula("=MOD(10, 3)", c), 1);
|
|
478
|
+
expectOk(evaluateFormula("=MOD(15, 5)", c), 0);
|
|
479
|
+
expectOk(evaluateFormula("=MOD(-7, 3)", c), -1); // JS modulo keeps sign of dividend
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("MOD rejects zero divisor", () => {
|
|
483
|
+
expectError(evaluateFormula("=MOD(10, 0)", c), "DIV_BY_ZERO");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("MOD wrong arg count", () => {
|
|
487
|
+
expectError(evaluateFormula("=MOD(10)", c), "WRONG_ARG_COUNT");
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe("AND / OR / NOT / CONTAINS", () => {
|
|
492
|
+
const c = ctx(["a"], [["1"]]);
|
|
493
|
+
|
|
494
|
+
test("AND returns 1 when all truthy, 0 otherwise", () => {
|
|
495
|
+
expectOk(evaluateFormula("=AND(1, 2, 3)", c), 1);
|
|
496
|
+
expectOk(evaluateFormula(`=AND("yes", 1)`, c), 1);
|
|
497
|
+
expectOk(evaluateFormula("=AND(1, 0, 3)", c), 0);
|
|
498
|
+
expectOk(evaluateFormula(`=AND("", 1)`, c), 0);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("AND short-circuits on first false (later errors not surfaced)", () => {
|
|
502
|
+
// The 1/0 short-circuit means the third arg is never evaluated;
|
|
503
|
+
// if it were, the unknown column would error.
|
|
504
|
+
expectOk(evaluateFormula("=AND(0, missingCol)", c), 0);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("AND requires at least one argument", () => {
|
|
508
|
+
expectError(evaluateFormula("=AND()", c), "WRONG_ARG_COUNT");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("OR returns 1 if any truthy, 0 if all falsy", () => {
|
|
512
|
+
expectOk(evaluateFormula("=OR(0, 0, 1)", c), 1);
|
|
513
|
+
expectOk(evaluateFormula("=OR(0, 0, 0)", c), 0);
|
|
514
|
+
expectOk(evaluateFormula(`=OR("", "")`, c), 0);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("OR short-circuits on first true", () => {
|
|
518
|
+
expectOk(evaluateFormula("=OR(1, missingCol)", c), 1);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("OR requires at least one argument", () => {
|
|
522
|
+
expectError(evaluateFormula("=OR()", c), "WRONG_ARG_COUNT");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("NOT inverts truthiness", () => {
|
|
526
|
+
expectOk(evaluateFormula("=NOT(1)", c), 0);
|
|
527
|
+
expectOk(evaluateFormula("=NOT(0)", c), 1);
|
|
528
|
+
expectOk(evaluateFormula(`=NOT("")`, c), 1);
|
|
529
|
+
expectOk(evaluateFormula(`=NOT("hi")`, c), 0);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("NOT requires exactly one argument", () => {
|
|
533
|
+
expectError(evaluateFormula("=NOT()", c), "WRONG_ARG_COUNT");
|
|
534
|
+
expectError(evaluateFormula("=NOT(1, 2)", c), "WRONG_ARG_COUNT");
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("CONTAINS does substring match", () => {
|
|
538
|
+
expectOk(evaluateFormula(`=CONTAINS("hello world", "world")`, c), 1);
|
|
539
|
+
expectOk(evaluateFormula(`=CONTAINS("hello world", "xyz")`, c), 0);
|
|
540
|
+
expectOk(evaluateFormula(`=CONTAINS("hello", "")`, c), 1); // empty needle always matches
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("CONTAINS coerces numbers to string", () => {
|
|
544
|
+
expectOk(evaluateFormula(`=CONTAINS("price: 42", 42)`, c), 1);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("CONTAINS requires two arguments", () => {
|
|
548
|
+
expectError(evaluateFormula(`=CONTAINS("hi")`, c), "WRONG_ARG_COUNT");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("AND/OR/NOT integrate with IF", () => {
|
|
552
|
+
const c2 = ctx(["x", "y"], [["10", "20"]]);
|
|
553
|
+
expectOk(evaluateFormula(`=IF(AND(x > 5, y < 30), "yes", "no")`, c2), "yes");
|
|
554
|
+
expectOk(evaluateFormula(`=IF(OR(x > 100, y > 100), "high", "low")`, c2), "low");
|
|
555
|
+
expectOk(evaluateFormula(`=IF(NOT(x == 0), "non-zero", "zero")`, c2), "non-zero");
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// =============================================================================
|
|
560
|
+
// Conditional functions
|
|
561
|
+
// =============================================================================
|
|
562
|
+
|
|
563
|
+
describe("IF / IFEMPTY / IFERROR", () => {
|
|
564
|
+
test("IF picks the true branch", () => {
|
|
565
|
+
const c = ctx(["price"], [["100"]]);
|
|
566
|
+
expectOk(evaluateFormula(`=IF(price > 50, "expensive", "cheap")`, c), "expensive");
|
|
567
|
+
expectOk(evaluateFormula(`=IF(price > 200, "expensive", "cheap")`, c), "cheap");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("IF: 0 is false, non-zero is true", () => {
|
|
571
|
+
const c = ctx(["a"], [["0"]]);
|
|
572
|
+
expectOk(evaluateFormula(`=IF(a, "yes", "no")`, c), "no");
|
|
573
|
+
expectOk(evaluateFormula(`=IF(1, "yes", "no")`, c), "yes");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("IF lazily evaluates only chosen branch", () => {
|
|
577
|
+
const c = ctx(["a"], [["10"]]);
|
|
578
|
+
// false branch divides by zero — must NOT evaluate when condition true
|
|
579
|
+
expectOk(evaluateFormula(`=IF(1, 42, 1 / 0)`, c), 42);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("IFEMPTY falls back when column cell is empty", () => {
|
|
583
|
+
const c = ctx(["notes"], [[""]]);
|
|
584
|
+
expectOk(evaluateFormula(`=IFEMPTY(notes, "(none)")`, c), "(none)");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("IFEMPTY passes value through when not empty", () => {
|
|
588
|
+
const c = ctx(["notes"], [["hello"]]);
|
|
589
|
+
expectOk(evaluateFormula(`=IFEMPTY(notes, "(none)")`, c), "hello");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test("IFERROR catches errors and returns fallback", () => {
|
|
593
|
+
const c = ctx(["a"], [["10"]]);
|
|
594
|
+
expectOk(evaluateFormula(`=IFERROR(10 / 0, "—")`, c), "—");
|
|
595
|
+
expectOk(evaluateFormula(`=IFERROR(SUM(missing), 0)`, c), 0);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("IFERROR passes through ok results", () => {
|
|
599
|
+
const c = ctx(["a"], [["10"]]);
|
|
600
|
+
expectOk(evaluateFormula(`=IFERROR(10 / 2, "—")`, c), 5);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// =============================================================================
|
|
605
|
+
// String functions
|
|
606
|
+
// =============================================================================
|
|
607
|
+
|
|
608
|
+
describe("string functions", () => {
|
|
609
|
+
const c = ctx(["name"], [["Hello"]]);
|
|
610
|
+
|
|
611
|
+
test("CONCAT joins values, coerces numbers to strings", () => {
|
|
612
|
+
expectOk(evaluateFormula(`=CONCAT("Hi ", name, "!")`, c), "Hi Hello!");
|
|
613
|
+
expectOk(evaluateFormula(`=CONCAT(1, "+", 2, "=", 3)`, c), "1+2=3");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("UPPER / LOWER", () => {
|
|
617
|
+
expectOk(evaluateFormula(`=UPPER(name)`, c), "HELLO");
|
|
618
|
+
expectOk(evaluateFormula(`=LOWER(name)`, c), "hello");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("LEN measures string length", () => {
|
|
622
|
+
expectOk(evaluateFormula(`=LEN(name)`, c), 5);
|
|
623
|
+
expectOk(evaluateFormula(`=LEN("")`, c), 0);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("SUBSTRING is 0-indexed", () => {
|
|
627
|
+
// "Hello", start=0, len=3 → "Hel"
|
|
628
|
+
expectOk(evaluateFormula(`=SUBSTRING(name, 0, 3)`, c), "Hel");
|
|
629
|
+
// "Hello", start=1, len=3 → "ell"
|
|
630
|
+
expectOk(evaluateFormula(`=SUBSTRING(name, 1, 3)`, c), "ell");
|
|
631
|
+
// out-of-range start clamps to 0
|
|
632
|
+
expectOk(evaluateFormula(`=SUBSTRING(name, -1, 2)`, c), "He");
|
|
633
|
+
// length past end returns what's available
|
|
634
|
+
expectOk(evaluateFormula(`=SUBSTRING(name, 2, 99)`, c), "llo");
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("string function arg count", () => {
|
|
638
|
+
expectError(evaluateFormula(`=UPPER()`, c), "WRONG_ARG_COUNT");
|
|
639
|
+
expectError(evaluateFormula(`=SUBSTRING("hi", 0)`, c), "WRONG_ARG_COUNT");
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
describe("TRIM / LEFT / RIGHT / REPLACE", () => {
|
|
644
|
+
const c = ctx(["a"], [["1"]]);
|
|
645
|
+
|
|
646
|
+
test("TRIM strips leading/trailing whitespace", () => {
|
|
647
|
+
// NOTE: the formula string-literal parser only supports `\"` and
|
|
648
|
+
// `\\` escapes (see lexer comment) — no `\t` / `\n`. So we test
|
|
649
|
+
// with plain spaces here. TRIM uses String.prototype.trim which
|
|
650
|
+
// strips all Unicode whitespace, so real tabs/newlines passing
|
|
651
|
+
// through (e.g. via column refs that contain them) would also be
|
|
652
|
+
// stripped at runtime.
|
|
653
|
+
expectOk(evaluateFormula(`=TRIM(" hi ")`, c), "hi");
|
|
654
|
+
expectOk(evaluateFormula(`=TRIM("hi")`, c), "hi");
|
|
655
|
+
expectOk(evaluateFormula(`=TRIM(" ")`, c), "");
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("TRIM wrong arg count", () => {
|
|
659
|
+
expectError(evaluateFormula("=TRIM()", c), "WRONG_ARG_COUNT");
|
|
660
|
+
expectError(evaluateFormula(`=TRIM("a", "b")`, c), "WRONG_ARG_COUNT");
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("LEFT takes first N chars", () => {
|
|
664
|
+
expectOk(evaluateFormula(`=LEFT("Hello", 3)`, c), "Hel");
|
|
665
|
+
expectOk(evaluateFormula(`=LEFT("Hi", 5)`, c), "Hi"); // n > length: full string
|
|
666
|
+
expectOk(evaluateFormula(`=LEFT("Hi", 0)`, c), "");
|
|
667
|
+
expectOk(evaluateFormula(`=LEFT("Hi", -1)`, c), ""); // negative clamps to 0
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test("LEFT rejects non-numeric n", () => {
|
|
671
|
+
expectError(evaluateFormula(`=LEFT("hi", "x")`, c), "NON_NUMERIC");
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("RIGHT takes last N chars", () => {
|
|
675
|
+
expectOk(evaluateFormula(`=RIGHT("Hello", 3)`, c), "llo");
|
|
676
|
+
expectOk(evaluateFormula(`=RIGHT("Hi", 5)`, c), "Hi");
|
|
677
|
+
expectOk(evaluateFormula(`=RIGHT("Hi", 0)`, c), "");
|
|
678
|
+
expectOk(evaluateFormula(`=RIGHT("Hi", -1)`, c), "");
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
test("LEFT/RIGHT arg count", () => {
|
|
682
|
+
expectError(evaluateFormula(`=LEFT("hi")`, c), "WRONG_ARG_COUNT");
|
|
683
|
+
expectError(evaluateFormula(`=RIGHT()`, c), "WRONG_ARG_COUNT");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("REPLACE substitutes all occurrences", () => {
|
|
687
|
+
expectOk(evaluateFormula(`=REPLACE("aaa", "a", "b")`, c), "bbb");
|
|
688
|
+
expectOk(evaluateFormula(`=REPLACE("hello world", "world", "there")`, c), "hello there");
|
|
689
|
+
expectOk(evaluateFormula(`=REPLACE("none here", "xyz", "abc")`, c), "none here");
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("REPLACE rejects empty search", () => {
|
|
693
|
+
expectError(evaluateFormula(`=REPLACE("hi", "", "x")`, c), "PARSE_ERROR");
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("REPLACE arg count", () => {
|
|
697
|
+
expectError(evaluateFormula(`=REPLACE("hi", "x")`, c), "WRONG_ARG_COUNT");
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
describe("NOW / TODAY / DATEDIFF", () => {
|
|
702
|
+
const c = ctx(["a"], [["1"]]);
|
|
703
|
+
|
|
704
|
+
test("NOW returns YYYY-MM-DD HH:MM:SS", () => {
|
|
705
|
+
const r = evaluateFormula("=NOW()", c);
|
|
706
|
+
expect(r.kind).toBe("ok");
|
|
707
|
+
if (r.kind === "ok") {
|
|
708
|
+
expect(typeof r.value).toBe("string");
|
|
709
|
+
expect(r.value as string).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("TODAY returns YYYY-MM-DD", () => {
|
|
714
|
+
const r = evaluateFormula("=TODAY()", c);
|
|
715
|
+
expect(r.kind).toBe("ok");
|
|
716
|
+
if (r.kind === "ok") {
|
|
717
|
+
expect(r.value as string).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("NOW / TODAY reject arguments", () => {
|
|
722
|
+
expectError(evaluateFormula(`=NOW("hi")`, c), "WRONG_ARG_COUNT");
|
|
723
|
+
expectError(evaluateFormula(`=TODAY(1)`, c), "WRONG_ARG_COUNT");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("DATEDIFF default unit is days", () => {
|
|
727
|
+
expectOk(evaluateFormula(`=DATEDIFF("2026-01-01", "2026-01-11")`, c), 10);
|
|
728
|
+
expectOk(evaluateFormula(`=DATEDIFF("2026-01-11", "2026-01-01")`, c), -10);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("DATEDIFF supports h / hours / m / minutes / s / seconds", () => {
|
|
732
|
+
expectOk(evaluateFormula(`=DATEDIFF("2026-01-01T00:00:00Z", "2026-01-01T03:00:00Z", "h")`, c), 3);
|
|
733
|
+
expectOk(evaluateFormula(`=DATEDIFF("2026-01-01T00:00:00Z", "2026-01-01T03:00:00Z", "hours")`, c), 3);
|
|
734
|
+
expectOk(evaluateFormula(`=DATEDIFF("2026-01-01T00:00:00Z", "2026-01-01T00:30:00Z", "m")`, c), 30);
|
|
735
|
+
expectOk(evaluateFormula(`=DATEDIFF("2026-01-01T00:00:00Z", "2026-01-01T00:00:45Z", "s")`, c), 45);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test("DATEDIFF returns 0 for same date", () => {
|
|
739
|
+
expectOk(evaluateFormula(`=DATEDIFF("2026-05-12", "2026-05-12")`, c), 0);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("DATEDIFF rejects invalid date input", () => {
|
|
743
|
+
expectError(evaluateFormula(`=DATEDIFF("not-a-date", "2026-01-01")`, c), "PARSE_ERROR");
|
|
744
|
+
expectError(evaluateFormula(`=DATEDIFF("2026-01-01", "garbage")`, c), "PARSE_ERROR");
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("DATEDIFF rejects unknown unit", () => {
|
|
748
|
+
expectError(evaluateFormula(`=DATEDIFF("2026-01-01", "2026-01-02", "weeks")`, c), "PARSE_ERROR");
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("DATEDIFF arg count", () => {
|
|
752
|
+
expectError(evaluateFormula(`=DATEDIFF("2026-01-01")`, c), "WRONG_ARG_COUNT");
|
|
753
|
+
expectError(evaluateFormula(`=DATEDIFF("2026-01-01", "2026-01-02", "d", "extra")`, c), "WRONG_ARG_COUNT");
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// =============================================================================
|
|
758
|
+
// Comparison + equality
|
|
759
|
+
// =============================================================================
|
|
760
|
+
|
|
761
|
+
describe("comparison operators", () => {
|
|
762
|
+
const c = ctx(["a", "b", "name"], [["10", "20", "alice"]]);
|
|
763
|
+
|
|
764
|
+
test("numeric comparisons return 1 / 0", () => {
|
|
765
|
+
expectOk(evaluateFormula("=a < b", c), 1);
|
|
766
|
+
expectOk(evaluateFormula("=a >= b", c), 0);
|
|
767
|
+
expectOk(evaluateFormula("=a == 10", c), 1);
|
|
768
|
+
expectOk(evaluateFormula("=a != 11", c), 1);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test("string equality", () => {
|
|
772
|
+
expectOk(evaluateFormula(`=name == "alice"`, c), 1);
|
|
773
|
+
expectOk(evaluateFormula(`=name == "bob"`, c), 0);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("equality works across coercions", () => {
|
|
777
|
+
// "10" (string) vs 10 (number) — both look numeric, compare as numbers
|
|
778
|
+
const n = ctx(["s"], [["10"]]);
|
|
779
|
+
expectOk(evaluateFormula(`=s == 10`, n), 1);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("non-numeric ordering errors", () => {
|
|
783
|
+
expectError(evaluateFormula(`=name < 10`, c), "NON_NUMERIC");
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// =============================================================================
|
|
788
|
+
// Unknown function — suggestion
|
|
789
|
+
// =============================================================================
|
|
790
|
+
|
|
791
|
+
describe("unknown function", () => {
|
|
792
|
+
test("UNKNOWN_FUNCTION with did-you-mean", () => {
|
|
793
|
+
const res = evaluateFormula("=SUMM(a)", ctx(["a"], [["1"]]));
|
|
794
|
+
expect(res.kind).toBe("error");
|
|
795
|
+
if (res.kind === "error") {
|
|
796
|
+
expect(res.code).toBe("UNKNOWN_FUNCTION");
|
|
797
|
+
expect(res.suggestion).toBe("SUM");
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("totally unknown function — no suggestion", () => {
|
|
802
|
+
const res = evaluateFormula("=ZZZBOOM(a)", ctx(["a"], [["1"]]));
|
|
803
|
+
expect(res.kind).toBe("error");
|
|
804
|
+
if (res.kind === "error") {
|
|
805
|
+
expect(res.code).toBe("UNKNOWN_FUNCTION");
|
|
806
|
+
expect(res.suggestion).toBeUndefined();
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// =============================================================================
|
|
812
|
+
// End-to-end realistic formulas
|
|
813
|
+
// =============================================================================
|
|
814
|
+
|
|
815
|
+
describe("realistic scenarios", () => {
|
|
816
|
+
test("invoice line: tax = price × 0.19", () => {
|
|
817
|
+
const c = ctx(["price"], [["100"], ["200"]], 1);
|
|
818
|
+
expectOk(evaluateFormula("=price * 0.19", c), 38);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test("invoice total: SUM(total)", () => {
|
|
822
|
+
const c = ctx(
|
|
823
|
+
["price", "total"],
|
|
824
|
+
[
|
|
825
|
+
["100", "119"],
|
|
826
|
+
["200", "238"],
|
|
827
|
+
],
|
|
828
|
+
);
|
|
829
|
+
expectOk(evaluateFormula("=SUM(total)", c), 357);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("conditional discount", () => {
|
|
833
|
+
const c = ctx(["qty", "price"], [["50", "10"]]);
|
|
834
|
+
expectOk(evaluateFormula(`=IF(qty > 10, price * qty * 0.9, price * qty)`, c), 450);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("display a status badge with fallback", () => {
|
|
838
|
+
const c = ctx(["status"], [[""]]);
|
|
839
|
+
expectOk(evaluateFormula(`=UPPER(IFEMPTY(status, "draft"))`, c), "DRAFT");
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("safe divide with IFERROR", () => {
|
|
843
|
+
const c = ctx(["total", "qty"], [["100", "0"]]);
|
|
844
|
+
expectOk(evaluateFormula(`=IFERROR(total / qty, "—")`, c), "—");
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("ROUND of an aggregate", () => {
|
|
848
|
+
const c = ctx(["x"], [["1.111"], ["2.222"], ["3.333"]]);
|
|
849
|
+
expectOk(evaluateFormula("=ROUND(AVG(x), 2)", c), 2.22);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("CONCAT with column refs + literal", () => {
|
|
853
|
+
const c = ctx(["first", "last"], [["Ada", "Lovelace"]]);
|
|
854
|
+
expectOk(evaluateFormula(`=CONCAT(first, " ", last)`, c), "Ada Lovelace");
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// =============================================================================
|
|
859
|
+
// Formula-in-formula resolution + cycle detection
|
|
860
|
+
// =============================================================================
|
|
861
|
+
|
|
862
|
+
describe("formula-in-formula resolution", () => {
|
|
863
|
+
test("col-ref to a formula cell evaluates the formula", () => {
|
|
864
|
+
// total = =hours * rate, then tax = total * 0.19
|
|
865
|
+
const c = ctx(["hours", "rate", "total"], [["10", "50", "=hours * rate"]], 0, 0);
|
|
866
|
+
// Reference "total" from a formula → recursive eval → 500
|
|
867
|
+
expectOk(evaluateFormula("=total * 0.19", c), 95);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test("aggregate over a column of formulas", () => {
|
|
871
|
+
// Each row's total is a formula; SUM(total) should compute each
|
|
872
|
+
const c = ctx(
|
|
873
|
+
["hours", "rate", "total"],
|
|
874
|
+
[
|
|
875
|
+
["10", "50", "=hours * rate"],
|
|
876
|
+
["20", "60", "=hours * rate"],
|
|
877
|
+
["5", "100", "=hours * rate"],
|
|
878
|
+
],
|
|
879
|
+
);
|
|
880
|
+
expectOk(evaluateFormula("=SUM(total)", c), 500 + 1200 + 500);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test("ROWSUM picks up formula cells in this row", () => {
|
|
884
|
+
// Row has [10, =a + 5, 20]; ROWSUM at col 0 → eval col 1 (15) + col 2 (20) = 35
|
|
885
|
+
const c = ctx(["a", "b", "c"], [["10", "=a + 5", "20"]], 0, 0);
|
|
886
|
+
expectOk(evaluateFormula("=ROWSUM()", c), 35);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("circular reference is detected", () => {
|
|
890
|
+
// a → b → a
|
|
891
|
+
const c = ctx(["a", "b"], [["=b", "=a"]], 0, 0);
|
|
892
|
+
expectError(evaluateFormula("=a", c), "CIRCULAR_REF");
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
test("self-referential formula errors", () => {
|
|
896
|
+
// a column references itself
|
|
897
|
+
const c = ctx(["a"], [["=a"]], 0, 0);
|
|
898
|
+
expectError(evaluateFormula("=a", c), "CIRCULAR_REF");
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test("aggregate skips circular cells silently", () => {
|
|
902
|
+
// Column has two valid + one circular; SUM should ignore the cycle
|
|
903
|
+
const c = ctx(
|
|
904
|
+
["a", "b"],
|
|
905
|
+
[
|
|
906
|
+
["10", "=a"],
|
|
907
|
+
["20", "=b"], // circular: b references b
|
|
908
|
+
["30", "=a + 5"],
|
|
909
|
+
],
|
|
910
|
+
);
|
|
911
|
+
// SUM(b): row0 b=a=10, row1 cycle skipped, row2 b=a+5=35. Total: 45.
|
|
912
|
+
expectOk(evaluateFormula("=SUM(b)", c), 45);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("non-numeric formula result is preserved through chaining", () => {
|
|
916
|
+
const c = ctx(["a", "b"], [["hello", `=UPPER(a)`]], 0, 0);
|
|
917
|
+
expectOk(evaluateFormula("=b", c), "HELLO");
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
// =============================================================================
|
|
922
|
+
// Backtick-quoted column names (for headers with spaces / special chars)
|
|
923
|
+
// =============================================================================
|
|
924
|
+
|
|
925
|
+
describe("backtick-quoted column references", () => {
|
|
926
|
+
test("references a column with spaces", () => {
|
|
927
|
+
const c = ctx(["Tax (19%)", "other"], [["100", "x"]], 0, 0);
|
|
928
|
+
expectOk(evaluateFormula("=`Tax (19%)`", c), 100);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("backtick ident in function arg", () => {
|
|
932
|
+
const c = ctx(["Total Cost"], [["100"], ["200"], ["300"]]);
|
|
933
|
+
expectOk(evaluateFormula("=SUM(`Total Cost`)", c), 600);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("backtick supports escapes for literal backtick", () => {
|
|
937
|
+
const c = ctx(["weird `name"], [["42"]], 0, 0);
|
|
938
|
+
expectOk(evaluateFormula("=`weird \\`name`", c), 42);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test("unterminated backtick is a parse error", () => {
|
|
942
|
+
const c = ctx(["a"], [["1"]]);
|
|
943
|
+
expectError(evaluateFormula("=`unterm", c), "PARSE_ERROR");
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("backtick + arithmetic", () => {
|
|
947
|
+
const c = ctx(["Hours per Day", "rate"], [["8", "50"]], 0, 0);
|
|
948
|
+
expectOk(evaluateFormula("=`Hours per Day` * rate", c), 400);
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// =============================================================================
|
|
953
|
+
// MEDIAN + PERCENT
|
|
954
|
+
// =============================================================================
|
|
955
|
+
|
|
956
|
+
describe("MEDIAN", () => {
|
|
957
|
+
test("median of odd-length numeric column", () => {
|
|
958
|
+
const c = ctx(["x"], [["1"], ["3"], ["2"], ["5"], ["4"]]);
|
|
959
|
+
expectOk(evaluateFormula("=MEDIAN(x)", c), 3);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
test("median of even-length numeric column", () => {
|
|
963
|
+
const c = ctx(["x"], [["1"], ["2"], ["3"], ["4"]]);
|
|
964
|
+
// (2 + 3) / 2 = 2.5
|
|
965
|
+
expectOk(evaluateFormula("=MEDIAN(x)", c), 2.5);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test("median ignores non-numeric cells", () => {
|
|
969
|
+
const c = ctx(["x"], [["1"], ["x"], ["3"], [""], ["5"]]);
|
|
970
|
+
// numeric: [1, 3, 5] → median 3
|
|
971
|
+
expectOk(evaluateFormula("=MEDIAN(x)", c), 3);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test("median of empty column returns 0", () => {
|
|
975
|
+
const c = ctx(["x"], []);
|
|
976
|
+
expectOk(evaluateFormula("=MEDIAN(x)", c), 0);
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
describe("PERCENT", () => {
|
|
981
|
+
test("PERCENT computes part / total × 100", () => {
|
|
982
|
+
const c = ctx(["a"], [["1"]]);
|
|
983
|
+
expectOk(evaluateFormula("=PERCENT(25, 200)", c), 12.5);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
test("PERCENT rounds to 2 decimals", () => {
|
|
987
|
+
const c = ctx(["a"], [["1"]]);
|
|
988
|
+
// 1/3 = 33.333... → 33.33
|
|
989
|
+
expectOk(evaluateFormula("=PERCENT(1, 3)", c), 33.33);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
test("PERCENT with column refs", () => {
|
|
993
|
+
const c = ctx(["price", "total"], [["50", "200"]]);
|
|
994
|
+
expectOk(evaluateFormula("=PERCENT(price, total)", c), 25);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
test("PERCENT division by zero", () => {
|
|
998
|
+
const c = ctx(["a"], [["1"]]);
|
|
999
|
+
expectError(evaluateFormula("=PERCENT(5, 0)", c), "DIV_BY_ZERO");
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test("PERCENT with non-numeric arg", () => {
|
|
1003
|
+
const c = ctx(["a"], [["1"]]);
|
|
1004
|
+
expectError(evaluateFormula(`=PERCENT("foo", 100)`, c), "NON_NUMERIC");
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
describe("PROGRESS", () => {
|
|
1009
|
+
test("PROGRESS renders a ratio", () => {
|
|
1010
|
+
const c = ctx(["a"], [["1"]]);
|
|
1011
|
+
expectProgress(evaluateFormula("=PROGRESS(0.4)", c), 0.4, "40%");
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("PROGRESS renders done / total", () => {
|
|
1015
|
+
const c = ctx(["done", "total"], [["2", "10"]]);
|
|
1016
|
+
expectProgress(evaluateFormula("=PROGRESS(done, total)", c), 0.2, "2/10");
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
test("PROGRESS clamps visual ratio", () => {
|
|
1020
|
+
const c = ctx(["a"], [["1"]]);
|
|
1021
|
+
expectProgress(evaluateFormula("=PROGRESS(2)", c), 1, "100%");
|
|
1022
|
+
expectProgress(evaluateFormula("=PROGRESS(-1)", c), 0, "0%");
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test("PROGRESS validates arguments", () => {
|
|
1026
|
+
const c = ctx(["a"], [["1"]]);
|
|
1027
|
+
expectError(evaluateFormula("=PROGRESS()", c), "WRONG_ARG_COUNT");
|
|
1028
|
+
expectError(evaluateFormula(`=PROGRESS("x")`, c), "NON_NUMERIC");
|
|
1029
|
+
expectError(evaluateFormula("=PROGRESS(1, 0)", c), "DIV_BY_ZERO");
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
describe("isTotalRow", () => {
|
|
1034
|
+
test("label + empty cells + single =SUM is a total row", () => {
|
|
1035
|
+
// User's exact scenario: only the formula counts toward the ratio.
|
|
1036
|
+
expect(isTotalRow(["Sum", "", "", "=SUM(price)"])).toBe(true);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
test("row of pure literals is not a total row", () => {
|
|
1040
|
+
expect(isTotalRow(["10", "20", "30"])).toBe(false);
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
test("row with single non-aggregate formula is not a total row", () => {
|
|
1044
|
+
expect(isTotalRow(["10", "20", "=hours * rate"])).toBe(false);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
test("row with mixed aggregate + arithmetic — ratio ≥ 0.5 → true", () => {
|
|
1048
|
+
expect(isTotalRow(["Total", "=SUM(qty)", "=SUM(price)", "=hours * rate"])).toBe(true);
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
test("row with mostly arithmetic formulas — ratio < 0.5 → false", () => {
|
|
1052
|
+
expect(isTotalRow(["=a+b", "=a*c", "=SUM(d)"])).toBe(false);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test("ROWSUM / ROWAVG count as aggregates", () => {
|
|
1056
|
+
expect(isTotalRow(["Row total", "=ROWSUM"])).toBe(true);
|
|
1057
|
+
expect(isTotalRow(["Row avg", "=ROWAVG"])).toBe(true);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
test("MEDIAN counts as aggregate", () => {
|
|
1061
|
+
expect(isTotalRow(["Median", "=MEDIAN(price)"])).toBe(true);
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test("aggregate function name is case-insensitive", () => {
|
|
1065
|
+
expect(isTotalRow(["Total", "=sum(price)"])).toBe(true);
|
|
1066
|
+
expect(isTotalRow(["Total", "=Avg(price)"])).toBe(true);
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
test("leading whitespace after `=` is tolerated", () => {
|
|
1070
|
+
// `slice(1).trim()` strips the space between `=` and the function name.
|
|
1071
|
+
expect(isTotalRow(["Total", "= SUM(price)"])).toBe(true);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
test("PERCENT is NOT considered an aggregate (it's pairwise math)", () => {
|
|
1075
|
+
expect(isTotalRow(["Tax %", "=PERCENT(tax, total)"])).toBe(false);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test("empty row is not a total row", () => {
|
|
1079
|
+
expect(isTotalRow([])).toBe(false);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
test("row of all-empty cells is not a total row", () => {
|
|
1083
|
+
expect(isTotalRow(["", "", ""])).toBe(false);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test("ratio threshold — exactly 0.5 → true", () => {
|
|
1087
|
+
expect(isTotalRow(["=SUM(a)", "=b+c"])).toBe(true);
|
|
1088
|
+
});
|
|
1089
|
+
});
|