@dnax/ui 0.0.27 → 0.0.29

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
@@ -503,6 +503,107 @@ function evalCall(node: Extract<Node, { kind: "call" }>, ctx: FormulaContext): F
503
503
  }
504
504
  return name === "and"
505
505
  }
506
+ // IFS : paires cond → valeur, évaluées paresseusement
507
+ if (name === "ifs") {
508
+ if (node.args.length % 2 !== 0) return err("#VALUE!")
509
+ for (let i = 0; i < node.args.length; i += 2) {
510
+ const cond = toScalar(evalNode(node.args[i]!, ctx, false))
511
+ if (isError(cond)) return cond
512
+ if (truthy(cond)) return toScalar(evalNode(node.args[i + 1]!, ctx, false))
513
+ }
514
+ return err("#N/A")
515
+ }
516
+ // SWITCH(expr, v1, r1, …, défaut?)
517
+ if (name === "switch") {
518
+ if (!node.args.length) return err("#N/A")
519
+ const expr = toScalar(evalNode(node.args[0]!, ctx, false))
520
+ if (isError(expr)) return expr
521
+ const last = node.args.length - 1
522
+ const hasDefault = node.args.length % 2 === 0
523
+ for (let i = 1; i <= (hasDefault ? last - 1 : last); i += 2) {
524
+ const val = toScalar(evalNode(node.args[i]!, ctx, false))
525
+ if (isError(val)) return val
526
+ if (String(expr) === String(val)) return toScalar(evalNode(node.args[i + 1]!, ctx, false))
527
+ }
528
+ return hasDefault ? toScalar(evalNode(node.args[last]!, ctx, false)) : err("#N/A")
529
+ }
530
+ if (name === "ifna" && node.args.length >= 1) {
531
+ const v = toScalar(evalNode(node.args[0]!, ctx, false))
532
+ if (isError(v) && v.code === "#N/A")
533
+ return node.args[1] !== undefined ? toScalar(evalNode(node.args[1]!, ctx, false)) : null
534
+ return v
535
+ }
536
+ // VLOOKUP : recherche EXACTE de la 1re colonne d'une plage A1 (col_index 1-based)
537
+ if (name === "vlookup" && node.args.length >= 3) {
538
+ const key = toScalar(evalNode(node.args[0]!, ctx, false))
539
+ const rangeNode = node.args[1]!
540
+ const colNumRaw = toScalar(evalNode(node.args[2]!, ctx, false))
541
+ const colNum = Number(colNumRaw)
542
+ const colI = Number.isNaN(colNum) ? 1 : Math.trunc(colNum)
543
+ if (isError(key)) return key
544
+ if (rangeNode.kind === "range") {
545
+ const a = resolveRefPos(rangeNode.start)
546
+ const b = resolveRefPos(rangeNode.end)
547
+ if (a && b) {
548
+ const r0 = Math.min(a[0], b[0])
549
+ const r1 = Math.max(a[0], b[0])
550
+ const c0 = Math.min(a[1], b[1])
551
+ const c1 = Math.max(a[1], b[1])
552
+ const outC = c0 + colI - 1
553
+ if (outC < c0 || outC > c1) return err("#REF!")
554
+ for (let r = r0; r <= r1; r++) {
555
+ const cell = ctx.resolveCell(r, c0)
556
+ if (cell === undefined) continue
557
+ const hit =
558
+ typeof key === "number" && typeof cell === "number"
559
+ ? key === cell
560
+ : String(key ?? "") === String(cell ?? "")
561
+ if (hit) {
562
+ const found = ctx.resolveCell(r, outC)
563
+ return found === undefined ? err("#REF!") : found
564
+ }
565
+ }
566
+ return err("#N/A")
567
+ }
568
+ }
569
+ return err("#VALUE!")
570
+ }
571
+ // XLOOKUP(key, table, return_range, [not_found]) — correspondance exacte
572
+ if (name === "xlookup" && node.args.length >= 3) {
573
+ const key = toScalar(evalNode(node.args[0]!, ctx, false))
574
+ const tableNode = node.args[1]!
575
+ const retNode = node.args[2]!
576
+ if (!isError(key) && tableNode.kind === "range" && retNode.kind === "range") {
577
+ const ta = resolveRefPos(tableNode.start)
578
+ const tb = resolveRefPos(tableNode.end)
579
+ const ra = resolveRefPos(retNode.start)
580
+ const rb = resolveRefPos(retNode.end)
581
+ if (ta && tb && ra && rb) {
582
+ const r0 = Math.min(ta[0], tb[0])
583
+ const r1 = Math.max(ta[0], tb[0])
584
+ const c0 = Math.min(ta[1], tb[1])
585
+ const retR0 = Math.min(ra[0], rb[0])
586
+ const retC = ra[1]
587
+ for (let r = r0; r <= r1; r++) {
588
+ const cell = ctx.resolveCell(r, c0)
589
+ if (cell === undefined) continue
590
+ const hit =
591
+ typeof key === "number" && typeof cell === "number"
592
+ ? key === cell
593
+ : String(key ?? "") === String(cell ?? "")
594
+ if (hit) {
595
+ const row = retR0 + (r - r0)
596
+ const found = ctx.resolveCell(row, retC)
597
+ if (found === undefined) return err("#REF!")
598
+ return found
599
+ }
600
+ }
601
+ if (node.args[3] !== undefined) return toScalar(evalNode(node.args[3], ctx, false))
602
+ return err("#N/A")
603
+ }
604
+ }
605
+ return err("#VALUE!")
606
+ }
506
607
 
507
608
  const fn = FUNCTIONS[name]
508
609
  if (!fn) return err("#NAME?")
@@ -615,6 +716,197 @@ const FUNCTIONS: Record<string, GridFunction> = {
615
716
  }),
616
717
  }
617
718
 
719
+ // ─── Fonctions étendues (texte / dates / logique) — 2026-09-07 ───
720
+ const pad2 = (n: number) => (n < 10 ? "0" + n : String(n))
721
+ const toLocalDate = (v: FormulaValue): Date | FormulaError => {
722
+ if (v === null || v === undefined || v === "") return err("#VALUE!")
723
+ if (typeof v === "number") return new Date(v * 86400000) // série Excel approx.
724
+ const s = String(v).trim().replace(" ", "T")
725
+ const d = new Date(s.length >= 10 ? s : s + "T00:00:00")
726
+ return Number.isNaN(d.getTime()) ? err("#VALUE!") : d
727
+ }
728
+ const isoLocal = (d: Date, time = false) => {
729
+ const base =
730
+ d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate())
731
+ return time ? base + "T" + pad2(d.getHours()) + ":" + pad2(d.getMinutes()) : base
732
+ }
733
+ const isoUTC = (d: Date) =>
734
+ d.getUTCFullYear() + "-" + pad2(d.getUTCMonth() + 1) + "-" + pad2(d.getUTCDate())
735
+
736
+ Object.assign(FUNCTIONS, {
737
+ // Logique étendue (évaluées paresseusement dans evalCall : IFS, SWITCH, IFNA, VLOOKUP)
738
+ // Texte
739
+ left: (args: FormulaValue[]) => {
740
+ const s = toStr(args[0] ?? null)
741
+ const n = args[1] === undefined ? 1 : Math.max(0, Math.trunc(Number(args[1]) || 0))
742
+ return s.slice(0, n)
743
+ },
744
+ right: (args: FormulaValue[]) => {
745
+ const s = toStr(args[0] ?? null)
746
+ const n = args[1] === undefined ? 1 : Math.max(0, Math.trunc(Number(args[1]) || 0))
747
+ return n === 0 ? "" : s.slice(-n)
748
+ },
749
+ mid: (args: FormulaValue[]) => {
750
+ const s = toStr(args[0] ?? null)
751
+ const start = Math.max(1, Math.trunc(Number(args[1]) || 0))
752
+ const len = args[2] === undefined ? s.length : Math.max(0, Math.trunc(Number(args[2]) || 0))
753
+ return s.slice(start - 1, start - 1 + len)
754
+ },
755
+ find: (args: FormulaValue[]) => {
756
+ const find = toStr(args[0] ?? null)
757
+ const within = toStr(args[1] ?? null)
758
+ const start = Math.max(1, Math.trunc(Number(args[2]) || 0)) - 1
759
+ if (!find) return err("#VALUE!")
760
+ const i = within.indexOf(find, Math.max(0, start))
761
+ return i === -1 ? err("#VALUE!") : i + 1
762
+ },
763
+ substitute: (args: FormulaValue[]) => {
764
+ const text = toStr(args[0] ?? null)
765
+ const oldS = toStr(args[1] ?? null)
766
+ const newS = toStr(args[2] ?? null)
767
+ if (!oldS) return text
768
+ const which = args[3] === undefined ? -1 : Math.trunc(Number(args[3]) || 0)
769
+ if (which <= 0) return text.split(oldS).join(newS)
770
+ let count = 0
771
+ let idx = 0
772
+ while (true) {
773
+ const at = text.indexOf(oldS, idx)
774
+ if (at === -1) return text
775
+ count++
776
+ if (count === which) {
777
+ return text.slice(0, at) + newS + text.slice(at + oldS.length)
778
+ }
779
+ idx = at + oldS.length
780
+ }
781
+ },
782
+ replace: (args: FormulaValue[]) => {
783
+ const s = toStr(args[0] ?? null)
784
+ const start = Math.max(1, Math.trunc(Number(args[1]) || 0)) - 1
785
+ const num = Math.max(0, Math.trunc(Number(args[2]) || 0))
786
+ const repl = toStr(args[3] ?? null)
787
+ return s.slice(0, start) + repl + s.slice(start + num)
788
+ },
789
+ concatenate: (args: FormulaValue[]) => FUNCTIONS.concat!(args, false),
790
+ // Dates (ISO : AAAA-MM-JJ, AAAA-MM-JJT HH:mm)
791
+ today: () => isoLocal(new Date()),
792
+ now: () => isoLocal(new Date(), true),
793
+ date: (args: FormulaValue[]) => {
794
+ const y = Math.trunc(Number(args[0]) || 0)
795
+ const m = Math.trunc(Number(args[1]) || 0)
796
+ const d = Math.trunc(Number(args[2]) || 0)
797
+ const dt = new Date(Date.UTC(y, m - 1, d))
798
+ return isoUTC(dt)
799
+ },
800
+ year: (args: FormulaValue[]) => {
801
+ const d = toLocalDate(args[0] ?? null)
802
+ return isError(d) ? d : d.getFullYear()
803
+ },
804
+ month: (args: FormulaValue[]) => {
805
+ const d = toLocalDate(args[0] ?? null)
806
+ return isError(d) ? d : d.getMonth() + 1
807
+ },
808
+ day: (args: FormulaValue[]) => {
809
+ const d = toLocalDate(args[0] ?? null)
810
+ return isError(d) ? d : d.getDate()
811
+ },
812
+ edate: (args: FormulaValue[]) => {
813
+ const d = toLocalDate(args[0] ?? null)
814
+ if (isError(d)) return d
815
+ const months = Math.trunc(Number(args[1]) || 0)
816
+ d.setMonth(d.getMonth() + months)
817
+ return isoLocal(d)
818
+ },
819
+ // Dates avancées (2026-09-07)
820
+ weekday: (args: FormulaValue[]) => {
821
+ const d = toLocalDate(args[0] ?? null)
822
+ if (isError(d)) return d
823
+ const t = Math.trunc(Number(args[1]) ?? 1) || 1
824
+ const wd = d.getDay() // 0 = dimanche
825
+ if (t === 2) return wd === 0 ? 7 : wd
826
+ if (t === 3) return wd === 0 ? 6 : wd - 1
827
+ return wd + 1 // 1 = dimanche .. 7 = samedi
828
+ },
829
+ eomonth: (args: FormulaValue[]) => {
830
+ const d = toLocalDate(args[0] ?? null)
831
+ if (isError(d)) return d
832
+ const m = Math.trunc(Number(args[1]) || 0)
833
+ d.setMonth(d.getMonth() + m + 1, 0)
834
+ return isoLocal(d)
835
+ },
836
+ datedif: (args: FormulaValue[]) => {
837
+ const a = toLocalDate(args[0] ?? null)
838
+ const b = toLocalDate(args[1] ?? null)
839
+ if (isError(a) || isError(b)) return isError(a) ? a : b
840
+ const unit = toStr(args[2] ?? "d").toUpperCase()
841
+ let ms = b.getTime() - a.getTime()
842
+ if (ms < 0 && unit !== "YD") return err("#NUM!")
843
+ const days = Math.floor(ms / 86400000)
844
+ if (unit === "D") return days
845
+ if (unit === "M") {
846
+ return (
847
+ (b.getFullYear() - a.getFullYear()) * 12 +
848
+ (b.getMonth() - a.getMonth()) -
849
+ (b.getDate() < a.getDate() ? 1 : 0)
850
+ )
851
+ }
852
+ if (unit === "Y") {
853
+ let y = b.getFullYear() - a.getFullYear()
854
+ if (b.getMonth() < a.getMonth() || (b.getMonth() === a.getMonth() && b.getDate() < a.getDate())) y--
855
+ return y
856
+ }
857
+ if (unit === "MD") {
858
+ const tmp = new Date(a)
859
+ tmp.setFullYear(b.getFullYear(), b.getMonth(), 1)
860
+ tmp.setMonth(tmp.getMonth() + 1, 0)
861
+ const base = Math.max(1, tmp.getDate())
862
+ return b.getDate() < a.getDate() ? b.getDate() + (base - a.getDate()) : b.getDate() - a.getDate()
863
+ }
864
+ if (unit === "YD") {
865
+ const anchor = new Date(b)
866
+ anchor.setFullYear(a.getFullYear())
867
+ if (anchor < a) anchor.setFullYear(a.getFullYear() + 1)
868
+ return Math.round((b.getTime() - anchor.getTime()) / 86400000)
869
+ }
870
+ return err("#VALUE!")
871
+ },
872
+ text: (args: FormulaValue[]) => {
873
+ const v = args[0] ?? null
874
+ const fmt = toStr(args[1] ?? "")
875
+ // nombres : 0.00 / 0% / milliers
876
+ if (typeof v === "number" || (typeof v === "string" && !Number.isNaN(Number(v)))) {
877
+ const n = typeof v === "number" ? v : Number(v)
878
+ const f = fmt.trim()
879
+ if (f.includes("%")) return String(Math.round(n * 100)) + "%"
880
+ const dec = /0+(\.0+)?/.exec(f)?.[0]
881
+ const digits = dec && dec.includes(".") ? dec.length - dec.indexOf(".") - 1 : 0
882
+ if (f === "0") return String(Math.round(n))
883
+ return n.toFixed(digits)
884
+ }
885
+ // dates ISO → tokens YYYY MM DD HH mm
886
+ const d = toLocalDate(v)
887
+ if (!isError(d)) {
888
+ const map: Record<string, string> = {
889
+ YYYY: String(d.getFullYear()),
890
+ YY: pad2(d.getFullYear() % 100),
891
+ MM: pad2(d.getMonth() + 1),
892
+ DD: pad2(d.getDate()),
893
+ HH: pad2(d.getHours()),
894
+ mm: pad2(d.getMinutes()),
895
+ hh: pad2(d.getHours() % 12 || 12),
896
+ ss: pad2(d.getSeconds()),
897
+ dddd: "",
898
+ }
899
+ let out = fmt
900
+ for (const k of Object.keys(map)) out = out.split(k).join(map[k]!)
901
+ out = out.replace(/dddd|ddd/g, "")
902
+ return out
903
+ }
904
+ return toStr(v)
905
+ },
906
+ // Aléatoire / autres
907
+ rand: () => Math.random(),
908
+ })
909
+
618
910
  /** Évalue une formule ("=…" optionnel). Retourne une valeur ou une FormulaError. */
619
911
  export function evaluateFormula(source: string, ctx: FormulaContext): FormulaValue {
620
912
  const src = source.startsWith("=") ? source.slice(1) : source
package/lib/icons.ts CHANGED
@@ -35,4 +35,47 @@ export const icons = {
35
35
  search: "lucide:search",
36
36
  star: "lucide:star",
37
37
  x: "lucide:x",
38
+ // — éditeur de texte (QTiptap) —
39
+ undo2: "lucide:undo-2",
40
+ redo2: "lucide:redo-2",
41
+ pilcrow: "lucide:pilcrow",
42
+ heading1: "lucide:heading-1",
43
+ heading2: "lucide:heading-2",
44
+ heading3: "lucide:heading-3",
45
+ bold: "lucide:bold",
46
+ italic: "lucide:italic",
47
+ underline: "lucide:underline",
48
+ strikethrough: "lucide:strikethrough",
49
+ code: "lucide:code",
50
+ link: "lucide:link",
51
+ unlink: "lucide:unlink",
52
+ list: "lucide:list",
53
+ listOrdered: "lucide:list-ordered",
54
+ quote: "lucide:quote",
55
+ codeXml: "lucide:code-xml",
56
+ removeFormatting: "lucide:remove-formatting",
57
+ palette: "lucide:palette",
58
+ eraser: "lucide:eraser",
59
+ alignLeft: "lucide:align-left",
60
+ alignCenter: "lucide:align-center",
61
+ alignRight: "lucide:align-right",
62
+ listChecks: "lucide:list-checks",
63
+ table: "lucide:table",
64
+ // — tableur (QSpreadsheet) —
65
+ trash2: "lucide:trash-2",
66
+ clipboardPaste: "lucide:clipboard-paste",
67
+ rows3: "lucide:rows-3",
68
+ columns3: "lucide:columns-3",
69
+ arrowAZ: "lucide:arrow-up-a-z",
70
+ arrowZA: "lucide:arrow-down-a-z",
71
+ squarePlus: "lucide:square-plus",
72
+ squareMinus: "lucide:square-minus",
73
+ pencil: "lucide:pencil",
74
+ sortAsc: "lucide:arrow-up-narrow-wide",
75
+ sortDesc: "lucide:arrow-down-wide-narrow",
76
+ selectCheck: "lucide:check",
77
+ filter: "lucide:filter",
78
+ scissors: "lucide:scissors",
79
+ highlighter: "lucide:highlighter",
80
+ tableCellsMerge: "lucide:table-cells-merge",
38
81
  } as const
@@ -0,0 +1,118 @@
1
+ // v-intersection — directive : appelle un handler quand l'élément entre /
2
+ // sort du viewport (Intersection Observer), équivalent Quasar
3
+ // https://quasar.dev/vue-directives/intersection
4
+ //
5
+ // Usage :
6
+ // <div v-intersection="onEnter">…</div>
7
+ // <div v-intersection.once="onFirstEnter">…</div>
8
+ // <div v-intersection="{ handler, cfg: { rootMargin: '0px', threshold: 0.2 } }">…</div>
9
+ // <div v-intersection="false">…</div> (désactive l'observation)
10
+ //
11
+ // Le handler reçoit un IntersectionObserverEntry. Les éléments partageant les
12
+ // mêmes options (cfg) utilisent le même IntersectionObserver sous le capot.
13
+
14
+ export type IntersectionValue =
15
+ | ((entry: IntersectionObserverEntry) => void)
16
+ | { handler: (entry: IntersectionObserverEntry) => void; cfg?: IntersectionObserverInit }
17
+ | false
18
+ | undefined
19
+ | null
20
+
21
+ type PoolEntry = {
22
+ key: string
23
+ observer: IntersectionObserver
24
+ elements: Set<Element>
25
+ }
26
+
27
+ type QElement = HTMLElement & {
28
+ __qIntersection?: {
29
+ key: string
30
+ observer: IntersectionObserver
31
+ handler: (e: IntersectionObserverEntry) => void
32
+ once: boolean
33
+ raw: IntersectionValue
34
+ }
35
+ }
36
+
37
+ const pool = new Map<string, PoolEntry>()
38
+
39
+ function getPoolEntry(cfg: IntersectionObserverInit): PoolEntry {
40
+ const key = JSON.stringify([
41
+ cfg.rootMargin ?? "0px",
42
+ cfg.threshold ?? 0,
43
+ cfg.root === null ? null : cfg.root instanceof Element ? (cfg.root as Element).id || "" : String(cfg.root),
44
+ ])
45
+ let entry = pool.get(key)
46
+ if (!entry) {
47
+ const elements = new Set<Element>()
48
+ const observer = new IntersectionObserver((entries) => {
49
+ for (const e of entries) {
50
+ const rec = (e.target as QElement).__qIntersection
51
+ if (!rec) continue
52
+ if (rec.once && e.isIntersecting) {
53
+ rec.handler(e)
54
+ unobserve(e.target as QElement)
55
+ continue
56
+ }
57
+ rec.handler(e)
58
+ }
59
+ }, cfg)
60
+ entry = { key, observer, elements }
61
+ pool.set(key, entry)
62
+ }
63
+ return entry
64
+ }
65
+
66
+ function unobserve(el: QElement) {
67
+ const rec = el.__qIntersection
68
+ if (!rec) return
69
+ const entry = pool.get(rec.key)
70
+ el.__qIntersection = undefined
71
+ rec.observer.unobserve(el)
72
+ if (entry) {
73
+ entry.elements.delete(el)
74
+ if (entry.elements.size === 0) {
75
+ entry.observer.disconnect()
76
+ pool.delete(rec.key)
77
+ }
78
+ }
79
+ }
80
+
81
+ function apply(el: HTMLElement, value: IntersectionValue, once = false) {
82
+ const target = el as QElement
83
+ if (target.__qIntersection) unobserve(target)
84
+ if (!value) return
85
+
86
+ let handler: (entry: IntersectionObserverEntry) => void
87
+ let cfg: IntersectionObserverInit = { root: null, rootMargin: "0px", threshold: 0 }
88
+
89
+ if (typeof value === "function") {
90
+ handler = value
91
+ }
92
+ else if (typeof value === "object") {
93
+ handler = value.handler
94
+ if (value.cfg) cfg = { ...value.cfg }
95
+ }
96
+ else {
97
+ return
98
+ }
99
+
100
+ const entry = getPoolEntry(cfg)
101
+ target.__qIntersection = { key: entry.key, observer: entry.observer, handler, once, raw: value }
102
+ entry.elements.add(el)
103
+ entry.observer.observe(el)
104
+ }
105
+
106
+ export const vIntersection = {
107
+ mounted(el: HTMLElement, binding: { value?: IntersectionValue; modifiers?: Record<string, boolean> }) {
108
+ apply(el, binding.value, !!binding.modifiers?.once)
109
+ },
110
+ updated(el: HTMLElement, binding: { value?: IntersectionValue; modifiers?: Record<string, boolean> }) {
111
+ const rec = (el as QElement).__qIntersection
112
+ if (rec && rec.raw === binding.value) return
113
+ apply(el, binding.value, !!binding.modifiers?.once)
114
+ },
115
+ unmounted(el: HTMLElement) {
116
+ unobserve(el as QElement)
117
+ },
118
+ }
package/lib/q.ts CHANGED
@@ -14,6 +14,7 @@ import QNotifyToast from "../components/QNotifyToast.vue"
14
14
  import { platform, qBreakpoints } from "./platform"
15
15
  import { screen } from "./screen"
16
16
  import { loading } from "./loading"
17
+ import { localStorage, sessionStorage } from "./storage"
17
18
 
18
19
  export interface DialogOptions {
19
20
  /** Composant de dialogue (SFC importé) ou nom de composant global */
@@ -323,8 +324,9 @@ function notify(opts: NotifyOptions): NotifyController {
323
324
  }
324
325
 
325
326
  // API publique : $q.dialog / $q.bottomSheet / $q.notify / $q.imagePreview / $q.platform / $q.breakpoints / $q.screen / $q.loading
327
+ // / $q.localStorage / $q.sessionStorage (stockage web typé)
326
328
  // Conventions Quasar : .open() pour les piles (dialog, bottomSheet, imagePreview),
327
- // .show() pour notify et loading.
329
+ // .show() pour notify et loading ; méthodes directes pour le stockage.
328
330
  const notifyPlugin = notify as typeof notify & { show: typeof notify }
329
331
  notifyPlugin.show = notify
330
332
 
@@ -343,6 +345,8 @@ export const $q = {
343
345
  breakpoints: qBreakpoints,
344
346
  screen,
345
347
  loading,
348
+ localStorage,
349
+ sessionStorage,
346
350
  }
347
351
 
348
352
  /** Accès programmatique aux plugins ($q.dialog, $q.notify, $q.loading…) */
@@ -0,0 +1,92 @@
1
+ // Tests unitaires des helpers purs du tableur (bun test).
2
+ import { describe, expect, it } from "bun:test"
3
+ import {
4
+ colLetter,
5
+ colFromLetters,
6
+ isBlankValue,
7
+ isoAddDays,
8
+ shiftFormulaRefs,
9
+ csvEscape,
10
+ csvSplitLine,
11
+ parseCsv,
12
+ deriveCols,
13
+ } from "./spreadsheet"
14
+
15
+ describe("lettres de colonnes", () => {
16
+ it("colLetter : 0→A, 25→Z, 26→AA, 27→AB, 701→ZZ, 702→AAA", () => {
17
+ expect(colLetter(0)).toBe("A")
18
+ expect(colLetter(25)).toBe("Z")
19
+ expect(colLetter(26)).toBe("AA")
20
+ expect(colLetter(27)).toBe("AB")
21
+ expect(colLetter(701)).toBe("ZZ")
22
+ expect(colLetter(702)).toBe("AAA")
23
+ })
24
+ it("colFromLetters est l'inverse", () => {
25
+ expect(colFromLetters("A")).toBe(0)
26
+ expect(colFromLetters("AA")).toBe(26)
27
+ expect(colFromLetters("zz")).toBe(701)
28
+ for (let i = 0; i < 200; i++) expect(colFromLetters(colLetter(i))).toBe(i)
29
+ })
30
+ })
31
+
32
+ describe("shiftFormulaRefs (autofill)", () => {
33
+ it("décale les références relatives de (dr,dc)", () => {
34
+ expect(shiftFormulaRefs("=B1*C1", 1, 0)).toBe("=B2*C2")
35
+ expect(shiftFormulaRefs("=A1", 0, 2)).toBe("=C1")
36
+ })
37
+ it("préserve les $ absolus", () => {
38
+ expect(shiftFormulaRefs("=$B$1+A1", 2, 0)).toBe("=$B$1+A3")
39
+ expect(shiftFormulaRefs("=$B1+B$1", 1, 1)).toBe("=$B2+C$1")
40
+ })
41
+ it("décale les plages entières", () => {
42
+ expect(shiftFormulaRefs("=SUM(D1:D4)", 1, 0)).toBe("=SUM(D2:D5)")
43
+ })
44
+ it("laisse les valeurs sans lettres intactes", () => {
45
+ expect(shiftFormulaRefs("=1+2", 1, 1)).toBe("=1+2")
46
+ })
47
+ })
48
+
49
+ describe("dates ISO", () => {
50
+ it("isoAddDays", () => {
51
+ expect(isoAddDays("2026-09-07", 1)).toBe("2026-09-08")
52
+ expect(isoAddDays("2026-09-07", -7)).toBe("2026-08-31")
53
+ expect(isoAddDays("2026-12-31", 1)).toBe("2027-01-01")
54
+ })
55
+ it("isBlankValue", () => {
56
+ expect(isBlankValue(null)).toBe(true)
57
+ expect(isBlankValue(undefined)).toBe(true)
58
+ expect(isBlankValue("")).toBe(true)
59
+ expect(isBlankValue(0)).toBe(false)
60
+ expect(isBlankValue("0")).toBe(false)
61
+ })
62
+ })
63
+
64
+ describe("CSV", () => {
65
+ it("csvEscape quote si nécessaire", () => {
66
+ expect(csvEscape("simple", ",")).toBe("simple")
67
+ expect(csvEscape("a,b", ",")).toBe('"a,b"')
68
+ expect(csvEscape('say "hi"', ",")).toBe('"say ""hi"""')
69
+ expect(csvEscape("ligne\nsuivante", ";")).toBe('"ligne\nsuivante"')
70
+ expect(csvEscape(null, ",")).toBe("")
71
+ })
72
+ it("csvSplitLine gère les guillemets doublés et les délimiteurs", () => {
73
+ expect(csvSplitLine('a,"b,c",d', ",")).toEqual(["a", "b,c", "d"])
74
+ expect(csvSplitLine('"x""y",z', ",")).toEqual(['x"y', "z"])
75
+ expect(csvSplitLine("a;b", ";")).toEqual(["a", "b"])
76
+ })
77
+ it("parseCsv : multiligne, quotes, CRLF", () => {
78
+ const text = 'h1,h2\r\n"a,1","b""2"\r\nc,d\n'
79
+ expect(parseCsv(text)).toEqual([
80
+ ["h1", "h2"],
81
+ ["a,1", 'b"2'],
82
+ ["c", "d"],
83
+ ])
84
+ })
85
+ it("deriveCols déduit les colonnes des clés", () => {
86
+ expect(deriveCols([{ a: 1, b: "x" }, { b: 2, c: true }])).toEqual([
87
+ { name: "a", type: "text" },
88
+ { name: "b", type: "text" },
89
+ { name: "c", type: "text" },
90
+ ])
91
+ })
92
+ })