@dnax/ui 0.0.65 → 0.0.67

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/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,9 +1236,124 @@ Object.assign(FUNCTIONS, {
907
1236
  rand: () => Math.random(),
908
1237
  })
909
1238
 
910
- /** Évalue une formule ("=…" optionnel). Retourne une valeur ou une FormulaError. */
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
+
1352
+ /** Évalue une formule ("=…" optionnel, espaces ignorés). Retourne une valeur ou une FormulaError. */
911
1353
  export function evaluateFormula(source: string, ctx: FormulaContext): FormulaValue {
912
- const src = source.startsWith("=") ? source.slice(1) : source
1354
+ // `isFormulaRaw` accepte des espaces avant le `=` : le moteur doit en faire autant.
1355
+ const trimmed = source.trim()
1356
+ const src = trimmed.startsWith("=") ? trimmed.slice(1) : trimmed
913
1357
  try {
914
1358
  const parser = new Parser(src)
915
1359
  const node = parser.parseExpr()
@@ -6,6 +6,10 @@ import {
6
6
  isBlankValue,
7
7
  isoAddDays,
8
8
  shiftFormulaRefs,
9
+ functionQueryAt,
10
+ rankSuggestions,
11
+ functionCallAt,
12
+ signatureArgs,
9
13
  csvEscape,
10
14
  csvSplitLine,
11
15
  parseCsv,
@@ -44,6 +48,112 @@ describe("shiftFormulaRefs (autofill)", () => {
44
48
  it("laisse les valeurs sans lettres intactes", () => {
45
49
  expect(shiftFormulaRefs("=1+2", 1, 1)).toBe("=1+2")
46
50
  })
51
+ it("ne touche pas aux littéraux de chaîne", () => {
52
+ expect(shiftFormulaRefs('=IF(A1>0,"A1","x")', 1, 0)).toBe('=IF(A2>0,"A1","x")')
53
+ expect(shiftFormulaRefs("=CONCAT('B2',A1)", 0, 1)).toBe("=CONCAT('B2',B1)")
54
+ })
55
+ it("ne confond pas un identifiant avec une référence", () => {
56
+ expect(shiftFormulaRefs("=LOG10(A1)", 1, 0)).toBe("=LOG10(A2)")
57
+ expect(shiftFormulaRefs("=price1+A1", 1, 0)).toBe("=price1+A2")
58
+ expect(shiftFormulaRefs("=A1:price2", 1, 0)).toBe("=A2:price2")
59
+ })
60
+ })
61
+
62
+ describe("functionQueryAt (autocomplétion fx)", () => {
63
+ it("rend l'identifiant en cours de frappe, en minuscules", () => {
64
+ expect(functionQueryAt("=SU")).toBe("su")
65
+ expect(functionQueryAt("=sum(")).toBe("")
66
+ expect(functionQueryAt("=SUM(A1)+IF")).toBe("if")
67
+ })
68
+
69
+ it("ne suggère rien hors d'une formule ou dans une chaîne", () => {
70
+ expect(functionQueryAt("SUM")).toBe("")
71
+ expect(functionQueryAt('="hello wor')).toBe("")
72
+ expect(functionQueryAt("='hello wor")).toBe("")
73
+ })
74
+
75
+ it("ne suggère pas au milieu d'une référence ou d'une plage", () => {
76
+ expect(functionQueryAt("=A1", 2)).toBe("")
77
+ expect(functionQueryAt("=A1:B")).toBe("")
78
+ expect(functionQueryAt("=$AB")).toBe("")
79
+ expect(functionQueryAt("=A1+B")).toBe("b")
80
+ expect(functionQueryAt("=SUM(A")).toBe("a")
81
+ })
82
+
83
+ it("suit le curseur, pas seulement la fin du texte", () => {
84
+ expect(functionQueryAt("=SU+1", 3)).toBe("su")
85
+ expect(functionQueryAt("=SU+1", 5)).toBe("")
86
+ })
87
+ })
88
+
89
+ describe("rankSuggestions (autocomplétion)", () => {
90
+ const items = [
91
+ { name: "SUM" },
92
+ { name: "SUMIF" },
93
+ { name: "SUMPRODUCT" },
94
+ { name: "MID" },
95
+ { name: "MEDIAN" },
96
+ { name: "IF" },
97
+ { name: "IFS" },
98
+ ]
99
+
100
+ it("classe égalité > préfixe, puis nom le plus court", () => {
101
+ expect(rankSuggestions(items, "sum").map((i) => i.name)).toEqual([
102
+ "SUM",
103
+ "SUMIF",
104
+ "SUMPRODUCT",
105
+ ])
106
+ expect(rankSuggestions(items, "if").map((i) => i.name)).toEqual(["IF", "IFS", "SUMIF"])
107
+ })
108
+
109
+ it("trouve une sous-chaîne", () => {
110
+ expect(rankSuggestions(items, "edian").map((i) => i.name)).toEqual(["MEDIAN"])
111
+ expect(rankSuggestions(items, "ia").map((i) => i.name)).toEqual(["MEDIAN"])
112
+ })
113
+
114
+ it("trouve une sous-séquence (fuzzy)", () => {
115
+ expect(rankSuggestions(items, "sm").map((i) => i.name)).toEqual([
116
+ "SUM",
117
+ "SUMIF",
118
+ "SUMPRODUCT",
119
+ ])
120
+ })
121
+
122
+ it("respecte la limite et ignore une requête vide", () => {
123
+ expect(rankSuggestions(items, "s", 1)).toHaveLength(1)
124
+ expect(rankSuggestions(items, "")).toEqual([])
125
+ expect(rankSuggestions(items, "zzz")).toEqual([])
126
+ })
127
+ })
128
+
129
+ describe("functionCallAt / signatureArgs (aide à l'argument)", () => {
130
+ const known = ["SUM", "IF", "MAX"]
131
+
132
+ it("détecte la fonction et l'index de l'argument courant", () => {
133
+ expect(functionCallAt("=SUM(", 5, known)).toEqual({ name: "sum", index: 0 })
134
+ expect(functionCallAt("=SUM(A1,", 8, known)).toEqual({ name: "sum", index: 1 })
135
+ expect(functionCallAt("=IF(A1>0,", 9, known)).toEqual({ name: "if", index: 1 })
136
+ })
137
+
138
+ it("prend la fonction la plus interne", () => {
139
+ expect(functionCallAt("=SUM(IF(A1>0,", 13, known)).toEqual({ name: "if", index: 1 })
140
+ })
141
+
142
+ it("ignore les virgules des chaînes et les appels fermés", () => {
143
+ expect(functionCallAt('=IF(A1,"x,y",', 7, known)).toEqual({ name: "if", index: 1 })
144
+ expect(functionCallAt('=IF(A1,"x,y",', 13, known)).toEqual({ name: "if", index: 2 })
145
+ expect(functionCallAt("=A1+B1", 6, known)).toBeNull()
146
+ expect(functionCallAt("=SUM(A1)", 9, known)).toBeNull()
147
+ })
148
+
149
+ it("signatureArgs découpe les paramètres", () => {
150
+ expect(signatureArgs("range, criteria, [sum_range]")).toEqual([
151
+ "range",
152
+ "criteria",
153
+ "[sum_range]",
154
+ ])
155
+ expect(signatureArgs("")).toEqual([])
156
+ })
47
157
  })
48
158
 
49
159
  describe("dates ISO", () => {