@dnax/ui 0.0.65 → 0.0.66
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/components/QSpreadsheet.vue +51 -6
- package/components/QTable.vue +20 -6
- package/lib/formula.test.ts +186 -1
- package/lib/formula.ts +442 -0
- package/lib/styleTokens.test.ts +15 -0
- package/package.json +1 -1
- package/styles/main.css +21 -7
|
@@ -1984,12 +1984,30 @@ const onFxKeydown = (e: KeyboardEvent) => {
|
|
|
1984
1984
|
|
|
1985
1985
|
// ─── Autocomplétion des fonctions (barre fx + éditeur formule) ───
|
|
1986
1986
|
const FORMULA_FNS: { name: string; sig: string }[] = [
|
|
1987
|
+
// Agrégats & critères
|
|
1987
1988
|
{ name: "SUM", sig: "a, b, …" },
|
|
1989
|
+
{ name: "SUMIF", sig: "range, criteria, [sum_range]" },
|
|
1990
|
+
{ name: "SUMIFS", sig: "sum_range, range1, crit1…" },
|
|
1988
1991
|
{ name: "AVERAGE", sig: "a, b, …" },
|
|
1989
|
-
{ name: "
|
|
1990
|
-
{ name: "
|
|
1992
|
+
{ name: "AVERAGEIF", sig: "range, criteria, [avg_range]" },
|
|
1993
|
+
{ name: "AVERAGEIFS", sig: "avg_range, range1, crit1…" },
|
|
1991
1994
|
{ name: "COUNT", sig: "a, b, …" },
|
|
1992
1995
|
{ name: "COUNTA", sig: "a, b, …" },
|
|
1996
|
+
{ name: "COUNTIF", sig: "range, criteria" },
|
|
1997
|
+
{ name: "COUNTIFS", sig: "range1, crit1…" },
|
|
1998
|
+
{ name: "SUMPRODUCT", sig: "range1, range2…" },
|
|
1999
|
+
{ name: "MIN", sig: "a, b, …" },
|
|
2000
|
+
{ name: "MAX", sig: "a, b, …" },
|
|
2001
|
+
{ name: "MEDIAN", sig: "a, b, …" },
|
|
2002
|
+
// Statistiques
|
|
2003
|
+
{ name: "LARGE", sig: "range, k" },
|
|
2004
|
+
{ name: "SMALL", sig: "range, k" },
|
|
2005
|
+
{ name: "RANK", sig: "value, range, [order]" },
|
|
2006
|
+
{ name: "STDEV", sig: "a, b, …" },
|
|
2007
|
+
{ name: "STDEVP", sig: "a, b, …" },
|
|
2008
|
+
{ name: "VAR", sig: "a, b, …" },
|
|
2009
|
+
{ name: "VARP", sig: "a, b, …" },
|
|
2010
|
+
// Logique
|
|
1993
2011
|
{ name: "IF", sig: "cond, yes, no" },
|
|
1994
2012
|
{ name: "IFS", sig: "cond1, v1, cond2, v2…" },
|
|
1995
2013
|
{ name: "IFERROR", sig: "value, fallback" },
|
|
@@ -1998,7 +2016,22 @@ const FORMULA_FNS: { name: string; sig: string }[] = [
|
|
|
1998
2016
|
{ name: "AND", sig: "a, b…" },
|
|
1999
2017
|
{ name: "OR", sig: "a, b…" },
|
|
2000
2018
|
{ name: "NOT", sig: "v" },
|
|
2001
|
-
{ name: "
|
|
2019
|
+
{ name: "TRUE", sig: "" },
|
|
2020
|
+
{ name: "FALSE", sig: "" },
|
|
2021
|
+
// Info / erreurs
|
|
2022
|
+
{ name: "ISBLANK", sig: "v" },
|
|
2023
|
+
{ name: "ISNUMBER", sig: "v" },
|
|
2024
|
+
{ name: "ISTEXT", sig: "v" },
|
|
2025
|
+
{ name: "ISERROR", sig: "v" },
|
|
2026
|
+
{ name: "ISERR", sig: "v" },
|
|
2027
|
+
{ name: "ISNA", sig: "v" },
|
|
2028
|
+
{ name: "NA", sig: "" },
|
|
2029
|
+
// Recherche
|
|
2030
|
+
{ name: "VLOOKUP", sig: "key, range, col" },
|
|
2031
|
+
{ name: "XLOOKUP", sig: "key, table, return_range, [not_found]" },
|
|
2032
|
+
{ name: "INDEX", sig: "range, row, [col]" },
|
|
2033
|
+
{ name: "MATCH", sig: "key, range, [type]" },
|
|
2034
|
+
// Maths
|
|
2002
2035
|
{ name: "ABS", sig: "n" },
|
|
2003
2036
|
{ name: "ROUND", sig: "n, [digits]" },
|
|
2004
2037
|
{ name: "ROUNDUP", sig: "n, [digits]" },
|
|
@@ -2011,23 +2044,26 @@ const FORMULA_FNS: { name: string; sig: string }[] = [
|
|
|
2011
2044
|
{ name: "POWER", sig: "n, exp" },
|
|
2012
2045
|
{ name: "PI", sig: "" },
|
|
2013
2046
|
{ name: "RAND", sig: "" },
|
|
2047
|
+
// Texte
|
|
2014
2048
|
{ name: "CONCAT", sig: "a, b…" },
|
|
2015
2049
|
{ name: "CONCATENATE", sig: "a, b…" },
|
|
2050
|
+
{ name: "TEXTJOIN", sig: "delim, ignore_empty, a, b…" },
|
|
2016
2051
|
{ name: "UPPER", sig: "text" },
|
|
2017
2052
|
{ name: "LOWER", sig: "text" },
|
|
2053
|
+
{ name: "PROPER", sig: "text" },
|
|
2018
2054
|
{ name: "TRIM", sig: "text" },
|
|
2019
2055
|
{ name: "LEN", sig: "text" },
|
|
2020
2056
|
{ name: "LEFT", sig: "text, [n]" },
|
|
2021
2057
|
{ name: "RIGHT", sig: "text, [n]" },
|
|
2022
2058
|
{ name: "MID", sig: "text, start, n" },
|
|
2059
|
+
{ name: "SEARCH", sig: "find, text, [start]" },
|
|
2023
2060
|
{ name: "FIND", sig: "find, text, [start]" },
|
|
2024
2061
|
{ name: "SUBSTITUTE", sig: "text, old, new, [n]" },
|
|
2025
2062
|
{ name: "REPLACE", sig: "text, start, n, new" },
|
|
2063
|
+
{ name: "EXACT", sig: "a, b" },
|
|
2026
2064
|
{ name: "VALUE", sig: "text" },
|
|
2027
2065
|
{ name: "N", sig: "v" },
|
|
2028
|
-
|
|
2029
|
-
{ name: "ISNUMBER", sig: "v" },
|
|
2030
|
-
{ name: "ISTEXT", sig: "v" },
|
|
2066
|
+
// Dates
|
|
2031
2067
|
{ name: "TODAY", sig: "" },
|
|
2032
2068
|
{ name: "NOW", sig: "" },
|
|
2033
2069
|
{ name: "DATE", sig: "y, m, d" },
|
|
@@ -2035,6 +2071,15 @@ const FORMULA_FNS: { name: string; sig: string }[] = [
|
|
|
2035
2071
|
{ name: "MONTH", sig: "date" },
|
|
2036
2072
|
{ name: "DAY", sig: "date" },
|
|
2037
2073
|
{ name: "EDATE", sig: "date, months" },
|
|
2074
|
+
{ name: "EOMONTH", sig: "date, months" },
|
|
2075
|
+
{ name: "DATEDIF", sig: "start, end, unit" },
|
|
2076
|
+
{ name: "DAYS", sig: "end, start" },
|
|
2077
|
+
{ name: "WEEKDAY", sig: "date, [type]" },
|
|
2078
|
+
{ name: "WEEKNUM", sig: "date, [type]" },
|
|
2079
|
+
{ name: "ISOWEEKNUM", sig: "date" },
|
|
2080
|
+
{ name: "WORKDAY", sig: "start, days, [holidays]" },
|
|
2081
|
+
{ name: "NETWORKDAYS", sig: "start, end, [holidays]" },
|
|
2082
|
+
{ name: "TEXT", sig: "value, format" },
|
|
2038
2083
|
]
|
|
2039
2084
|
// La source alimente des méthodes de chaîne (`.trim()`, `.startsWith()`) :
|
|
2040
2085
|
// la valeur brute d'une cellule peut être un nombre / booléen → on coerce.
|
package/components/QTable.vue
CHANGED
|
@@ -35,6 +35,7 @@ import { icons } from "../lib/icons"
|
|
|
35
35
|
import { cn } from "../lib/utils"
|
|
36
36
|
import { radiusStyle, useRadius } from "../lib/useComponentProps"
|
|
37
37
|
import type { RadiusProp } from "../lib/useComponentProps"
|
|
38
|
+
import QSelect from "./QSelect.vue"
|
|
38
39
|
|
|
39
40
|
interface Props {
|
|
40
41
|
/** Lignes de données */
|
|
@@ -164,6 +165,13 @@ const rowsPerPage = computed({
|
|
|
164
165
|
get: () => pagination.value.rowsPerPage,
|
|
165
166
|
set: (v) => setPagination({ page: 1, rowsPerPage: v }),
|
|
166
167
|
})
|
|
168
|
+
|
|
169
|
+
/** Libellé d'une option « lignes par page » (0 = tout afficher).
|
|
170
|
+
* Reçoit l'option normalisée par QSelect (`{ value, label }`) ou la valeur brute. */
|
|
171
|
+
const rowsPerPageOptionLabel = (opt: any): string => {
|
|
172
|
+
const value = opt !== null && typeof opt === "object" ? opt.value : opt
|
|
173
|
+
return value === 0 ? "All" : String(value)
|
|
174
|
+
}
|
|
167
175
|
const sortBy = computed(() => pagination.value.sortBy)
|
|
168
176
|
const descending = computed(() => pagination.value.descending)
|
|
169
177
|
|
|
@@ -729,12 +737,18 @@ const virtual = computed(() => {
|
|
|
729
737
|
<slot name="bottom" />
|
|
730
738
|
<div class="q-table__pagination">
|
|
731
739
|
<span class="q-table__range">{{ rangeLabel }}</span>
|
|
732
|
-
<
|
|
733
|
-
Rows per page
|
|
734
|
-
<select
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
740
|
+
<div class="q-table__rows-select">
|
|
741
|
+
<span>Rows per page</span>
|
|
742
|
+
<q-select
|
|
743
|
+
v-model="rowsPerPage"
|
|
744
|
+
class="q-table__rows-field"
|
|
745
|
+
:options="rowsPerPageOptions"
|
|
746
|
+
:option-label="rowsPerPageOptionLabel"
|
|
747
|
+
emit-value
|
|
748
|
+
dense
|
|
749
|
+
aria-label="Rows per page"
|
|
750
|
+
/>
|
|
751
|
+
</div>
|
|
738
752
|
<div class="q-table__page-nav">
|
|
739
753
|
<button
|
|
740
754
|
type="button"
|
package/lib/formula.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tests unitaires du moteur de formules A1 (bun test).
|
|
2
2
|
import { describe, expect, it } from "bun:test"
|
|
3
|
-
import { evaluateFormula, isError } from "./formula"
|
|
3
|
+
import { criteriaMatcher, evaluateFormula, FORMULA_FUNCTION_NAMES, isError } from "./formula"
|
|
4
4
|
import type { FormulaValue } from "./formula"
|
|
5
5
|
|
|
6
6
|
type Grid = (string | number | boolean | null)[][]
|
|
@@ -204,3 +204,188 @@ describe("math & erreurs de type", () => {
|
|
|
204
204
|
expect(isError(e("=1/0"))).toBe(true)
|
|
205
205
|
})
|
|
206
206
|
})
|
|
207
|
+
|
|
208
|
+
// ─── Extension 2026-10-17 : critères, stats, recherche, dates, texte ───
|
|
209
|
+
|
|
210
|
+
describe("critères (SUMIF / COUNTIF / *IFS)", () => {
|
|
211
|
+
const grid: Grid = [
|
|
212
|
+
["A", "x", 10],
|
|
213
|
+
["B", "y", 20],
|
|
214
|
+
["C", "x", 30],
|
|
215
|
+
[null, null, null],
|
|
216
|
+
["D", "y", 40],
|
|
217
|
+
]
|
|
218
|
+
const e = harness(grid)
|
|
219
|
+
|
|
220
|
+
it("SUMIF : critère texte, comparaison numérique, plage de somme optionnelle", () => {
|
|
221
|
+
expect(e('=SUMIF(B1:B5,"x",C1:C5)')).toBe(40)
|
|
222
|
+
expect(e('=SUMIF(B1:B5,"y",C1:C5)')).toBe(60)
|
|
223
|
+
expect(e('=SUMIF(C1:C5,">15")')).toBe(90)
|
|
224
|
+
expect(e('=SUMIF(C1:C5,"<>")')).toBe(100)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("COUNTIF : égalité (insensible à la casse), vides, jokers", () => {
|
|
228
|
+
expect(e('=COUNTIF(A1:A5,"a")')).toBe(1)
|
|
229
|
+
expect(e('=COUNTIF(A1:A5,"?")')).toBe(4)
|
|
230
|
+
expect(e('=COUNTIF(A1:A5,"")')).toBe(1)
|
|
231
|
+
expect(e('=COUNTIF(A1:A5,"<>")')).toBe(4)
|
|
232
|
+
expect(e('=COUNTIF(A1:A5,"a*")')).toBe(1)
|
|
233
|
+
expect(e('=COUNTIF(A1:A5,">b")')).toBe(2)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it("AVERAGEIF ignore les non-nombres ; #DIV/0! sans correspondance", () => {
|
|
237
|
+
expect(e('=AVERAGEIF(B1:B5,"y",C1:C5)')).toBe(30)
|
|
238
|
+
expect(String(e('=AVERAGEIF(B1:B5,"z",C1:C5)'))).toBe("#DIV/0!")
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("COUNTIFS / SUMIFS / AVERAGEIFS croisent plusieurs critères", () => {
|
|
242
|
+
expect(e('=COUNTIFS(B1:B5,"x",C1:C5,">15")')).toBe(1)
|
|
243
|
+
expect(e('=SUMIFS(C1:C5,B1:B5,"x",C1:C5,">15")')).toBe(30)
|
|
244
|
+
expect(e('=SUMIFS(C1:C5,B1:B5,"x")')).toBe(40)
|
|
245
|
+
expect(e('=AVERAGEIFS(C1:C5,B1:B5,"y")')).toBe(30)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it("SUMPRODUCT multiplie les plages terme à terme", () => {
|
|
249
|
+
expect(e("=SUMPRODUCT(C1:C3,C1:C3)")).toBe(1400)
|
|
250
|
+
expect(e("=SUMPRODUCT(C1:C2)")).toBe(30)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe("criteriaMatcher (unitaire)", () => {
|
|
255
|
+
it("compare nombres, textes, vides et jokers", () => {
|
|
256
|
+
expect(criteriaMatcher(">10")(15)).toBe(true)
|
|
257
|
+
expect(criteriaMatcher(">10")("abc")).toBe(false)
|
|
258
|
+
expect(criteriaMatcher("<=5")(5)).toBe(true)
|
|
259
|
+
expect(criteriaMatcher(0)("0")).toBe(true)
|
|
260
|
+
expect(criteriaMatcher("paris")("PARIS")).toBe(true)
|
|
261
|
+
expect(criteriaMatcher("paris")("paris ")).toBe(false)
|
|
262
|
+
expect(criteriaMatcher("par*")("paris")).toBe(true)
|
|
263
|
+
expect(criteriaMatcher("<>")(null)).toBe(false)
|
|
264
|
+
expect(criteriaMatcher("")("")).toBe(true)
|
|
265
|
+
expect(criteriaMatcher("<m")("apple")).toBe(true)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
describe("statistiques", () => {
|
|
270
|
+
const grid: Grid = [
|
|
271
|
+
[10],
|
|
272
|
+
[20],
|
|
273
|
+
[30],
|
|
274
|
+
[40],
|
|
275
|
+
]
|
|
276
|
+
const e = harness(grid)
|
|
277
|
+
|
|
278
|
+
it("MEDIAN / LARGE / SMALL / RANK", () => {
|
|
279
|
+
expect(e("=MEDIAN(A1:A4)")).toBe(25)
|
|
280
|
+
expect(e("=LARGE(A1:A4,2)")).toBe(30)
|
|
281
|
+
expect(e("=SMALL(A1:A4,2)")).toBe(20)
|
|
282
|
+
expect(e("=RANK(20,A1:A4)")).toBe(3)
|
|
283
|
+
expect(e("=RANK(20,A1:A4,1)")).toBe(2)
|
|
284
|
+
expect(String(e("=LARGE(A1:A4,9)"))).toBe("#NUM!")
|
|
285
|
+
expect(String(e("=RANK(99,A1:A4)"))).toBe("#N/A")
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("STDEV / STDEVP / VAR / VARP", () => {
|
|
289
|
+
expect(Number(e("=STDEV(A1:A4)"))).toBeCloseTo(12.9099, 3)
|
|
290
|
+
expect(Number(e("=STDEVP(A1:A4)"))).toBeCloseTo(11.1803, 3)
|
|
291
|
+
expect(Number(e("=VAR(A1:A4)"))).toBeCloseTo(166.6667, 3)
|
|
292
|
+
expect(e("=VARP(A1:A4)")).toBe(125)
|
|
293
|
+
expect(String(e("=STDEV(A1)"))).toBe("#DIV/0!")
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe("recherche INDEX / MATCH", () => {
|
|
298
|
+
const grid: Grid = [
|
|
299
|
+
["A", "x", 10],
|
|
300
|
+
["B", "y", 20],
|
|
301
|
+
["C", "x", 30],
|
|
302
|
+
]
|
|
303
|
+
const e = harness(grid)
|
|
304
|
+
|
|
305
|
+
it("INDEX sur une plage (ligne/colonne, index unique)", () => {
|
|
306
|
+
expect(e("=INDEX(C1:C3,2)")).toBe(20)
|
|
307
|
+
expect(e("=INDEX(A1:C1,2)")).toBe("x")
|
|
308
|
+
expect(e("=INDEX(A1:C3,3,3)")).toBe(30)
|
|
309
|
+
expect(String(e("=INDEX(A1:C3,9,1)"))).toBe("#REF!")
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it("MATCH exact et approché", () => {
|
|
313
|
+
expect(e('=MATCH("x",B1:B3,0)')).toBe(1)
|
|
314
|
+
expect(e("=MATCH(30,C1:C3,0)")).toBe(3)
|
|
315
|
+
expect(e("=MATCH(25,C1:C3,1)")).toBe(2)
|
|
316
|
+
expect(String(e("=MATCH(99,C1:C3,0)"))).toBe("#N/A")
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
describe("littéraux TRUE / FALSE", () => {
|
|
321
|
+
const e = harness([[]])
|
|
322
|
+
it("valeurs booléennes en littéral et en fonction", () => {
|
|
323
|
+
expect(e("=IF(TRUE,1,2)")).toBe(1)
|
|
324
|
+
expect(e("=IF(FALSE,1,2)")).toBe(2)
|
|
325
|
+
expect(e("=TRUE()")).toBe(true)
|
|
326
|
+
expect(e("=FALSE()")).toBe(false)
|
|
327
|
+
expect(e('=IFS(0,1,TRUE,"x")')).toBe("x")
|
|
328
|
+
})
|
|
329
|
+
it("un identifiant plus long reste une référence", () => {
|
|
330
|
+
expect(String(e("=TRUE1"))).toBe("#NAME?")
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
describe("erreurs (ISERROR / ISERR / ISNA / NA)", () => {
|
|
335
|
+
const e = harness([[]])
|
|
336
|
+
it("distingue erreur générique, #N/A et absence d'erreur", () => {
|
|
337
|
+
expect(e("=ISERROR(1/0)")).toBe(true)
|
|
338
|
+
expect(e("=ISERROR(1)")).toBe(false)
|
|
339
|
+
expect(e("=ISERR(1/0)")).toBe(true)
|
|
340
|
+
expect(e("=ISERR(NA())")).toBe(false)
|
|
341
|
+
expect(e("=ISNA(NA())")).toBe(true)
|
|
342
|
+
expect(e("=ISNA(1/0)")).toBe(false)
|
|
343
|
+
expect(String(e("=NA()"))).toBe("#N/A")
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe("texte étendu", () => {
|
|
348
|
+
const e = harness([[]])
|
|
349
|
+
it("TEXTJOIN / PROPER / SEARCH / EXACT", () => {
|
|
350
|
+
expect(e('=TEXTJOIN("-",1,"a","","b")')).toBe("a-b")
|
|
351
|
+
expect(e('=TEXTJOIN("-",0,"a","","b")')).toBe("a--b")
|
|
352
|
+
expect(e('=PROPER("hello world")')).toBe("Hello World")
|
|
353
|
+
expect(e('=SEARCH("B","abc")')).toBe(2)
|
|
354
|
+
expect(String(e('=SEARCH("z","abc")'))).toBe("#VALUE!")
|
|
355
|
+
expect(e('=EXACT("a","A")')).toBe(false)
|
|
356
|
+
expect(e('=EXACT("a","a")')).toBe(true)
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
describe("dates étendues", () => {
|
|
361
|
+
const grid: Grid = [
|
|
362
|
+
["2026-10-05"],
|
|
363
|
+
["2026-10-06"],
|
|
364
|
+
]
|
|
365
|
+
const e = harness(grid)
|
|
366
|
+
it("DAYS / WEEKNUM / ISOWEEKNUM", () => {
|
|
367
|
+
expect(e('=DAYS("2026-10-10","2026-10-01")')).toBe(9)
|
|
368
|
+
expect(e('=WEEKNUM("2026-01-05",2)')).toBe(2)
|
|
369
|
+
expect(e('=ISOWEEKNUM("2026-01-05")')).toBe(2)
|
|
370
|
+
expect(e('=WEEKDAY("2026-10-05",2)')).toBe(1)
|
|
371
|
+
})
|
|
372
|
+
it("WORKDAY / NETWORKDAYS (avec et sans fériés)", () => {
|
|
373
|
+
expect(e('=WORKDAY("2026-10-16",1)')).toBe("2026-10-19")
|
|
374
|
+
expect(e('=NETWORKDAYS("2026-10-01","2026-10-07")')).toBe(5)
|
|
375
|
+
expect(e('=NETWORKDAYS("2026-10-01","2026-10-07",A1:A2)')).toBe(3)
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
describe("parité moteur ↔ autocomplétion", () => {
|
|
380
|
+
it("aucune fonction intégrée n'est absente de FORMULA_FNS", async () => {
|
|
381
|
+
const vue = await Bun.file(new URL("../components/QSpreadsheet.vue", import.meta.url)).text()
|
|
382
|
+
const start = vue.indexOf("const FORMULA_FNS")
|
|
383
|
+
const end = vue.indexOf("const fxSource")
|
|
384
|
+
const block = vue.slice(start, end)
|
|
385
|
+
const suggested = new Set([...block.matchAll(/name: "([A-Z]+)"/g)].map((m) => m[1]!))
|
|
386
|
+
// Alias internes volontairement absents des suggestions.
|
|
387
|
+
const aliases = new Set(["AVG", "CEIL", "POW"])
|
|
388
|
+
const missing = FORMULA_FUNCTION_NAMES.filter((n) => !aliases.has(n) && !suggested.has(n))
|
|
389
|
+
expect(missing).toEqual([])
|
|
390
|
+
})
|
|
391
|
+
})
|
package/lib/formula.ts
CHANGED
|
@@ -45,6 +45,7 @@ interface RefToken {
|
|
|
45
45
|
type Node =
|
|
46
46
|
| { kind: "num"; value: number }
|
|
47
47
|
| { kind: "str"; value: string }
|
|
48
|
+
| { kind: "bool"; value: boolean }
|
|
48
49
|
| { kind: "ref"; ref: RefToken }
|
|
49
50
|
| { kind: "range"; start: RefToken; end: RefToken }
|
|
50
51
|
| { kind: "call"; name: string; args: Node[] }
|
|
@@ -242,6 +243,13 @@ class Parser {
|
|
|
242
243
|
let digits = ""
|
|
243
244
|
while (!this.eof() && /[0-9]/.test(this.peek()!)) digits += this.next()
|
|
244
245
|
|
|
246
|
+
// Littéraux TRUE / FALSE (Excel) — ne s'appliquent pas à une référence type TRUE1.
|
|
247
|
+
const keyword = letters.toUpperCase()
|
|
248
|
+
if (!colAbs && !rowAbs && digits.length === 0 && (keyword === "TRUE" || keyword === "FALSE")) {
|
|
249
|
+
this.skipWs()
|
|
250
|
+
if (this.peek() !== ":") return { kind: "bool", value: keyword === "TRUE" }
|
|
251
|
+
}
|
|
252
|
+
|
|
245
253
|
let token: RefToken
|
|
246
254
|
if (digits.length > 0 && letters.length <= 3) {
|
|
247
255
|
token = {
|
|
@@ -327,6 +335,77 @@ const truthy = (v: FormulaValue): boolean => {
|
|
|
327
335
|
return v !== ""
|
|
328
336
|
}
|
|
329
337
|
|
|
338
|
+
/** Cellule « vide » au sens des critères (`null`, `undefined` ou chaîne vide). */
|
|
339
|
+
const isBlankValue = (v: FormulaValue): boolean => v === null || v === undefined || v === ""
|
|
340
|
+
|
|
341
|
+
/** Motif texte → expression régulière : `*` = tout, `?` = un caractère, insensible à la casse. */
|
|
342
|
+
const wildcardRegExp = (pattern: string): RegExp => {
|
|
343
|
+
let out = ""
|
|
344
|
+
for (const ch of pattern) {
|
|
345
|
+
if (ch === "*") out += ".*"
|
|
346
|
+
else if (ch === "?") out += "."
|
|
347
|
+
else out += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
348
|
+
}
|
|
349
|
+
return new RegExp("^" + out + "$", "i")
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Transforme un critère de `SUMIF` / `COUNTIF` (…) en prédicat.
|
|
354
|
+
*
|
|
355
|
+
* - `10` / `"10"` → égalité numérique ;
|
|
356
|
+
* - `">10"`, `"<=5"`, `"<>abc"`, `"<m"` → comparaison (numérique ou texte) ;
|
|
357
|
+
* - `""` / `"<>"` → cellules **vides** / **non vides** ;
|
|
358
|
+
* - `*` et `?` → jokers sur le texte, insensible à la casse.
|
|
359
|
+
*/
|
|
360
|
+
export function criteriaMatcher(criteria: FormulaValue): (v: FormulaValue) => boolean {
|
|
361
|
+
if (isError(criteria)) return () => false
|
|
362
|
+
if (criteria === null || criteria === undefined) return isBlankValue
|
|
363
|
+
if (typeof criteria === "number" || typeof criteria === "boolean") {
|
|
364
|
+
const target = typeof criteria === "boolean" ? (criteria ? 1 : 0) : criteria
|
|
365
|
+
return (v) => {
|
|
366
|
+
if (isBlankValue(v)) return false
|
|
367
|
+
const n = typeof v === "number" ? v : Number(v)
|
|
368
|
+
return !Number.isNaN(n) && n === target
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const m = /^(>=|<=|<>|>|<|=)?([\s\S]*)$/.exec(String(criteria))!
|
|
373
|
+
const op = m[1] ?? "="
|
|
374
|
+
const raw = (m[2] ?? "").trim()
|
|
375
|
+
|
|
376
|
+
// Critères de vacuité : "" (vides) / "<>" (non vides)
|
|
377
|
+
if (raw === "" && (op === "=" || op === "<>")) {
|
|
378
|
+
return op === "=" ? isBlankValue : (v) => !isBlankValue(v)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const numeric = raw !== "" && !Number.isNaN(Number(raw))
|
|
382
|
+
const equals = (v: FormulaValue): boolean => {
|
|
383
|
+
if (isBlankValue(v)) return false
|
|
384
|
+
if (numeric) {
|
|
385
|
+
const n = typeof v === "number" ? v : Number(v)
|
|
386
|
+
return !Number.isNaN(n) && n === Number(raw)
|
|
387
|
+
}
|
|
388
|
+
return wildcardRegExp(raw).test(toStr(v))
|
|
389
|
+
}
|
|
390
|
+
if (op === "=" || op === "<>") return op === "=" ? equals : (v) => !equals(v)
|
|
391
|
+
|
|
392
|
+
if (numeric) {
|
|
393
|
+
const target = Number(raw)
|
|
394
|
+
return (v) => {
|
|
395
|
+
if (isBlankValue(v)) return false
|
|
396
|
+
const n = typeof v === "number" ? v : Number(v)
|
|
397
|
+
if (Number.isNaN(n)) return false
|
|
398
|
+
return op === ">" ? n > target : op === "<" ? n < target : op === ">=" ? n >= target : n <= target
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const target = raw.toLowerCase()
|
|
402
|
+
return (v) => {
|
|
403
|
+
if (isBlankValue(v)) return false
|
|
404
|
+
const s = toStr(v).toLowerCase()
|
|
405
|
+
return op === ">" ? s > target : op === "<" ? s < target : op === ">=" ? s >= target : s <= target
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
330
409
|
// collect=false ne produit jamais de tableau (les plages renvoient #VALUE!) —
|
|
331
410
|
// ce garde sert uniquement au typage.
|
|
332
411
|
const toScalar = (v: EvalValue): FormulaValue => (Array.isArray(v) ? err("#VALUE!") : v)
|
|
@@ -355,6 +434,8 @@ function evalNode(node: Node, ctx: FormulaContext, collect: boolean): EvalValue
|
|
|
355
434
|
return node.value
|
|
356
435
|
case "str":
|
|
357
436
|
return node.value
|
|
437
|
+
case "bool":
|
|
438
|
+
return node.value
|
|
358
439
|
case "ref":
|
|
359
440
|
return resolveRef(node.ref, ctx)
|
|
360
441
|
case "range": {
|
|
@@ -387,6 +468,18 @@ function evalNode(node: Node, ctx: FormulaContext, collect: boolean): EvalValue
|
|
|
387
468
|
}
|
|
388
469
|
}
|
|
389
470
|
|
|
471
|
+
// — Évaluation d'un argument en liste (plage → valeurs, scalaire → [valeur]) —
|
|
472
|
+
// Indispensable aux fonctions à plages multiples (`SUMIF`, `SUMPRODUCT`…) : la forme
|
|
473
|
+
// et l'ordre des arguments doivent rester distincts (contrairement au dispatch générique
|
|
474
|
+
// qui aplatit tous les arguments dans un seul tableau).
|
|
475
|
+
const evalList = (node: Node, ctx: FormulaContext): FormulaValue[] => {
|
|
476
|
+
const v = evalNode(node, ctx, true)
|
|
477
|
+
return Array.isArray(v) ? v : [v]
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const evalScalar = (node: Node, ctx: FormulaContext): FormulaValue =>
|
|
481
|
+
toScalar(evalNode(node, ctx, false))
|
|
482
|
+
|
|
390
483
|
function evalBinary(node: Extract<Node, { kind: "binary" }>, ctx: FormulaContext): FormulaValue {
|
|
391
484
|
if (node.op === "&") {
|
|
392
485
|
const a = toScalar(evalNode(node.left, ctx, false))
|
|
@@ -605,6 +698,176 @@ function evalCall(node: Extract<Node, { kind: "call" }>, ctx: FormulaContext): F
|
|
|
605
698
|
return err("#VALUE!")
|
|
606
699
|
}
|
|
607
700
|
|
|
701
|
+
// ── Fonctions à plages multiples (alignées par index) ──
|
|
702
|
+
// La forme des arguments compte (plage de critères ↔ plage de valeurs) : elles sont
|
|
703
|
+
// donc évaluées ici, argument par argument, avant le dispatch générique qui aplatit.
|
|
704
|
+
const pairsFrom = (
|
|
705
|
+
from: number,
|
|
706
|
+
): [FormulaValue[], ((v: FormulaValue) => boolean)][] | FormulaError => {
|
|
707
|
+
const out: [FormulaValue[], ((v: FormulaValue) => boolean)][] = []
|
|
708
|
+
for (let i = from; i + 1 < node.args.length; i += 2) {
|
|
709
|
+
const criteria = evalScalar(node.args[i + 1]!, ctx)
|
|
710
|
+
if (isError(criteria)) return criteria
|
|
711
|
+
out.push([evalList(node.args[i]!, ctx), criteriaMatcher(criteria)])
|
|
712
|
+
}
|
|
713
|
+
return out
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Index (0-based) où **toutes** les paires (plage, critère) correspondent. */
|
|
717
|
+
const matched = (pairs: [FormulaValue[], ((v: FormulaValue) => boolean)][]): number[] => {
|
|
718
|
+
const len = pairs[0]?.[0].length ?? 0
|
|
719
|
+
const out: number[] = []
|
|
720
|
+
for (let i = 0; i < len; i++) {
|
|
721
|
+
if (pairs.every(([values, hit]) => hit(values[i] ?? null))) out.push(i)
|
|
722
|
+
}
|
|
723
|
+
return out
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const numbersAt = (values: FormulaValue[], indexes: number[]): number[] =>
|
|
727
|
+
indexes.map((i) => values[i]).filter((v): v is number => typeof v === "number")
|
|
728
|
+
|
|
729
|
+
const holidaysOf = (n: Node | undefined): Set<string> => {
|
|
730
|
+
const out = new Set<string>()
|
|
731
|
+
if (!n) return out
|
|
732
|
+
for (const v of evalList(n, ctx)) {
|
|
733
|
+
const d = toLocalDate(v)
|
|
734
|
+
if (!isError(d)) out.add(dayKey(d))
|
|
735
|
+
}
|
|
736
|
+
return out
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if ((name === "sumif" || name === "averageif") && node.args.length >= 2) {
|
|
740
|
+
const criteria = evalScalar(node.args[1]!, ctx)
|
|
741
|
+
if (isError(criteria)) return criteria
|
|
742
|
+
const criterionValues = evalList(node.args[0]!, ctx)
|
|
743
|
+
const valueValues = node.args[2] ? evalList(node.args[2], ctx) : criterionValues
|
|
744
|
+
const nums = numbersAt(valueValues, matched([[criterionValues, criteriaMatcher(criteria)]]))
|
|
745
|
+
const total = nums.reduce((a, b) => a + b, 0)
|
|
746
|
+
if (name === "sumif") return total
|
|
747
|
+
return nums.length ? total / nums.length : err("#DIV/0!")
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (name === "countif" && node.args.length >= 2) {
|
|
751
|
+
const criteria = evalScalar(node.args[1]!, ctx)
|
|
752
|
+
if (isError(criteria)) return criteria
|
|
753
|
+
return matched([[evalList(node.args[0]!, ctx), criteriaMatcher(criteria)]]).length
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (name === "countifs" && node.args.length >= 2) {
|
|
757
|
+
const pairs = pairsFrom(0)
|
|
758
|
+
if (isError(pairs)) return pairs
|
|
759
|
+
return matched(pairs).length
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if ((name === "sumifs" || name === "averageifs") && node.args.length >= 3) {
|
|
763
|
+
const pairs = pairsFrom(1)
|
|
764
|
+
if (isError(pairs)) return pairs
|
|
765
|
+
const nums = numbersAt(evalList(node.args[0]!, ctx), matched(pairs))
|
|
766
|
+
const total = nums.reduce((a, b) => a + b, 0)
|
|
767
|
+
if (name === "sumifs") return total
|
|
768
|
+
return nums.length ? total / nums.length : err("#DIV/0!")
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (name === "sumproduct" && node.args.length >= 1) {
|
|
772
|
+
const lists = node.args.map((a) => evalList(a, ctx))
|
|
773
|
+
const len = lists[0]?.length ?? 0
|
|
774
|
+
if (!len || lists.some((l) => l.length !== len)) return err("#VALUE!")
|
|
775
|
+
let total = 0
|
|
776
|
+
for (let i = 0; i < len; i++) {
|
|
777
|
+
let product = 1
|
|
778
|
+
// Comme Excel : une valeur non numérique compte pour 0 dans le produit.
|
|
779
|
+
for (const l of lists) product *= typeof l[i] === "number" ? (l[i] as number) : 0
|
|
780
|
+
total += product
|
|
781
|
+
}
|
|
782
|
+
return total
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if ((name === "large" || name === "small") && node.args.length >= 2) {
|
|
786
|
+
const values = evalList(node.args[0]!, ctx).filter((v): v is number => typeof v === "number")
|
|
787
|
+
const k = Math.trunc(Number(evalScalar(node.args[1]!, ctx)) || 0)
|
|
788
|
+
if (k < 1 || k > values.length) return err("#NUM!")
|
|
789
|
+
values.sort((a, b) => (name === "large" ? b - a : a - b))
|
|
790
|
+
return values[k - 1]!
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (name === "rank" && node.args.length >= 2) {
|
|
794
|
+
const target = evalScalar(node.args[0]!, ctx)
|
|
795
|
+
if (isError(target)) return target
|
|
796
|
+
const values = evalList(node.args[1]!, ctx).filter((v): v is number => typeof v === "number")
|
|
797
|
+
const n = Number(target)
|
|
798
|
+
if (Number.isNaN(n) || !values.includes(n)) return err("#N/A")
|
|
799
|
+
const order = node.args[2] ? Number(evalScalar(node.args[2], ctx)) : 0
|
|
800
|
+
const sorted = [...values].sort((a, b) => (order ? a - b : b - a))
|
|
801
|
+
return sorted.indexOf(n) + 1
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (name === "index" && node.args.length >= 2) {
|
|
805
|
+
const rangeNode = node.args[0]!
|
|
806
|
+
if (rangeNode.kind !== "range") return err("#VALUE!")
|
|
807
|
+
const a = resolveRefPos(rangeNode.start)
|
|
808
|
+
const b = resolveRefPos(rangeNode.end)
|
|
809
|
+
if (!a || !b) return err("#REF!")
|
|
810
|
+
const r0 = Math.min(a[0], b[0])
|
|
811
|
+
const r1 = Math.max(a[0], b[0])
|
|
812
|
+
const c0 = Math.min(a[1], b[1])
|
|
813
|
+
const c1 = Math.max(a[1], b[1])
|
|
814
|
+
const rows = r1 - r0 + 1
|
|
815
|
+
const cols = c1 - c0 + 1
|
|
816
|
+
const first = Math.trunc(Number(evalScalar(node.args[1]!, ctx)) || 0)
|
|
817
|
+
const second = node.args[2] ? Math.trunc(Number(evalScalar(node.args[2], ctx)) || 0) : 0
|
|
818
|
+
// Un seul index : ligne, ou colonne quand la plage n'a qu'une ligne (usage Excel).
|
|
819
|
+
const row = !second && rows === 1 ? 1 : first
|
|
820
|
+
const col = !second ? (rows === 1 ? first : 1) : second
|
|
821
|
+
if (row < 1 || col < 1 || row > rows || col > cols) return err("#REF!")
|
|
822
|
+
const value = ctx.resolveCell(r0 + row - 1, c0 + col - 1)
|
|
823
|
+
return value === undefined ? err("#REF!") : value
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (name === "match" && node.args.length >= 2) {
|
|
827
|
+
const rangeNode = node.args[1]!
|
|
828
|
+
if (rangeNode.kind !== "range") return err("#N/A")
|
|
829
|
+
const a = resolveRefPos(rangeNode.start)
|
|
830
|
+
const b = resolveRefPos(rangeNode.end)
|
|
831
|
+
if (!a || !b) return err("#N/A")
|
|
832
|
+
const key = evalScalar(node.args[0]!, ctx)
|
|
833
|
+
if (isError(key)) return key
|
|
834
|
+
const values: FormulaValue[] = []
|
|
835
|
+
for (let r = Math.min(a[0], b[0]); r <= Math.max(a[0], b[0]); r++)
|
|
836
|
+
for (let c = Math.min(a[1], b[1]); c <= Math.max(a[1], b[1]); c++)
|
|
837
|
+
values.push(ctx.resolveCell(r, c) ?? null)
|
|
838
|
+
const type = node.args[2] ? Math.trunc(Number(evalScalar(node.args[2], ctx)) || 0) : 1
|
|
839
|
+
if (type === 0) {
|
|
840
|
+
const at = values.findIndex((v) =>
|
|
841
|
+
typeof key === "number" && typeof v === "number"
|
|
842
|
+
? v === key
|
|
843
|
+
: toStr(v).toLowerCase() === toStr(key).toLowerCase())
|
|
844
|
+
return at === -1 ? err("#N/A") : at + 1
|
|
845
|
+
}
|
|
846
|
+
// Approché : dernier ≤ clé (croissant) / dernier ≥ clé (décroissant)
|
|
847
|
+
let best = -1
|
|
848
|
+
values.forEach((v, i) => {
|
|
849
|
+
const cmp = compare(v, key)
|
|
850
|
+
if (cmp === "err") return
|
|
851
|
+
if (type === -1 ? cmp >= 0 : cmp <= 0) best = i
|
|
852
|
+
})
|
|
853
|
+
return best === -1 ? err("#N/A") : best + 1
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (name === "workday" && node.args.length >= 2) {
|
|
857
|
+
const start = toLocalDate(evalScalar(node.args[0]!, ctx))
|
|
858
|
+
if (isError(start)) return start
|
|
859
|
+
const days = Math.trunc(Number(evalScalar(node.args[1]!, ctx)) || 0)
|
|
860
|
+
return isoLocal(addWorkdays(start, days, holidaysOf(node.args[2])))
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (name === "networkdays" && node.args.length >= 2) {
|
|
864
|
+
const start = toLocalDate(evalScalar(node.args[0]!, ctx))
|
|
865
|
+
const end = toLocalDate(evalScalar(node.args[1]!, ctx))
|
|
866
|
+
if (isError(start)) return start
|
|
867
|
+
if (isError(end)) return end
|
|
868
|
+
return countWorkdays(start, end, holidaysOf(node.args[2]))
|
|
869
|
+
}
|
|
870
|
+
|
|
608
871
|
const fn = FUNCTIONS[name]
|
|
609
872
|
if (!fn) return err("#NAME?")
|
|
610
873
|
|
|
@@ -733,6 +996,72 @@ const isoLocal = (d: Date, time = false) => {
|
|
|
733
996
|
const isoUTC = (d: Date) =>
|
|
734
997
|
d.getUTCFullYear() + "-" + pad2(d.getUTCMonth() + 1) + "-" + pad2(d.getUTCDate())
|
|
735
998
|
|
|
999
|
+
// — Dates « métier » (jours ouvrés, semaines) — calculs en UTC pour ignorer le fuseau/DST.
|
|
1000
|
+
/** Clé de jour (`YYYY-MM-DD`) insensible à l'heure et au fuseau. */
|
|
1001
|
+
const dayKey = (d: Date): string =>
|
|
1002
|
+
isoUTC(new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())))
|
|
1003
|
+
|
|
1004
|
+
/** Numéro de semaine « type Excel » : `startDay` 0 = dimanche, 1 = lundi. */
|
|
1005
|
+
const weekNumber = (d: Date, startDay: number): number => {
|
|
1006
|
+
const jan1 = Date.UTC(d.getFullYear(), 0, 1)
|
|
1007
|
+
const today = Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())
|
|
1008
|
+
const offset = (new Date(jan1).getUTCDay() - startDay + 7) % 7
|
|
1009
|
+
const dayOfYear = Math.round((today - jan1) / 86400000)
|
|
1010
|
+
return Math.floor((dayOfYear + offset) / 7) + 1
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Numéro de semaine ISO 8601 (semaine 1 = celle du premier jeudi). */
|
|
1014
|
+
const isoWeekNumber = (d: Date): number => {
|
|
1015
|
+
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
|
1016
|
+
const day = t.getUTCDay() || 7
|
|
1017
|
+
t.setUTCDate(t.getUTCDate() + 4 - day)
|
|
1018
|
+
const yearStart = Date.UTC(t.getUTCFullYear(), 0, 1)
|
|
1019
|
+
return Math.ceil(((t.getTime() - yearStart) / 86400000 + 1) / 7)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Ajoute (ou retire) des jours ouvrés (lun–ven), hors jours fériés. */
|
|
1023
|
+
const addWorkdays = (start: Date, days: number, holidays: Set<string>): Date => {
|
|
1024
|
+
let t = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate())
|
|
1025
|
+
const step = days < 0 ? -86400000 : 86400000
|
|
1026
|
+
let left = Math.abs(Math.trunc(days))
|
|
1027
|
+
while (left > 0) {
|
|
1028
|
+
t += step
|
|
1029
|
+
const d = new Date(t)
|
|
1030
|
+
const wd = d.getUTCDay()
|
|
1031
|
+
if (wd === 0 || wd === 6) continue
|
|
1032
|
+
if (holidays.has(isoUTC(d))) continue
|
|
1033
|
+
left--
|
|
1034
|
+
}
|
|
1035
|
+
const out = new Date(t)
|
|
1036
|
+
return new Date(out.getUTCFullYear(), out.getUTCMonth(), out.getUTCDate())
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Jours ouvrés entre deux dates (bornes incluses ; négatif si `end` < `start`). */
|
|
1040
|
+
const countWorkdays = (start: Date, end: Date, holidays: Set<string>): number => {
|
|
1041
|
+
const a = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate())
|
|
1042
|
+
const b = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate())
|
|
1043
|
+
const sign = a <= b ? 1 : -1
|
|
1044
|
+
const from = Math.min(a, b)
|
|
1045
|
+
const to = Math.max(a, b)
|
|
1046
|
+
let n = 0
|
|
1047
|
+
for (let t = from; t <= to; t += 86400000) {
|
|
1048
|
+
const d = new Date(t)
|
|
1049
|
+
const wd = d.getUTCDay()
|
|
1050
|
+
if (wd === 0 || wd === 6) continue
|
|
1051
|
+
if (holidays.has(isoUTC(d))) continue
|
|
1052
|
+
n++
|
|
1053
|
+
}
|
|
1054
|
+
return n * sign
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Variance d'une liste de nombres (`sample` → n−1, sinon population). */
|
|
1058
|
+
const varianceOf = (values: number[], sample: boolean): FormulaValue => {
|
|
1059
|
+
if (values.length < (sample ? 2 : 1)) return err("#DIV/0!")
|
|
1060
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length
|
|
1061
|
+
const sum = values.reduce((a, b) => a + (b - mean) ** 2, 0)
|
|
1062
|
+
return sum / (sample ? values.length - 1 : values.length)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
736
1065
|
Object.assign(FUNCTIONS, {
|
|
737
1066
|
// Logique étendue (évaluées paresseusement dans evalCall : IFS, SWITCH, IFNA, VLOOKUP)
|
|
738
1067
|
// Texte
|
|
@@ -907,6 +1236,119 @@ Object.assign(FUNCTIONS, {
|
|
|
907
1236
|
rand: () => Math.random(),
|
|
908
1237
|
})
|
|
909
1238
|
|
|
1239
|
+
// ─── Critères, statistiques, texte et dates (extension 2026-10-17) ───
|
|
1240
|
+
// Les agrégats conditionnels (`SUMIF`, `COUNTIF`, `*IFS`, `SUMPRODUCT`), `INDEX`/`MATCH`,
|
|
1241
|
+
// `LARGE`/`SMALL`/`RANK` et `WORKDAY`/`NETWORKDAYS` sont traités dans `evalCall` (plages).
|
|
1242
|
+
Object.assign(FUNCTIONS, {
|
|
1243
|
+
median: (args: FormulaValue[]) => {
|
|
1244
|
+
const n = numbers(args).sort((a, b) => a - b)
|
|
1245
|
+
if (!n.length) return err("#NUM!")
|
|
1246
|
+
const mid = Math.floor(n.length / 2)
|
|
1247
|
+
return n.length % 2 ? n[mid]! : (n[mid - 1]! + n[mid]!) / 2
|
|
1248
|
+
},
|
|
1249
|
+
stdev: (args: FormulaValue[]) => {
|
|
1250
|
+
const v = varianceOf(numbers(args), true)
|
|
1251
|
+
return typeof v === "number" ? Math.sqrt(v) : v
|
|
1252
|
+
},
|
|
1253
|
+
stdevp: (args: FormulaValue[]) => {
|
|
1254
|
+
const v = varianceOf(numbers(args), false)
|
|
1255
|
+
return typeof v === "number" ? Math.sqrt(v) : v
|
|
1256
|
+
},
|
|
1257
|
+
var: (args: FormulaValue[]) => varianceOf(numbers(args), true),
|
|
1258
|
+
varp: (args: FormulaValue[]) => varianceOf(numbers(args), false),
|
|
1259
|
+
// Constantes logiques (aussi disponibles en littéraux `TRUE` / `FALSE`)
|
|
1260
|
+
true: () => true,
|
|
1261
|
+
false: () => false,
|
|
1262
|
+
// Info / erreurs
|
|
1263
|
+
iserror: (args: FormulaValue[], h: boolean) => scalar(args, h, (v) => isError(v)),
|
|
1264
|
+
iserr: (args: FormulaValue[], h: boolean) =>
|
|
1265
|
+
scalar(args, h, (v) => isError(v) && v.code !== "#N/A"),
|
|
1266
|
+
isna: (args: FormulaValue[], h: boolean) =>
|
|
1267
|
+
scalar(args, h, (v) => isError(v) && v.code === "#N/A"),
|
|
1268
|
+
na: () => err("#N/A"),
|
|
1269
|
+
// Texte
|
|
1270
|
+
proper: (args: FormulaValue[], h: boolean) =>
|
|
1271
|
+
scalar(args, h, (v) =>
|
|
1272
|
+
toStr(v)
|
|
1273
|
+
.toLowerCase()
|
|
1274
|
+
.replace(/(^|[^\p{L}\p{N}])(\p{L})/gu, (_m: string, sep: string, ch: string) => sep + ch.toUpperCase())),
|
|
1275
|
+
exact: (args: FormulaValue[]) => toStr(args[0] ?? null) === toStr(args[1] ?? null),
|
|
1276
|
+
search: (args: FormulaValue[]) => {
|
|
1277
|
+
const needle = toStr(args[0] ?? null).toLowerCase()
|
|
1278
|
+
const hay = toStr(args[1] ?? null).toLowerCase()
|
|
1279
|
+
const start = Math.max(1, Math.trunc(Number(args[2] ?? 1)) || 1) - 1
|
|
1280
|
+
const at = hay.indexOf(needle, start)
|
|
1281
|
+
return at === -1 ? err("#VALUE!") : at + 1
|
|
1282
|
+
},
|
|
1283
|
+
textjoin: (args: FormulaValue[]) => {
|
|
1284
|
+
const delimiter = toStr(args[0] ?? "")
|
|
1285
|
+
const ignoreEmpty = args[1] === undefined ? true : truthy(args[1])
|
|
1286
|
+
return args
|
|
1287
|
+
.slice(2)
|
|
1288
|
+
.filter((v) => !(ignoreEmpty && (v === null || v === undefined || v === "")))
|
|
1289
|
+
.map(toStr)
|
|
1290
|
+
.join(delimiter)
|
|
1291
|
+
},
|
|
1292
|
+
// Dates
|
|
1293
|
+
days: (args: FormulaValue[]) => {
|
|
1294
|
+
const end = toLocalDate(args[0] ?? null)
|
|
1295
|
+
const start = toLocalDate(args[1] ?? null)
|
|
1296
|
+
if (isError(end)) return end
|
|
1297
|
+
if (isError(start)) return start
|
|
1298
|
+
const a = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate())
|
|
1299
|
+
const b = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate())
|
|
1300
|
+
return Math.round((a - b) / 86400000)
|
|
1301
|
+
},
|
|
1302
|
+
weeknum: (args: FormulaValue[]) => {
|
|
1303
|
+
const d = toLocalDate(args[0] ?? null)
|
|
1304
|
+
if (isError(d)) return d
|
|
1305
|
+
const type = Math.trunc(Number(args[1] ?? 1)) || 1
|
|
1306
|
+
return weekNumber(d, type === 2 ? 1 : 0)
|
|
1307
|
+
},
|
|
1308
|
+
isoweeknum: (args: FormulaValue[]) => {
|
|
1309
|
+
const d = toLocalDate(args[0] ?? null)
|
|
1310
|
+
return isError(d) ? d : isoWeekNumber(d)
|
|
1311
|
+
},
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1314
|
+
/** Fonctions évaluées hors `FUNCTIONS` (accès aux plages / arguments paresseux). */
|
|
1315
|
+
const NODE_FUNCTION_NAMES = [
|
|
1316
|
+
"AND",
|
|
1317
|
+
"AVERAGEIF",
|
|
1318
|
+
"AVERAGEIFS",
|
|
1319
|
+
"COUNTIF",
|
|
1320
|
+
"COUNTIFS",
|
|
1321
|
+
"IF",
|
|
1322
|
+
"IFERROR",
|
|
1323
|
+
"IFNA",
|
|
1324
|
+
"IFS",
|
|
1325
|
+
"INDEX",
|
|
1326
|
+
"LARGE",
|
|
1327
|
+
"MATCH",
|
|
1328
|
+
"NETWORKDAYS",
|
|
1329
|
+
"OR",
|
|
1330
|
+
"RANK",
|
|
1331
|
+
"SMALL",
|
|
1332
|
+
"SUMPRODUCT",
|
|
1333
|
+
"SUMIF",
|
|
1334
|
+
"SUMIFS",
|
|
1335
|
+
"SWITCH",
|
|
1336
|
+
"VLOOKUP",
|
|
1337
|
+
"WORKDAY",
|
|
1338
|
+
"XLOOKUP",
|
|
1339
|
+
]
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Noms des fonctions intégrées (majuscules, triés) — contrat avec les suggestions de
|
|
1343
|
+
* l'éditeur : aucune fonction ne doit être « invisible » dans l'autocomplétion.
|
|
1344
|
+
*/
|
|
1345
|
+
export const FORMULA_FUNCTION_NAMES: string[] = [
|
|
1346
|
+
...new Set([
|
|
1347
|
+
...Object.keys(FUNCTIONS).map((name) => name.toUpperCase()),
|
|
1348
|
+
...NODE_FUNCTION_NAMES,
|
|
1349
|
+
]),
|
|
1350
|
+
].sort()
|
|
1351
|
+
|
|
910
1352
|
/** Évalue une formule ("=…" optionnel). Retourne une valeur ou une FormulaError. */
|
|
911
1353
|
export function evaluateFormula(source: string, ctx: FormulaContext): FormulaValue {
|
|
912
1354
|
const src = source.startsWith("=") ? source.slice(1) : source
|
package/lib/styleTokens.test.ts
CHANGED
|
@@ -112,6 +112,7 @@ describe("classes de style (composant ↔ CSS)", () => {
|
|
|
112
112
|
"q-tabs--scroll-end",
|
|
113
113
|
".q-tabs--arrows.q-tabs--scroll-end .q-tabs__content",
|
|
114
114
|
],
|
|
115
|
+
["components/QTable.vue", "q-table__rows-field", ".q-table__rows-field"],
|
|
115
116
|
]
|
|
116
117
|
|
|
117
118
|
for (const [file, className, selector] of CONTRACTS) {
|
|
@@ -160,6 +161,20 @@ describe("fondu de défilement des tabs (thème-agnostique)", () => {
|
|
|
160
161
|
})
|
|
161
162
|
})
|
|
162
163
|
|
|
164
|
+
describe("pagination de QTable", () => {
|
|
165
|
+
it("le sélecteur « lignes par page » est un QSelect, pas un `<select>` natif", async () => {
|
|
166
|
+
const vue = await Bun.file(new URL("../components/QTable.vue", import.meta.url)).text()
|
|
167
|
+
expect({ usesQSelect: vue.includes("<q-select") }).toEqual({ usesQSelect: true })
|
|
168
|
+
expect({ nativeSelect: /<select\b/.test(vue) }).toEqual({ nativeSelect: false })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("la liste vient de la prop publique `rows-per-page-options` (aucun tableau dérivé)", async () => {
|
|
172
|
+
const vue = await Bun.file(new URL("../components/QTable.vue", import.meta.url)).text()
|
|
173
|
+
expect({ fromProp: vue.includes(':options="rowsPerPageOptions"') }).toEqual({ fromProp: true })
|
|
174
|
+
expect({ derived: vue.includes("rowsPerPageChoices") }).toEqual({ derived: false })
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
163
178
|
describe("mode sombre des contrôles", () => {
|
|
164
179
|
it("la bordure noire du checkbox / radio décoché est corrigée en sombre", () => {
|
|
165
180
|
// Base claire : bordure codée en dur (le variant `--dark` du composant la corrige, mais
|
package/package.json
CHANGED
package/styles/main.css
CHANGED
|
@@ -3615,13 +3615,27 @@ html.dark body {
|
|
|
3615
3615
|
color: rgb(255 255 255 / 0.6);
|
|
3616
3616
|
}
|
|
3617
3617
|
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3618
|
+
/* Sélecteur « lignes par page » : QSelect compact — le footer n'a pas de place
|
|
3619
|
+
pour la ligne d'aide (hint/error) ni pour la hauteur de champ standard. */
|
|
3620
|
+
.q-table__rows-field {
|
|
3621
|
+
width: 72px;
|
|
3622
|
+
flex: 0 0 auto;
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
.q-table__rows-field .q-field__control {
|
|
3626
|
+
min-height: 28px;
|
|
3627
|
+
padding: 0 8px;
|
|
3628
|
+
font-size: 13px;
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
.q-table__rows-field .q-field__bottom {
|
|
3632
|
+
display: none;
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
.q-table--dark .q-table__rows-field .q-field__control {
|
|
3636
|
+
background-color: #1d1d1d;
|
|
3637
|
+
border-color: var(--q-table-border);
|
|
3638
|
+
color: #fff;
|
|
3625
3639
|
}
|
|
3626
3640
|
|
|
3627
3641
|
.q-table__page-nav {
|