@dnax/ui 0.0.48 → 0.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/components/QBottomSheet.vue +172 -6
  2. package/components/QChart.vue +1 -0
  3. package/components/QDatePicker.vue +1 -1
  4. package/components/QImagePicker.vue +28 -1
  5. package/components/QInputChat.vue +859 -0
  6. package/components/QInputCurrency.vue +319 -0
  7. package/components/QItemLabel.vue +99 -0
  8. package/components/QMap.vue +410 -0
  9. package/components/QNumericKeyboard.vue +304 -0
  10. package/components/QPage.vue +26 -6
  11. package/components/QPageContainer.vue +32 -0
  12. package/components/QPopupProxy.vue +378 -0
  13. package/components/QQrcode.vue +132 -0
  14. package/components/QSelect.vue +12 -0
  15. package/components/QSpreadsheet.vue +395 -69
  16. package/components/QTime.vue +845 -0
  17. package/components/QTimeline.vue +111 -0
  18. package/components/QTimelineEntry.vue +271 -0
  19. package/index.ts +11 -1
  20. package/lib/bottomSheet.test.ts +121 -0
  21. package/lib/bottomSheet.ts +115 -0
  22. package/lib/color.ts +13 -0
  23. package/lib/currency.test.ts +193 -0
  24. package/lib/currency.ts +301 -0
  25. package/lib/fixedLayout.ts +12 -11
  26. package/lib/icons.ts +3 -20
  27. package/lib/map.test.ts +365 -0
  28. package/lib/map.ts +438 -0
  29. package/lib/mapEngine.ts +158 -0
  30. package/lib/mapLeaflet.ts +142 -0
  31. package/lib/mapMaplibre.ts +106 -0
  32. package/lib/mapMaptiler.ts +93 -0
  33. package/lib/numericKeyboard.test.ts +179 -0
  34. package/lib/numericKeyboard.ts +166 -0
  35. package/lib/pagePadding.test.ts +37 -0
  36. package/lib/pagePadding.ts +32 -0
  37. package/lib/qrcode.test.ts +103 -0
  38. package/lib/qrcode.ts +125 -0
  39. package/lib/spreadsheetChanges.test.ts +230 -0
  40. package/lib/spreadsheetChanges.ts +331 -0
  41. package/package.json +5 -17
  42. package/shims.d.ts +19 -0
  43. package/styles/main.css +689 -884
  44. package/components/QTiptap.vue +0 -996
  45. package/components/internal/QTiptapTableView.vue +0 -123
  46. package/components/internal/QTiptapTaskItemView.vue +0 -49
  47. package/lib/tiptap-table.ts +0 -19
  48. package/lib/tiptap-task-list.ts +0 -16
@@ -0,0 +1,37 @@
1
+ // Tests unitaires du padding utilisateur de QPage (prop `padding`).
2
+ import { describe, expect, it } from "bun:test"
3
+ import { PAGE_PADDING, resolvePagePadding } from "./pagePadding"
4
+
5
+ describe("resolvePagePadding", () => {
6
+ it("applique 14px sur la prop booléenne (modifier)", () => {
7
+ expect(resolvePagePadding(true)).toBe(PAGE_PADDING)
8
+ expect(resolvePagePadding(true)).toBe("14px")
9
+ })
10
+
11
+ it("n'applique aucun padding si la prop est absente ou fausse", () => {
12
+ expect(resolvePagePadding()).toBeUndefined()
13
+ expect(resolvePagePadding(false)).toBeUndefined()
14
+ expect(resolvePagePadding("")).toBeUndefined()
15
+ expect(resolvePagePadding(" ")).toBeUndefined()
16
+ expect(resolvePagePadding(null)).toBeUndefined()
17
+ })
18
+
19
+ it("rend la valeur CSS explicite telle quelle", () => {
20
+ expect(resolvePagePadding("12px")).toBe("12px")
21
+ expect(resolvePagePadding(" 2rem ")).toBe("2rem")
22
+ // Plusieurs valeurs : la prop reste une longueur CSS quelconque
23
+ expect(resolvePagePadding("12px 24px")).toBe("12px 24px")
24
+ })
25
+
26
+ it("ajoute l'unité manquante (une longueur nue invaliderait le calc())", () => {
27
+ expect(resolvePagePadding("12")).toBe("12px")
28
+ expect(resolvePagePadding("1.5")).toBe("1.5px")
29
+ expect(resolvePagePadding(18)).toBe("18px")
30
+ expect(resolvePagePadding(0)).toBe("0px")
31
+ })
32
+
33
+ it("ignore les nombres non finis", () => {
34
+ expect(resolvePagePadding(Number.NaN)).toBeUndefined()
35
+ expect(resolvePagePadding(Number.POSITIVE_INFINITY)).toBeUndefined()
36
+ })
37
+ })
@@ -0,0 +1,32 @@
1
+ // pagePadding — padding utilisateur du QPage (prop `padding`).
2
+ //
3
+ // Quasar n'expose qu'un booléen (`padding` = 16px) ; dnax.ui accepte aussi une valeur
4
+ // CSS explicite (`padding="12px"`), d'où la normalisation :
5
+ // - `true` (modifier, `<q-page padding>`) → PAGE_PADDING (14px, défaut du DS)
6
+ // - nombre ou chaîne numérique ("12") → "12px" (une longueur sans unité
7
+ // invaliderait tout le `calc()` de composition dans styles/main.css)
8
+ // - toute autre chaîne ("2rem", "12px 24px") → rendue telle quelle
9
+ // - absent, `false` ou chaîne vide → aucun padding
10
+ //
11
+ // La longueur retournée est posée dans `--q-page-padding` (le composant), lue par la
12
+ // règle `.q-page` de `styles/main.css` — là où elle est composée avec les offsets des
13
+ // barres `fixed` (`lib/fixedLayout.ts`) et la safe-area basse.
14
+
15
+ /** Padding appliqué par la prop booléenne `padding` — défaut du design system. */
16
+ export const PAGE_PADDING = "14px"
17
+
18
+ /** Nombre nu ("12", "1.5") → longueur CSS ("12px", "1.5px") ; sinon valeur inchangée. */
19
+ const withUnit = (value: string): string => (/^-?\d*\.?\d+$/.test(value) ? `${value}px` : value)
20
+
21
+ /**
22
+ * Longueur CSS à poser dans `--q-page-padding`, ou `undefined` quand aucun padding
23
+ * n'est demandé (prop absente, `false`, chaîne vide).
24
+ */
25
+ export function resolvePagePadding(padding?: boolean | string | number | null): string | undefined {
26
+ if (padding === true) return PAGE_PADDING
27
+ if (typeof padding === "number") return Number.isFinite(padding) ? `${padding}px` : undefined
28
+ if (typeof padding !== "string") return undefined
29
+
30
+ const value = padding.trim()
31
+ return value ? withUnit(value) : undefined
32
+ }
@@ -0,0 +1,103 @@
1
+ // Tests unitaires de l'encodage QR et du tracé SVG (`<q-qrcode>`).
2
+ import { describe, expect, it } from "bun:test"
3
+ import {
4
+ encodeQr,
5
+ isDark,
6
+ qrPath,
7
+ qrSize,
8
+ qrSvg,
9
+ qrTotalSize,
10
+ qrViewBox,
11
+ svgEscape,
12
+ type QrMatrix,
13
+ } from "./qrcode"
14
+
15
+ /** Matrice carrée décrite par des lignes de `.` (clair) et `#` (sombre) */
16
+ const matrixOf = (rows: string[]): QrMatrix => ({
17
+ size: rows.length,
18
+ data: rows.flatMap((row) => [...row].map((char) => (char === "#" ? 1 : 0))),
19
+ })
20
+
21
+ describe("encodeQr", () => {
22
+ it("encode une valeur en matrice carrée (4 × version + 17)", () => {
23
+ const matrix = encodeQr("https://dnax.io")
24
+ expect(matrix).toBeDefined()
25
+ expect(matrix!.size % 4).toBe(1)
26
+ expect(matrix!.size).toBeGreaterThanOrEqual(21)
27
+ expect(matrix!.data.length).toBe(matrix!.size * matrix!.size)
28
+ })
29
+
30
+ it("encode en QR (finders de coin : 7 modules sombres puis un clair)", () => {
31
+ const matrix = encodeQr("hello")
32
+ // Coins haut-gauche, haut-droit et bas-gauche : 7 sombres, puis 1 clair
33
+ for (let i = 0; i < 7; i++) {
34
+ expect(isDark(matrix!, i, 0)).toBe(true)
35
+ expect(isDark(matrix!, 0, i)).toBe(true)
36
+ }
37
+ expect(isDark(matrix!, 7, 0)).toBe(false)
38
+ })
39
+
40
+ it("grandit avec la charge utile et le niveau de correction", () => {
41
+ const small = encodeQr("hi", { ecc: "L" })!
42
+ const long = encodeQr("x".repeat(300), { ecc: "H" })!
43
+ expect(long.size).toBeGreaterThan(small.size)
44
+ })
45
+
46
+ it("rend `undefined` sur une valeur vide ou trop longue (jamais d'exception)", () => {
47
+ expect(encodeQr("")).toBeUndefined()
48
+ expect(encodeQr("y".repeat(5000), { ecc: "H" })).toBeUndefined()
49
+ })
50
+ })
51
+
52
+ describe("qrPath / qrViewBox / qrTotalSize", () => {
53
+ const matrix = matrixOf([".#..", "###.", "..#.", ".##."])
54
+
55
+ it("trace une sous-forme par plage horizontale", () => {
56
+ expect(qrPath(matrix)).toBe("M1 0h1v1h-1z" + "M0 1h3v1h-3z" + "M2 2h1v1h-1z" + "M1 3h2v1h-2z")
57
+ })
58
+
59
+ it("décale le tracé de la zone de silence", () => {
60
+ expect(qrPath(matrix, 2)).toBe("M3 2h1v1h-1z" + "M2 3h3v1h-3z" + "M4 4h1v1h-1z" + "M3 5h2v1h-2z")
61
+ })
62
+
63
+ it("garde une matrice vide silencieuse", () => {
64
+ expect(qrPath(matrixOf(["....", "...."]))).toBe("")
65
+ })
66
+
67
+ it("expose la taille totale et le viewBox", () => {
68
+ expect(qrTotalSize(matrix)).toBe(4)
69
+ expect(qrTotalSize(matrix, 4)).toBe(12)
70
+ expect(qrViewBox(matrix, 4)).toBe("0 0 12 12")
71
+ })
72
+
73
+ it("lit les modules hors matrice comme clairs", () => {
74
+ expect(isDark(matrix, -1, 0)).toBe(false)
75
+ expect(isDark(matrix, 0, 99)).toBe(false)
76
+ })
77
+ })
78
+
79
+ describe("qrSize / svgEscape / qrSvg", () => {
80
+ const matrix = matrixOf([".#", "#."])
81
+
82
+ it("normalise la taille CSS", () => {
83
+ expect(qrSize(200)).toBe("200px")
84
+ expect(qrSize("12rem")).toBe("12rem")
85
+ expect(qrSize(undefined)).toBe("160px")
86
+ expect(qrSize(0)).toBe("160px")
87
+ })
88
+
89
+ it("échappe ce qui part dans un SVG sérialisé", () => {
90
+ expect(svgEscape(`<b title="x">&`)).toBe("&lt;b title=&quot;x&quot;&gt;&amp;")
91
+ })
92
+
93
+ it("sérialise un SVG complet (fond, tracé, étiquette)", () => {
94
+ const svg = qrSvg(matrix, { margin: 1, color: "#111", background: "#fff", label: "QR" })
95
+
96
+ expect(svg).toContain('viewBox="0 0 4 4"')
97
+ expect(svg).toContain('<rect width="4" height="4" fill="#fff"/>')
98
+ expect(svg).toContain('d="M2 1h1v1h-1zM1 2h1v1h-1z"')
99
+ expect(svg).toContain('fill="#111"')
100
+ expect(svg).toContain('aria-label="QR"')
101
+ expect(svg).toContain('role="img"')
102
+ })
103
+ })
package/lib/qrcode.ts ADDED
@@ -0,0 +1,125 @@
1
+ // qrcode — encodage QR et tracé SVG. Le moteur d'encodage est la dépendance `qrcode`
2
+ // (déjà présente, sans types — cf. `shims.d.ts`) : elle fournit la matrice de modules, ce
3
+ // module la traduit en **chemin SVG** (une suite de rectangles horizontaux, bien plus
4
+ // compact qu'un carré par module) — d'où un rendu net à toutes les tailles, thémable et
5
+ // rendable en SSR.
6
+ //
7
+ // Pur (aucun DOM) → testable hors navigateur, comme `lib/pagePadding.ts`.
8
+ import QRCode from "qrcode"
9
+
10
+ /** Niveau de correction d'erreur : L (~7 %) → H (~30 %) */
11
+ export type QrEcc = "L" | "M" | "Q" | "H"
12
+
13
+ /** Matrice de modules : `data[y * size + x]` vaut 1 pour un module sombre */
14
+ export interface QrMatrix {
15
+ /** Côté de la matrice, en modules (21, 25, 29… : 4 × version + 17) */
16
+ size: number
17
+ /** Modules, ligne par ligne */
18
+ data: ArrayLike<number>
19
+ }
20
+
21
+ export interface QrEncodeOptions {
22
+ /** Correction d'erreur (défaut `"M"`) */
23
+ ecc?: QrEcc
24
+ /** Version forcée du QR (1 → 40). Par défaut la plus petite qui contient la donnée. */
25
+ version?: number
26
+ }
27
+
28
+ /** Un module est-il sombre ? (`x`/`y` hors matrice → clair, jamais d'exception) */
29
+ export const isDark = (matrix: QrMatrix, x: number, y: number): boolean => {
30
+ if (x < 0 || y < 0 || x >= matrix.size || y >= matrix.size) return false
31
+ return matrix.data[y * matrix.size + x] === 1
32
+ }
33
+
34
+ /**
35
+ * Encode une valeur en matrice QR. `undefined` quand la donnée ne tient pas (trop longue
36
+ * pour la correction d'erreur demandée) : à l'appelant de décider quoi afficher.
37
+ */
38
+ export function encodeQr(value: string, options: QrEncodeOptions = {}): QrMatrix | undefined {
39
+ if (typeof value !== "string" || value === "") return undefined
40
+
41
+ try {
42
+ const settings: Record<string, unknown> = { errorCorrectionLevel: options.ecc ?? "M" }
43
+ if (typeof options.version === "number") settings.version = options.version
44
+
45
+ return QRCode.create(value, settings).modules
46
+ } catch {
47
+ return undefined
48
+ }
49
+ }
50
+
51
+ /** Côté total de l'image (matrice + zone de silence), en modules */
52
+ export function qrTotalSize(matrix: QrMatrix, margin = 0): number {
53
+ return matrix.size + 2 * Math.max(0, Math.floor(margin))
54
+ }
55
+
56
+ /**
57
+ * Chemin SVG de la matrice : une sous-forme par **plage horizontale** de modules sombres
58
+ * (`M x y h largeur v1 h-largeur z`). `margin` ajoute la zone de silence, en modules — le
59
+ * QR doit garder un fond clair autour de lui pour être lisible par un lecteur.
60
+ */
61
+ export function qrPath(matrix: QrMatrix, margin = 0): string {
62
+ const offset = Math.max(0, Math.floor(margin))
63
+ const parts: string[] = []
64
+
65
+ for (let y = 0; y < matrix.size; y++) {
66
+ let x = 0
67
+ while (x < matrix.size) {
68
+ if (!isDark(matrix, x, y)) {
69
+ x++
70
+ continue
71
+ }
72
+
73
+ let run = 1
74
+ while (x + run < matrix.size && isDark(matrix, x + run, y)) run++
75
+
76
+ const px = x + offset
77
+ const py = y + offset
78
+ parts.push(`M${px} ${py}h${run}v1h-${run}z`)
79
+ x += run
80
+ }
81
+ }
82
+
83
+ return parts.join("")
84
+ }
85
+
86
+ /** `viewBox` du rendu (matrice + zone de silence) */
87
+ export function qrViewBox(matrix: QrMatrix, margin = 0): string {
88
+ const total = qrTotalSize(matrix, margin)
89
+ return `0 0 ${total} ${total}`
90
+ }
91
+
92
+ /** Taille CSS : nombre → px, chaîne CSS telle quelle, sinon `160px`. */
93
+ export function qrSize(value: unknown): string {
94
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return `${value}px`
95
+ if (typeof value === "string" && value.trim() !== "") return value.trim()
96
+ return "160px"
97
+ }
98
+
99
+ /** Échappe un texte destiné à un attribut d'un SVG **sérialisé** (chaîne, pas template). */
100
+ export const svgEscape = (value: string): string =>
101
+ value
102
+ .replace(/&/g, "&amp;")
103
+ .replace(/</g, "&lt;")
104
+ .replace(/>/g, "&gt;")
105
+ .replace(/"/g, "&quot;")
106
+
107
+ /** SVG complet (chaîne) — pour un téléchargement, un presse-papier ou un `<img src>`. */
108
+ export function qrSvg(
109
+ matrix: QrMatrix,
110
+ options: { margin?: number; color?: string; background?: string; label?: string } = {},
111
+ ): string {
112
+ const margin = options.margin ?? 0
113
+ const total = qrTotalSize(matrix, margin)
114
+ const color = options.color || "#000"
115
+ const background = options.background || "#fff"
116
+ const label = options.label ? ` aria-label="${svgEscape(options.label)}"` : ""
117
+
118
+ return (
119
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${qrViewBox(matrix, margin)}" ` +
120
+ `width="${total}" height="${total}" role="img"${label}>` +
121
+ `<rect width="${total}" height="${total}" fill="${svgEscape(background)}"/>` +
122
+ `<path d="${qrPath(matrix, margin)}" fill="${svgEscape(color)}" shape-rendering="crispEdges"/>` +
123
+ `</svg>`
124
+ )
125
+ }
@@ -0,0 +1,230 @@
1
+ // Tests unitaires du suivi des modifications du tableur (delta référence → état courant).
2
+ import { describe, expect, it } from "bun:test"
3
+ import {
4
+ deepEqual,
5
+ diffDocuments,
6
+ diffRows,
7
+ diffSheets,
8
+ sameCellValue,
9
+ type QSpreadsheetDocumentPayload,
10
+ } from "./spreadsheetChanges"
11
+
12
+ describe("sameCellValue", () => {
13
+ it("tient « vide » pour équivalent (null / undefined / \"\")", () => {
14
+ expect(sameCellValue(null, undefined)).toBe(true)
15
+ expect(sameCellValue("", null)).toBe(true)
16
+ expect(sameCellValue("", "")).toBe(true)
17
+ // … mais une valeur effacée n'est pas « identique » à une valeur présente
18
+ expect(sameCellValue(null, 0)).toBe(false)
19
+ expect(sameCellValue("", "x")).toBe(false)
20
+ })
21
+
22
+ it("compare les primitives, NaN compris", () => {
23
+ expect(sameCellValue(18.5, 18.5)).toBe(true)
24
+ expect(sameCellValue("a", "b")).toBe(false)
25
+ expect(sameCellValue(NaN, NaN)).toBe(true)
26
+ expect(sameCellValue(0, false)).toBe(false)
27
+ })
28
+
29
+ it("compare les tableaux (multiselect) élément par élément", () => {
30
+ expect(sameCellValue(["a", "b"], ["a", "b"])).toBe(true)
31
+ expect(sameCellValue(["a", "b"], ["b", "a"])).toBe(false)
32
+ expect(sameCellValue(["a"], ["a", "b"])).toBe(false)
33
+ expect(sameCellValue([], [])).toBe(true)
34
+ })
35
+ })
36
+
37
+ describe("deepEqual", () => {
38
+ it("ignore l'ordre des clés (records reconstruits)", () => {
39
+ expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true)
40
+ expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false)
41
+ })
42
+
43
+ it("respecte l'ordre des tableaux et descend en profondeur", () => {
44
+ expect(deepEqual([1, 2], [1, 2])).toBe(true)
45
+ expect(deepEqual([1, 2], [2, 1])).toBe(false)
46
+ expect(deepEqual({ a: { b: [1, { c: 2 }] } }, { a: { b: [1, { c: 2 }] } })).toBe(true)
47
+ expect(deepEqual({ a: { b: [1, { c: 2 }] } }, { a: { b: [1, { c: 3 }] } })).toBe(false)
48
+ })
49
+ })
50
+
51
+ const row = (key: string, values: Record<string, any> = {}) => ({ _key: key, ...values })
52
+
53
+ describe("diffRows", () => {
54
+ it("sépare ajoutées / modifiées / supprimées", () => {
55
+ const before = [row("a", { name: "Ada" }), row("b", { name: "Grace" })]
56
+ const after = [row("a", { name: "Ada" }), row("c", { name: "Alan" })]
57
+ const delta = diffRows(before, after, { columns: ["name"], sheet: "s1" })
58
+ expect(delta.added.map((r) => r.key)).toEqual(["c"])
59
+ expect(delta.deleted.map((r) => r.key)).toEqual(["b"])
60
+ expect(delta.updated).toHaveLength(0)
61
+ expect(delta.added[0]).toMatchObject({ sheet: "s1", row: { name: "Alan" } })
62
+ })
63
+
64
+ it("identifie par `_key` : un réordonnancement n'est pas une modification", () => {
65
+ const before = [row("a", { n: 1 }), row("b", { n: 2 })]
66
+ const after = [row("b", { n: 2 }), row("a", { n: 1 })]
67
+ const delta = diffRows(before, after, { columns: ["n"] })
68
+ expect(delta.added).toHaveLength(0)
69
+ expect(delta.deleted).toHaveLength(0)
70
+ expect(delta.updated).toHaveLength(0)
71
+ })
72
+
73
+ it("liste les colonnes modifiées", () => {
74
+ const delta = diffRows(
75
+ [row("a", { name: "Ada", age: 36, city: "London" })],
76
+ [row("a", { name: "Ada", age: 37, city: "London" })],
77
+ { columns: ["name", "age", "city"] },
78
+ )
79
+ expect(delta.updated).toHaveLength(1)
80
+ expect(delta.updated[0]!.columns).toEqual(["age"])
81
+ expect(delta.updated[0]!.before).toMatchObject({ age: 36 })
82
+ expect(delta.updated[0]!.row).toMatchObject({ age: 37 })
83
+ })
84
+
85
+ it("ne compare que le schéma courant (colonne supprimée / ajoutée vide)", () => {
86
+ // colonne supprimée : le schéma ne la contient plus → aucune ligne « modifiée »
87
+ const removed = diffRows(
88
+ [row("a", { name: "Ada", gone: 5 })],
89
+ [row("a", { name: "Ada" })],
90
+ { columns: ["name"] },
91
+ )
92
+ expect(removed.updated).toHaveLength(0)
93
+ // colonne ajoutée vide : "" vs absence = « vide » des deux côtés → rien non plus
94
+ const added = diffRows([row("a", { name: "Ada" })], [row("a", { name: "Ada", extra: "" })], {
95
+ columns: ["name", "extra"],
96
+ })
97
+ expect(added.updated).toHaveLength(0)
98
+ })
99
+
100
+ it("compare les valeurs de type tableau (multiselect)", () => {
101
+ const delta = diffRows(
102
+ [row("a", { tags: ["design", "docs"] })],
103
+ [row("a", { tags: ["design", "backend"] })],
104
+ { columns: ["tags"] },
105
+ )
106
+ expect(delta.updated[0]!.columns).toEqual(["tags"])
107
+ })
108
+
109
+ it("sans schéma déclaré, compare les clés des lignes (`_key` exclue)", () => {
110
+ const delta = diffRows([row("a", { n: 1 })], [row("a", { n: 2 })])
111
+ expect(delta.updated[0]!.columns).toEqual(["n"])
112
+ })
113
+
114
+ it("ignore les lignes sans clé (non identifiables)", () => {
115
+ const delta = diffRows([{ n: 1 }], [{ n: 1 }])
116
+ expect(delta.added).toHaveLength(0)
117
+ expect(delta.deleted).toHaveLength(0)
118
+ })
119
+ })
120
+
121
+ describe("diffSheets", () => {
122
+ const sheet = (key: string, name: string, columns?: any[]) => ({ key, name, columns, rows: [] })
123
+
124
+ it("sépare ajoutées / supprimées", () => {
125
+ const delta = diffSheets([sheet("a", "A")], [sheet("a", "A"), sheet("b", "B")])
126
+ expect(delta.added.map((s) => s.key)).toEqual(["b"])
127
+ expect(delta.deleted).toHaveLength(0)
128
+
129
+ const back = diffSheets([sheet("a", "A"), sheet("b", "B")], [sheet("a", "A")])
130
+ expect(back.deleted.map((s) => s.key)).toEqual(["b"])
131
+ expect(back.added).toHaveLength(0)
132
+ })
133
+
134
+ it("détecte un renommage et un changement de schéma", () => {
135
+ const renamed = diffSheets([sheet("a", "A")], [sheet("a", "B")])
136
+ expect(renamed.updated[0]).toMatchObject({ key: "a", changed: ["name"] })
137
+
138
+ const schema = diffSheets(
139
+ [sheet("a", "A", ["x"])],
140
+ [sheet("a", "A", ["x", "y"])],
141
+ )
142
+ expect(schema.updated[0]!.changed).toEqual(["columns"])
143
+ })
144
+
145
+ it("ne signale rien quand la feuille est inchangée", () => {
146
+ const delta = diffSheets([sheet("a", "A", ["x"])], [sheet("a", "A", ["x"])])
147
+ expect(delta.added).toHaveLength(0)
148
+ expect(delta.updated).toHaveLength(0)
149
+ expect(delta.deleted).toHaveLength(0)
150
+ })
151
+ })
152
+
153
+ describe("diffDocuments", () => {
154
+ const doc = (over: Partial<QSpreadsheetDocumentPayload> = {}): QSpreadsheetDocumentPayload => ({
155
+ version: 1,
156
+ active: "s1",
157
+ sheets: [{ key: "s1", name: "One", columns: ["name", "age"], rows: [row("a", { name: "Ada", age: 36 })] }],
158
+ ...over,
159
+ })
160
+
161
+ it("référence identique → rien de modifié", () => {
162
+ const changes = diffDocuments(doc(), doc())
163
+ expect(changes.dirty).toBe(false)
164
+ expect(changes.count).toBe(0)
165
+ expect(changes.rows.added).toHaveLength(0)
166
+ expect(changes.extras).toEqual([])
167
+ })
168
+
169
+ it("sans référence → aucun delta (pas de faux positif)", () => {
170
+ expect(diffDocuments(null, doc()).dirty).toBe(false)
171
+ })
172
+
173
+ it("une cellule modifiée → ligne mise à jour, avec sa feuille et sa colonne", () => {
174
+ const after = doc()
175
+ after.sheets![0]!.rows = [row("a", { name: "Ada", age: 37 })]
176
+ const changes = diffDocuments(doc(), after)
177
+ expect(changes.dirty).toBe(true)
178
+ expect(changes.count).toBe(1)
179
+ expect(changes.rows.updated[0]).toMatchObject({ sheet: "s1", key: "a", columns: ["age"] })
180
+ })
181
+
182
+ it("changer d'onglet actif n'est pas une modification", () => {
183
+ const before = doc({ active: "s1" })
184
+ const after = doc({ active: "s1" })
185
+ after.active = "s1"
186
+ expect(diffDocuments(before, after).dirty).toBe(false)
187
+ // et une valeur `active` différente non plus
188
+ expect(diffDocuments(doc({ active: "s1" }), doc({ active: "s9" })).dirty).toBe(false)
189
+ })
190
+
191
+ it("une mise en forme seule rend le document modifié (extras)", () => {
192
+ const before = doc()
193
+ const after = doc()
194
+ ;(after.sheets![0] as any).formats = { "0:name": { bold: true } }
195
+ const changes = diffDocuments(before, after)
196
+ expect(changes.dirty).toBe(true)
197
+ expect(changes.rows.updated).toHaveLength(0)
198
+ expect(changes.extras).toEqual(["formats"])
199
+ // … et elle compte pour un élément (indicateur « 1 modified »)
200
+ expect(changes.count).toBe(1)
201
+ })
202
+
203
+ it("feuille ajoutée : portée par `sheets.added`, ses lignes ne sont pas répétées dans `rows`", () => {
204
+ const after = doc()
205
+ after.sheets!.push({ key: "s2", name: "Two", columns: ["name"], rows: [row("z", { name: "Zoe" })] })
206
+ const changes = diffDocuments(doc(), after)
207
+ expect(changes.sheets.added.map((s) => s.key)).toEqual(["s2"])
208
+ expect(changes.rows.added).toHaveLength(0)
209
+ expect(changes.count).toBe(1)
210
+ })
211
+
212
+ it("feuille supprimée : portée par `sheets.deleted`", () => {
213
+ const before = doc()
214
+ before.sheets!.push({ key: "s2", name: "Two", columns: ["name"], rows: [row("z", { name: "Zoe" })] })
215
+ const changes = diffDocuments(before, doc())
216
+ expect(changes.sheets.deleted.map((s) => s.key)).toEqual(["s2"])
217
+ expect(changes.rows.deleted).toHaveLength(0)
218
+ })
219
+
220
+ it("colonne supprimée : changement de schéma, pas de bruit sur les lignes", () => {
221
+ const before = doc()
222
+ before.sheets![0]!.columns = ["name", "age"]
223
+ const after = doc()
224
+ after.sheets![0]!.columns = ["name"]
225
+ after.sheets![0]!.rows = [row("a", { name: "Ada" })]
226
+ const changes = diffDocuments(before, after)
227
+ expect(changes.sheets.updated[0]!.changed).toEqual(["columns"])
228
+ expect(changes.rows.updated).toHaveLength(0)
229
+ })
230
+ })