@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/components/QBar.vue +42 -0
- package/components/QBoard.vue +285 -0
- package/components/QConfigProvider.vue +9 -5
- package/components/QDialog.vue +26 -12
- package/components/QEditorJs.vue +146 -0
- package/components/QFab.vue +19 -2
- package/components/QInteract.vue +387 -0
- package/components/QSpreadsheet.vue +4662 -0
- package/components/QSyntax.vue +22 -9
- package/components/QTiptap.vue +993 -0
- package/components/internal/QTiptapTableView.vue +123 -0
- package/components/internal/QTiptapTaskItemView.vue +49 -0
- package/index.ts +18 -0
- package/lib/config.ts +6 -0
- package/lib/formula.test.ts +206 -0
- package/lib/formula.ts +292 -0
- package/lib/icons.ts +43 -0
- package/lib/intersection.ts +118 -0
- package/lib/q.ts +5 -1
- package/lib/spreadsheet.test.ts +92 -0
- package/lib/spreadsheet.ts +175 -0
- package/lib/storage.ts +175 -0
- package/lib/tiptap-table.ts +19 -0
- package/lib/tiptap-task-list.ts +16 -0
- package/lib/touchHold.ts +221 -0
- package/lib/touchPan.ts +237 -0
- package/lib/touchRepeat.ts +148 -0
- package/lib/touchSwipe.ts +182 -0
- package/lib/useComponentProps.ts +7 -0
- package/module.ts +10 -0
- package/package.json +27 -1
- package/scripts/generate-exports.ts +12 -0
- package/styles/main.css +2217 -32
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QTiptapTableView — NodeView Vue du nœud « table » : rend un vrai <table>
|
|
3
|
+
// avec le contentDOM ProseMirror dans le <tbody>. Les rangées/cellules restent
|
|
4
|
+
// du contenu ProseMirror classique (sélection, merge/split, clavier).
|
|
5
|
+
// Ajouts maison : colgroup + poignées de redimensionnement de colonnes
|
|
6
|
+
// (drag), appliqué localement via des largeurs en % (non persistées dans le
|
|
7
|
+
// document — pour persister il faudrait écrire les attrs colwidth des cellules).
|
|
8
|
+
import { computed, onBeforeUnmount, ref, watch } from "vue"
|
|
9
|
+
import { nodeViewProps, NodeViewContent, NodeViewWrapper } from "@tiptap/vue-3"
|
|
10
|
+
|
|
11
|
+
const props = defineProps(nodeViewProps)
|
|
12
|
+
|
|
13
|
+
// Nombre de colonnes = largeur de la première rangée (les suivantes suivent
|
|
14
|
+
// les rowspans/colspans du schéma).
|
|
15
|
+
const cols = computed(() => {
|
|
16
|
+
const first = props.node?.firstChild
|
|
17
|
+
return first ? first.childCount : 0
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// Largeurs en % (initialement égales) ; recalculees quand le nombre de
|
|
21
|
+
// colonnes change (ajout/suppression).
|
|
22
|
+
const widths = ref<number[]>([])
|
|
23
|
+
watch(
|
|
24
|
+
cols,
|
|
25
|
+
(count) => {
|
|
26
|
+
if (count === widths.value.length) return
|
|
27
|
+
const each = count > 0 ? 100 / count : 0
|
|
28
|
+
widths.value = Array.from({ length: count }, () => each)
|
|
29
|
+
},
|
|
30
|
+
{ immediate: true },
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// — Drag de redimensionnement (poignées entre colonnes) —
|
|
34
|
+
const tableEl = ref<HTMLElement | null>(null)
|
|
35
|
+
let dragging = false
|
|
36
|
+
let boundary = 0
|
|
37
|
+
let startX = 0
|
|
38
|
+
let leftStart = 0
|
|
39
|
+
let rightStart = 0
|
|
40
|
+
let tableWidth = 0
|
|
41
|
+
|
|
42
|
+
const onMove = (event: PointerEvent) => {
|
|
43
|
+
if (!dragging) return
|
|
44
|
+
const delta = event.clientX - startX
|
|
45
|
+
const minPx = 48
|
|
46
|
+
let leftPx = leftStart + delta
|
|
47
|
+
let rightPx = rightStart - delta
|
|
48
|
+
if (leftPx < minPx) {
|
|
49
|
+
rightPx -= minPx - leftPx
|
|
50
|
+
leftPx = minPx
|
|
51
|
+
}
|
|
52
|
+
if (rightPx < minPx) {
|
|
53
|
+
leftPx -= minPx - rightPx
|
|
54
|
+
rightPx = minPx
|
|
55
|
+
}
|
|
56
|
+
if (leftPx < minPx || rightPx < minPx) return
|
|
57
|
+
const total = tableWidth || 1
|
|
58
|
+
const next = [...widths.value]
|
|
59
|
+
next[boundary - 1] = (leftPx / total) * 100
|
|
60
|
+
next[boundary] = (rightPx / total) * 100
|
|
61
|
+
widths.value = next
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const onUp = () => {
|
|
65
|
+
dragging = false
|
|
66
|
+
window.removeEventListener("pointermove", onMove)
|
|
67
|
+
window.removeEventListener("pointerup", onUp)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const startResize = (event: PointerEvent, boundaryIndex: number) => {
|
|
71
|
+
if (!props.editor.isEditable) return
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
const table = tableEl.value
|
|
74
|
+
if (!table) return
|
|
75
|
+
const row = table.querySelector("tr")
|
|
76
|
+
if (!row) return
|
|
77
|
+
const leftCell = row.children[boundaryIndex - 1] as HTMLElement | undefined
|
|
78
|
+
const rightCell = row.children[boundaryIndex] as HTMLElement | undefined
|
|
79
|
+
if (!leftCell || !rightCell) return
|
|
80
|
+
tableWidth = table.getBoundingClientRect().width || 1
|
|
81
|
+
leftStart = leftCell.getBoundingClientRect().width
|
|
82
|
+
rightStart = rightCell.getBoundingClientRect().width
|
|
83
|
+
startX = event.clientX
|
|
84
|
+
boundary = boundaryIndex
|
|
85
|
+
dragging = true
|
|
86
|
+
window.addEventListener("pointermove", onMove)
|
|
87
|
+
window.addEventListener("pointerup", onUp)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onBeforeUnmount(() => {
|
|
91
|
+
window.removeEventListener("pointermove", onMove)
|
|
92
|
+
window.removeEventListener("pointerup", onUp)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// Position d'une poignée (bord droit de la colonne index) en %
|
|
96
|
+
const boundaryLeft = (index: number) => {
|
|
97
|
+
const acc = widths.value.slice(0, index).reduce((sum, w) => sum + w, 0)
|
|
98
|
+
return Math.min(acc, 99.5)
|
|
99
|
+
}
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<template>
|
|
103
|
+
<node-view-wrapper as="div" class="q-tiptap__table-wrapper">
|
|
104
|
+
<table ref="tableEl" class="q-tiptap__table" :class="{ 'q-tiptap__table--resizable': editor.isEditable }">
|
|
105
|
+
<colgroup v-if="widths.length">
|
|
106
|
+
<col v-for="(width, i) in widths" :key="i" :style="{ width: width + '%' }" />
|
|
107
|
+
</colgroup>
|
|
108
|
+
<node-view-content as="tbody" />
|
|
109
|
+
</table>
|
|
110
|
+
|
|
111
|
+
<!-- Poignées de redimensionnement entre les colonnes (éditable seulement) -->
|
|
112
|
+
<span
|
|
113
|
+
v-for="index in Math.max(cols - 1, 0)"
|
|
114
|
+
:key="'handle-' + index"
|
|
115
|
+
class="q-tiptap__resize-handle"
|
|
116
|
+
:style="{ left: boundaryLeft(index) + '%' }"
|
|
117
|
+
role="separator"
|
|
118
|
+
aria-orientation="vertical"
|
|
119
|
+
:aria-label="'Resize column ' + index"
|
|
120
|
+
@pointerdown.prevent.stop="startResize($event, index)"
|
|
121
|
+
/>
|
|
122
|
+
</node-view-wrapper>
|
|
123
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QTiptapTaskItemView — NodeView Vue du nœud « taskItem » : rend une vraie
|
|
3
|
+
// <q-checkbox> dnax.ui (au lieu du <input> natif du DOM par défaut).
|
|
4
|
+
// Le nœud est fourni par l'extension officielle TaskItem (schéma, keymap
|
|
5
|
+
// Enter/Tab, input rules "[ ]" / "[x]") dont on ne remplace que addNodeView.
|
|
6
|
+
//
|
|
7
|
+
// ⚠ NodeView Vue : la RACINE DOIT être <node-view-wrapper> (erreur Tiptap
|
|
8
|
+
// « Please use the NodeViewWrapper component » sinon). En v3 ce wrapper ne
|
|
9
|
+
// propage pas les attrs → data-checked/class cochée portés par le div interne
|
|
10
|
+
// qui contient <node-view-content> (le contentDOM peut être imbriqué).
|
|
11
|
+
import { nodeViewProps, NodeViewContent, NodeViewWrapper } from "@tiptap/vue-3"
|
|
12
|
+
import QCheckbox from "../QCheckbox.vue"
|
|
13
|
+
|
|
14
|
+
// nodeViewProps : descripteurs runtime officiels (editor, node, updateAttributes,
|
|
15
|
+
// getPos…) — pattern documenté pour les NodeViews Vue dans Tiptap.
|
|
16
|
+
const props = defineProps(nodeViewProps)
|
|
17
|
+
|
|
18
|
+
const checked = () => !!props.node.attrs.checked
|
|
19
|
+
const isEditable = () => props.editor.isEditable
|
|
20
|
+
|
|
21
|
+
const onToggle = (value: unknown) => {
|
|
22
|
+
if (!isEditable()) return
|
|
23
|
+
if (typeof props.getPos !== "function") return
|
|
24
|
+
props.editor.chain().focus().command(({ tr }) => {
|
|
25
|
+
const position = props.getPos()
|
|
26
|
+
if (typeof position !== "number") return false
|
|
27
|
+
const current = tr.doc.nodeAt(position)
|
|
28
|
+
tr.setNodeMarkup(position, undefined, { ...current?.attrs, checked: Boolean(value) })
|
|
29
|
+
return true
|
|
30
|
+
}).run()
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<node-view-wrapper as="li">
|
|
36
|
+
<label class="q-tiptap__task-check" contenteditable="false" @mousedown.prevent>
|
|
37
|
+
<q-checkbox
|
|
38
|
+
:model-value="checked()"
|
|
39
|
+
dense
|
|
40
|
+
:disable="!isEditable()"
|
|
41
|
+
aria-label="Task done"
|
|
42
|
+
@update:model-value="onToggle"
|
|
43
|
+
/>
|
|
44
|
+
</label>
|
|
45
|
+
<div class="q-tiptap__task-content" :class="{ 'q-tiptap__task-content--checked': checked() }">
|
|
46
|
+
<node-view-content />
|
|
47
|
+
</div>
|
|
48
|
+
</node-view-wrapper>
|
|
49
|
+
</template>
|
package/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { default as QAvatar } from "./components/QAvatar.vue"
|
|
|
9
9
|
export { default as QBackHeader } from "./components/QBackHeader.vue"
|
|
10
10
|
export { default as QBackTop } from "./components/QBackTop.vue"
|
|
11
11
|
export { default as QBadge } from "./components/QBadge.vue"
|
|
12
|
+
export { default as QBar } from "./components/QBar.vue"
|
|
13
|
+
export { default as QBoard } from "./components/QBoard.vue"
|
|
12
14
|
export { default as QBottomSheet } from "./components/QBottomSheet.vue"
|
|
13
15
|
export { default as QBottomSheetFooter } from "./components/QBottomSheetFooter.vue"
|
|
14
16
|
export { default as QBottomSheetHeader } from "./components/QBottomSheetHeader.vue"
|
|
@@ -44,6 +46,7 @@ export { default as QDialogFooter } from "./components/QDialogFooter.vue"
|
|
|
44
46
|
export { default as QDialogHeader } from "./components/QDialogHeader.vue"
|
|
45
47
|
export { default as QDialogProvider } from "./components/QDialogProvider.vue"
|
|
46
48
|
export { default as QDialogTrigger } from "./components/QDialogTrigger.vue"
|
|
49
|
+
export { default as QEditorJs } from "./components/QEditorJs.vue"
|
|
47
50
|
export { default as QFab } from "./components/QFab.vue"
|
|
48
51
|
export { default as QFabAction } from "./components/QFabAction.vue"
|
|
49
52
|
export { default as QFilePicker } from "./components/QFilePicker.vue"
|
|
@@ -63,6 +66,7 @@ export { default as QInput } from "./components/QInput.vue"
|
|
|
63
66
|
export { default as QInputOtp } from "./components/QInputOtp.vue"
|
|
64
67
|
export { default as QInputPassword } from "./components/QInputPassword.vue"
|
|
65
68
|
export { default as QInputTag } from "./components/QInputTag.vue"
|
|
69
|
+
export { default as QInteract } from "./components/QInteract.vue"
|
|
66
70
|
export { default as QIntersection } from "./components/QIntersection.vue"
|
|
67
71
|
export { default as QItem } from "./components/QItem.vue"
|
|
68
72
|
export { default as QItemSection } from "./components/QItemSection.vue"
|
|
@@ -110,6 +114,7 @@ export { default as QSlider } from "./components/QSlider.vue"
|
|
|
110
114
|
export { default as QSpace } from "./components/QSpace.vue"
|
|
111
115
|
export { default as QSpinner } from "./components/QSpinner.vue"
|
|
112
116
|
export { default as QSplitter } from "./components/QSplitter.vue"
|
|
117
|
+
export { default as QSpreadsheet } from "./components/QSpreadsheet.vue"
|
|
113
118
|
export { default as QSticky } from "./components/QSticky.vue"
|
|
114
119
|
export { default as QSwipeCell } from "./components/QSwipeCell.vue"
|
|
115
120
|
export { default as QSwiper } from "./components/QSwiper.vue"
|
|
@@ -122,6 +127,7 @@ export { default as QTable } from "./components/QTable.vue"
|
|
|
122
127
|
export { default as QTabs } from "./components/QTabs.vue"
|
|
123
128
|
export { default as QText } from "./components/QText.vue"
|
|
124
129
|
export { default as QTextCaption } from "./components/QTextCaption.vue"
|
|
130
|
+
export { default as QTiptap } from "./components/QTiptap.vue"
|
|
125
131
|
export { default as QToolbar } from "./components/QToolbar.vue"
|
|
126
132
|
export { default as QTooltip } from "./components/QTooltip.vue"
|
|
127
133
|
export { default as QUploader } from "./components/QUploader.vue"
|
|
@@ -144,6 +150,8 @@ export { screen } from "./lib/screen"
|
|
|
144
150
|
export type { QScreen } from "./lib/screen"
|
|
145
151
|
export { loading } from "./lib/loading"
|
|
146
152
|
export type { LoadingOptions, QLoadingPlugin } from "./lib/loading"
|
|
153
|
+
export { localStorage, sessionStorage } from "./lib/storage"
|
|
154
|
+
export type { QWebStorage } from "./lib/storage"
|
|
147
155
|
export {
|
|
148
156
|
registerOverlay,
|
|
149
157
|
unregisterOverlay,
|
|
@@ -154,3 +162,13 @@ export {
|
|
|
154
162
|
} from "./lib/overlayBack"
|
|
155
163
|
export type { OverlayHandle } from "./lib/overlayBack"
|
|
156
164
|
export { markOverlayClose, closeParentOverlay, vClose } from "./lib/closeOverlay"
|
|
165
|
+
export { vTouchPan } from "./lib/touchPan"
|
|
166
|
+
export type { PanDetails, TouchPanHandler, PanDirection } from "./lib/touchPan"
|
|
167
|
+
export { vTouchHold } from "./lib/touchHold"
|
|
168
|
+
export type { TouchHoldDetails, TouchHoldHandler } from "./lib/touchHold"
|
|
169
|
+
export { vTouchSwipe } from "./lib/touchSwipe"
|
|
170
|
+
export type { TouchSwipeDetails, TouchSwipeHandler, SwipeDirection } from "./lib/touchSwipe"
|
|
171
|
+
export { vTouchRepeat } from "./lib/touchRepeat"
|
|
172
|
+
export type { TouchRepeatDetails, TouchRepeatHandler } from "./lib/touchRepeat"
|
|
173
|
+
export { vIntersection } from "./lib/intersection"
|
|
174
|
+
export type { IntersectionValue } from "./lib/intersection"
|
package/lib/config.ts
CHANGED
|
@@ -14,10 +14,14 @@ export interface QThemeColors {
|
|
|
14
14
|
warning?: string
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export type QAppLang = "en" | "fr"
|
|
18
|
+
|
|
17
19
|
export interface QTheme {
|
|
18
20
|
/** Mode clair/sombre : light | dark | system (défaut : system = préférence OS) */
|
|
19
21
|
mode?: ThemeMode
|
|
20
22
|
colors?: QThemeColors
|
|
23
|
+
/** Langue par défaut appliquée aux composants qui la supportent */
|
|
24
|
+
lang?: QAppLang
|
|
21
25
|
/** Props par défaut par composant (clé = nom du composant, ex. "QBtn") */
|
|
22
26
|
componentProps?: Record<string, Record<string, unknown>>
|
|
23
27
|
}
|
|
@@ -26,6 +30,8 @@ export interface QConfigContext {
|
|
|
26
30
|
theme: Ref<QTheme>
|
|
27
31
|
/** true si le mode effectif est sombre (résout "system") */
|
|
28
32
|
isDark: Readonly<Ref<boolean>>
|
|
33
|
+
/** Langue de l'application ("en" par défaut) */
|
|
34
|
+
lang: Readonly<Ref<QAppLang>>
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export const qConfigKey: InjectionKey<QConfigContext> = Symbol("q-config")
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Tests unitaires du moteur de formules A1 (bun test).
|
|
2
|
+
import { describe, expect, it } from "bun:test"
|
|
3
|
+
import { evaluateFormula, isError } from "./formula"
|
|
4
|
+
import type { FormulaValue } from "./formula"
|
|
5
|
+
|
|
6
|
+
type Grid = (string | number | boolean | null)[][]
|
|
7
|
+
|
|
8
|
+
function harness(grid: Grid, formulaRow = 0) {
|
|
9
|
+
return (f: string): FormulaValue =>
|
|
10
|
+
evaluateFormula(f, {
|
|
11
|
+
row: formulaRow,
|
|
12
|
+
resolveField: () => undefined,
|
|
13
|
+
resolveCell: (r, c) => {
|
|
14
|
+
const row = grid[r]
|
|
15
|
+
return row && c >= 0 && c < row.length
|
|
16
|
+
? (row[c] as FormulaValue | undefined)
|
|
17
|
+
: undefined
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("opérateurs & références", () => {
|
|
23
|
+
const grid: Grid = [
|
|
24
|
+
[1, 2, 3],
|
|
25
|
+
[10, 20, 30],
|
|
26
|
+
]
|
|
27
|
+
const e = harness(grid)
|
|
28
|
+
it("arithmétique et parenthèses", () => {
|
|
29
|
+
expect(e("=1+2*3")).toBe(7)
|
|
30
|
+
expect(e("=(1+2)*3")).toBe(9)
|
|
31
|
+
expect(e("=2^3")).toBe(8)
|
|
32
|
+
expect(e("=10%")).toBe(0.1)
|
|
33
|
+
expect(e("=200*10%")).toBe(20)
|
|
34
|
+
})
|
|
35
|
+
it("références A1 / plages / concat", () => {
|
|
36
|
+
expect(e("=A1+B1")).toBe(3)
|
|
37
|
+
expect(e("=A2*C1")).toBe(30)
|
|
38
|
+
expect(e('="x"&A1')).toBe("x1")
|
|
39
|
+
})
|
|
40
|
+
it("comparaisons", () => {
|
|
41
|
+
expect(e("=B2>B1")).toBe(true)
|
|
42
|
+
expect(e('=A1="1"')).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
it("référence hors grille → #REF!", () => {
|
|
45
|
+
expect(String(e("=Z99"))).toBe("#REF!")
|
|
46
|
+
})
|
|
47
|
+
it("division par zéro → #DIV/0!", () => {
|
|
48
|
+
expect(String(e("=1/0"))).toBe("#DIV/0!")
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("fonctions agrégation", () => {
|
|
53
|
+
const grid: Grid = [
|
|
54
|
+
[5, "x", 10],
|
|
55
|
+
[null, 2, 3],
|
|
56
|
+
]
|
|
57
|
+
const e = harness(grid)
|
|
58
|
+
it("SUM / AVERAGE / MIN / MAX sur plages", () => {
|
|
59
|
+
expect(e("=SUM(A1:C2)")).toBe(20)
|
|
60
|
+
expect(e("=AVERAGE(A1,C1,C2)")).toBe(6)
|
|
61
|
+
expect(e("=MIN(A1:C2)")).toBe(2)
|
|
62
|
+
expect(e("=MAX(A1:C2)")).toBe(10)
|
|
63
|
+
})
|
|
64
|
+
it("COUNT compte les nombres, COUNTA les non-vides", () => {
|
|
65
|
+
expect(e("=COUNT(A1:C2)")).toBe(4)
|
|
66
|
+
expect(e("=COUNTA(A1:C2)")).toBe(5)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe("fonctions logiques (lazy)", () => {
|
|
71
|
+
const e = harness([[]])
|
|
72
|
+
it("IF n'évalue que la branche prise", () => {
|
|
73
|
+
expect(e("=IF(1,1,1/0)")).toBe(1)
|
|
74
|
+
expect(e('=IF(0,1,"no")')).toBe("no")
|
|
75
|
+
})
|
|
76
|
+
it("IFS premier vrai", () => {
|
|
77
|
+
expect(e('=IFS(1>2,"a",1<2,"b")')).toBe("b")
|
|
78
|
+
expect(String(e("=IFS(0,1)"))).toBe("#N/A")
|
|
79
|
+
})
|
|
80
|
+
it("SWITCH avec défaut", () => {
|
|
81
|
+
expect(e('=SWITCH(2,1,"un",2,"deux","autre")')).toBe("deux")
|
|
82
|
+
expect(e('=SWITCH(9,1,"un","autre")')).toBe("autre")
|
|
83
|
+
})
|
|
84
|
+
it("IFERROR / IFNA", () => {
|
|
85
|
+
expect(e('=IFERROR(1/0,"ko")')).toBe("ko")
|
|
86
|
+
expect(e('=IFERROR(2,"ko")')).toBe(2)
|
|
87
|
+
})
|
|
88
|
+
it("AND / OR / NOT", () => {
|
|
89
|
+
expect(e("=AND(1,1>0)")).toBe(true)
|
|
90
|
+
expect(e("=OR(0,1=1)")).toBe(true)
|
|
91
|
+
expect(e("=NOT(0)")).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("VLOOKUP", () => {
|
|
96
|
+
const grid: Grid = [
|
|
97
|
+
["A", 1, "x"],
|
|
98
|
+
["B", 2, "y"],
|
|
99
|
+
["A", 3, "z"],
|
|
100
|
+
]
|
|
101
|
+
const e = harness(grid)
|
|
102
|
+
it("recherche exacte dans la 1re colonne", () => {
|
|
103
|
+
expect(e('=VLOOKUP("B",A1:C3,2)')).toBe(2)
|
|
104
|
+
expect(e('=VLOOKUP("A",A1:C3,3)')).toBe("x")
|
|
105
|
+
})
|
|
106
|
+
it("absence → #N/A, colonne hors plage → #REF!", () => {
|
|
107
|
+
expect(String(e('=VLOOKUP("Z",A1:C3,2)'))).toBe("#N/A")
|
|
108
|
+
expect(String(e('=VLOOKUP("A",A1:C3,5)'))).toBe("#REF!")
|
|
109
|
+
})
|
|
110
|
+
it("pas une plage → #VALUE!", () => {
|
|
111
|
+
expect(String(e('=VLOOKUP("A",A1,2)'))).toBe("#VALUE!")
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe("XLOOKUP + dates avancées", () => {
|
|
116
|
+
const grid: Grid = [
|
|
117
|
+
["A", 1, "x"],
|
|
118
|
+
["B", 2, "y"],
|
|
119
|
+
["A", 3, "z"],
|
|
120
|
+
]
|
|
121
|
+
const e = harness(grid)
|
|
122
|
+
it("XLOOKUP retourne la cellule correspondante du range retour", () => {
|
|
123
|
+
expect(e('=XLOOKUP("B",A1:A3,B1:B3)')).toBe(2)
|
|
124
|
+
expect(e('=XLOOKUP("A",A1:A3,C1:C3)')).toBe("x")
|
|
125
|
+
})
|
|
126
|
+
it("XLOOKUP fallback / #N/A", () => {
|
|
127
|
+
expect(String(e('=XLOOKUP("Z",A1:A3,B1:B3,"?")'))).toBe("?")
|
|
128
|
+
expect(String(e('=XLOOKUP("Z",A1:A3,B1:B3)'))).toBe("#N/A")
|
|
129
|
+
})
|
|
130
|
+
it("WEEKDAY / EOMONTH / DATEDIF / TEXT", () => {
|
|
131
|
+
const d = harness([[]])
|
|
132
|
+
// 2026-09-07 = lundi
|
|
133
|
+
expect(d('=WEEKDAY("2026-09-07",2)')).toBe(1)
|
|
134
|
+
expect(d('=EOMONTH("2026-01-15",0)')).toBe("2026-01-31")
|
|
135
|
+
expect(d('=DATEDIF("2026-01-01","2026-02-10","D")')).toBe(40)
|
|
136
|
+
expect(d('=TEXT(1.5,"0.00")')).toBe("1.50")
|
|
137
|
+
expect(d('=TEXT(0.12,"0%")')).toBe("12%")
|
|
138
|
+
expect(d('=TEXT("2026-09-07","YYYY-MM-DD")')).toBe("2026-09-07")
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe("fonctions texte", () => {
|
|
143
|
+
const e = harness([[]])
|
|
144
|
+
it("LEFT / RIGHT / MID", () => {
|
|
145
|
+
expect(e('=LEFT("Hello",2)')).toBe("He")
|
|
146
|
+
expect(e('=RIGHT("Hello",2)')).toBe("lo")
|
|
147
|
+
expect(e('=MID("Hello",2,3)')).toBe("ell")
|
|
148
|
+
})
|
|
149
|
+
it("FIND / SUBSTITUTE / REPLACE / CONCAT", () => {
|
|
150
|
+
expect(e('=FIND("llo","Hello")')).toBe(3)
|
|
151
|
+
expect(e('=SUBSTITUTE("a-b-a","a","x",2)')).toBe("a-b-x")
|
|
152
|
+
expect(e('=SUBSTITUTE("a-b-a","a","x")')).toBe("x-b-x")
|
|
153
|
+
expect(e('=REPLACE("Hello",1,2,"XY")')).toBe("XYllo")
|
|
154
|
+
expect(e('=CONCATENATE("a",1)')).toBe("a1")
|
|
155
|
+
})
|
|
156
|
+
it("UPPER / LOWER / TRIM / LEN", () => {
|
|
157
|
+
expect(e('=UPPER("aB")')).toBe("AB")
|
|
158
|
+
expect(e('=TRIM(" x ")')).toBe("x")
|
|
159
|
+
expect(e('=LEN("abc")')).toBe(3)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe("fonctions dates", () => {
|
|
164
|
+
const e = harness([[]])
|
|
165
|
+
it("DATE construit en UTC", () => {
|
|
166
|
+
expect(e("=DATE(2026,9,7)")).toBe("2026-09-07")
|
|
167
|
+
})
|
|
168
|
+
it("YEAR / MONTH / DAY", () => {
|
|
169
|
+
expect(e('=YEAR("2026-09-07")')).toBe(2026)
|
|
170
|
+
expect(e('=MONTH("2026-09-07")')).toBe(9)
|
|
171
|
+
expect(e('=DAY("2026-09-07")')).toBe(7)
|
|
172
|
+
})
|
|
173
|
+
it("EDATE", () => {
|
|
174
|
+
expect(e('=EDATE("2026-01-15",1)')).toBe("2026-02-15")
|
|
175
|
+
})
|
|
176
|
+
it("TODAY / NOW / RAND sont valides", () => {
|
|
177
|
+
const t = e("=TODAY()")
|
|
178
|
+
expect(typeof t).toBe("string")
|
|
179
|
+
expect(String(t)).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
|
180
|
+
const r = e("=RAND()")
|
|
181
|
+
expect(typeof r).toBe("number")
|
|
182
|
+
expect(Number(r)).toBeGreaterThanOrEqual(0)
|
|
183
|
+
expect(Number(r)).toBeLessThan(1)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe("math & erreurs de type", () => {
|
|
188
|
+
const e = harness([[]])
|
|
189
|
+
it("ABS / ROUND / INT / MOD / SQRT / POWER", () => {
|
|
190
|
+
expect(e("=ABS(-4)")).toBe(4)
|
|
191
|
+
expect(e("=ROUND(1.234,2)")).toBe(1.23)
|
|
192
|
+
expect(e("=INT(-3.7)")).toBe(-3)
|
|
193
|
+
expect(e("=MOD(7,3)")).toBe(1)
|
|
194
|
+
expect(e("=SQRT(16)")).toBe(4)
|
|
195
|
+
expect(e("=POWER(2,3)")).toBe(8)
|
|
196
|
+
})
|
|
197
|
+
it("#NAME? pour une fonction inconnue", () => {
|
|
198
|
+
expect(String(e("=NOPE(1)"))).toBe("#NAME?")
|
|
199
|
+
})
|
|
200
|
+
it("#VALUE! pour du texte en numéro", () => {
|
|
201
|
+
expect(String(e('=1+"a"'))).toBe("#VALUE!")
|
|
202
|
+
})
|
|
203
|
+
it("isError détecte FormulaError", () => {
|
|
204
|
+
expect(isError(e("=1/0"))).toBe(true)
|
|
205
|
+
})
|
|
206
|
+
})
|