@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.
Files changed (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /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
+ });