@supersoniks/concorde 4.9.93 → 4.9.94

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 (89) hide show
  1. package/build-infos.json +1 -1
  2. package/concorde-core.bundle.js +299 -292
  3. package/concorde-core.es.js +10104 -6071
  4. package/dist/concorde-core.bundle.js +299 -292
  5. package/dist/concorde-core.es.js +10104 -6071
  6. package/dist/docs-mock-api-sw.js +110 -0
  7. package/dist/docs-mock-api-sw.js.map +3 -3
  8. package/dist/js/core/components/functional/functional.js +1 -0
  9. package/dist/js/core/components/functional/functional.js.map +1 -1
  10. package/dist/js/core/components/functional/jsonata/jsonata.js +119 -0
  11. package/dist/js/core/components/functional/jsonata/jsonata.js.map +1 -0
  12. package/dist/js/core/components/ui/button/button.js +1 -1
  13. package/dist/js/core/components/ui/button/button.js.map +1 -1
  14. package/dist/js/core/decorators/Subscriber.js +1 -0
  15. package/dist/js/core/decorators/Subscriber.js.map +1 -1
  16. package/dist/js/core/decorators/subscriber/derive.js +60 -0
  17. package/dist/js/core/decorators/subscriber/derive.js.map +1 -0
  18. package/dist/js/core/utils/derive.js +246 -0
  19. package/dist/js/core/utils/derive.js.map +1 -0
  20. package/dist/js/core/utils/jsonataHelpers.js +69 -0
  21. package/dist/js/core/utils/jsonataHelpers.js.map +1 -0
  22. package/dist/js/core/utils/jsonataRuntime.js +75 -0
  23. package/dist/js/core/utils/jsonataRuntime.js.map +1 -0
  24. package/dist/js/utils.js +2 -0
  25. package/dist/js/utils.js.map +1 -1
  26. package/dist/types/core/components/functional/functional.d.ts +1 -0
  27. package/dist/types/core/components/functional/functional.d.ts.map +1 -1
  28. package/dist/types/core/components/functional/jsonata/jsonata.d.ts +29 -0
  29. package/dist/types/core/components/functional/jsonata/jsonata.d.ts.map +1 -0
  30. package/dist/types/core/components/ui/button/button.d.ts +1 -1
  31. package/dist/types/core/components/ui/button/button.d.ts.map +1 -1
  32. package/dist/types/core/components/ui/form/checkbox/checkbox.d.ts +3 -3
  33. package/dist/types/core/components/ui/form/input/input.d.ts +3 -3
  34. package/dist/types/core/components/ui/form/input-autocomplete/input-autocomplete.d.ts +3 -3
  35. package/dist/types/core/components/ui/form/textarea/textarea.d.ts +3 -3
  36. package/dist/types/core/decorators/Subscriber.d.ts +2 -0
  37. package/dist/types/core/decorators/Subscriber.d.ts.map +1 -1
  38. package/dist/types/core/decorators/subscriber/derive.d.ts +30 -0
  39. package/dist/types/core/decorators/subscriber/derive.d.ts.map +1 -0
  40. package/dist/types/core/utils/derive.d.ts +40 -0
  41. package/dist/types/core/utils/derive.d.ts.map +1 -0
  42. package/dist/types/core/utils/jsonataHelpers.d.ts +15 -0
  43. package/dist/types/core/utils/jsonataHelpers.d.ts.map +1 -0
  44. package/dist/types/core/utils/jsonataRuntime.d.ts +13 -0
  45. package/dist/types/core/utils/jsonataRuntime.d.ts.map +1 -0
  46. package/dist/types/mixins.d.ts +3 -3
  47. package/dist/types/utils.d.ts +2 -0
  48. package/dist/types/utils.d.ts.map +1 -1
  49. package/docs/assets/{index-DURr6O71.js → index-DpIkksht.js} +652 -479
  50. package/docs/crawl/docs/_decorators/get.html +27 -6
  51. package/docs/crawl/docs/_decorators/patch.html +6 -0
  52. package/docs/crawl/docs/_decorators/post.html +8 -0
  53. package/docs/crawl/docs/_decorators/put.html +6 -0
  54. package/docs/crawl/hubs/decorators.html +36 -31
  55. package/docs/crawl/hubs/remote-data.html +36 -31
  56. package/docs/index.html +1 -1
  57. package/docs/llms-full.txt +47 -3
  58. package/docs/src/docs/_decorators/get.md +31 -0
  59. package/docs/src/docs/_decorators/patch.md +9 -0
  60. package/docs/src/docs/_decorators/post.md +11 -0
  61. package/docs/src/docs/_decorators/put.md +9 -0
  62. package/docs/src/docs/search/docs-search.json +120 -5
  63. package/docs/src/tsconfig.json +268 -268
  64. package/package.json +28 -3
  65. package/public/docs-mock-api-sw.js +110 -0
  66. package/public/docs-mock-api-sw.js.map +3 -3
  67. package/src/core/components/functional/functional.ts +1 -0
  68. package/src/core/components/functional/jsonata/jsonata.md +56 -0
  69. package/src/core/components/functional/jsonata/jsonata.spec.ts +202 -0
  70. package/src/core/components/functional/jsonata/jsonata.ts +116 -0
  71. package/src/core/components/ui/button/button.ts +2 -2
  72. package/src/core/decorators/Subscriber.ts +2 -0
  73. package/src/core/decorators/subscriber/derive.ts +95 -0
  74. package/src/core/utils/derive.spec.ts +190 -0
  75. package/src/core/utils/derive.ts +357 -0
  76. package/src/core/utils/jsonataHelpers.ts +100 -0
  77. package/src/core/utils/jsonataRuntime.ts +88 -0
  78. package/src/docs/_core-concept/dataFlow.md +9 -0
  79. package/src/docs/_misc/docs-mock-api.md +3 -0
  80. package/src/docs/components/docs-demo-sources.ts +26 -0
  81. package/src/docs/docs.ts +1 -0
  82. package/src/docs/example/docs-jsonata-demos.ts +128 -0
  83. package/src/docs/mock-api/fixtures-jsonata.ts +131 -0
  84. package/src/docs/mock-api/router.ts +26 -0
  85. package/src/docs/navigation/navigation.ts +4 -0
  86. package/src/docs/search/docs-search.json +180 -0
  87. package/src/tsconfig.json +18 -0
  88. package/src/tsconfig.tsbuildinfo +1 -1
  89. package/src/utils.ts +13 -0
@@ -0,0 +1,202 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import TestUtils from "@supersoniks/concorde/test-utils/TestUtils";
3
+ import { DataProviderKey } from "@supersoniks/concorde/core/utils/dataProviderKey";
4
+ import { deriveStateKey } from "@supersoniks/concorde/core/utils/derive";
5
+ import { clearJsonataCache } from "@supersoniks/concorde/core/utils/jsonataRuntime";
6
+ import { dp, set } from "@supersoniks/concorde/utils";
7
+ import "./jsonata";
8
+ import "../states/states";
9
+ import {
10
+ DOCS_COFFEE_BAGS,
11
+ DOCS_SPACE_CAT_MISSIONS,
12
+ } from "../../../../docs/mock-api/fixtures-jsonata";
13
+
14
+ const missionsKey = new DataProviderKey<typeof DOCS_SPACE_CAT_MISSIONS>(
15
+ "jsonata-test-missions",
16
+ );
17
+ const formKey = new DataProviderKey<{ top: number | string }>(
18
+ "jsonata-test-form",
19
+ );
20
+ const boardKey = new DataProviderKey<
21
+ Array<{ name: string; points: number }>
22
+ >("jsonata-test-board");
23
+
24
+ function wait(ms: number) {
25
+ return new Promise((r) => setTimeout(r, ms));
26
+ }
27
+
28
+ afterEach(() => {
29
+ clearJsonataCache();
30
+ });
31
+
32
+ describe("sonic-jsonata", () => {
33
+ it("calcule un leaderboard (groupby + top N) en live", async () => {
34
+ set(missionsKey, DOCS_SPACE_CAT_MISSIONS);
35
+ set(formKey, { top: 2 });
36
+
37
+ const [elt] = TestUtils.bootstrap(`
38
+ <sonic-jsonata
39
+ inputProvider="jsonata-test-missions"
40
+ outputProvider="jsonata-test-board"
41
+ formDataProvider="jsonata-test-form"
42
+ expression='$input{name: $sum(points)} ~> $each(function($v, $k) {{"name": $k, "points": $v}}) ^(>points)[[0..$number($top)-1]]'
43
+ ></sonic-jsonata>
44
+ `);
45
+
46
+ await wait(80);
47
+ const result = dp(boardKey).get();
48
+ expect(result).toHaveLength(2);
49
+ expect(result[0].name).toBe("Nebula Paws");
50
+ expect(result[0].points).toBe(75);
51
+ expect(result[1].name).toBe("Captain Whiskers");
52
+ expect(dp(deriveStateKey(boardKey)).get().status).toBe("ready");
53
+
54
+ // Comme sonic-input / sonic-select : mutation profonde sur le form DP
55
+ dp(formKey).top.set(1);
56
+ await wait(80);
57
+ const top1 = dp(boardKey).get();
58
+ const rows = Array.isArray(top1) ? top1 : [top1];
59
+ expect(rows).toHaveLength(1);
60
+ expect(rows[0].name).toBe("Nebula Paws");
61
+
62
+ (elt as HTMLElement).remove();
63
+ });
64
+
65
+ it("agrège le stock café par origine avec filtre roast", async () => {
66
+ const bagsKey = new DataProviderKey<typeof DOCS_COFFEE_BAGS>(
67
+ "jsonata-test-bags",
68
+ );
69
+ const coffeeForm = new DataProviderKey<{ roast: string }>(
70
+ "jsonata-test-coffee-form",
71
+ );
72
+ const stockKey = new DataProviderKey<Array<{ origin: string; kg: number }>>(
73
+ "jsonata-test-stock",
74
+ );
75
+ set(bagsKey, DOCS_COFFEE_BAGS);
76
+ set(coffeeForm, { roast: "light" });
77
+
78
+ const [elt] = TestUtils.bootstrap(`
79
+ <sonic-jsonata
80
+ inputProvider="jsonata-test-bags"
81
+ outputProvider="jsonata-test-stock"
82
+ formDataProvider="jsonata-test-coffee-form"
83
+ expression='($roast = "all" ? $input : $input[roast=$roast]){origin: $sum(kg)} ~> $each(function($v, $k) {{"origin": $k, "kg": $v}}) ^(>kg)'
84
+ ></sonic-jsonata>
85
+ `);
86
+
87
+ await wait(80);
88
+ const stock = dp(stockKey).get();
89
+ expect(stock.length).toBeGreaterThan(0);
90
+ expect(stock[0].origin).toBe("Ethiopia");
91
+ expect(stock[0].kg).toBe(16);
92
+ expect(stock.map((r) => r.origin)).not.toContain("Brazil");
93
+
94
+ dp(coffeeForm).roast.set("all");
95
+ await wait(80);
96
+ const all = dp(stockKey).get();
97
+ expect(all.map((r) => r.origin)).toContain("Brazil");
98
+
99
+ (elt as HTMLElement).remove();
100
+ });
101
+
102
+ it("expose status error sur expression invalide", async () => {
103
+ const err = vi.spyOn(console, "error").mockImplementation(() => {});
104
+ set(missionsKey, DOCS_SPACE_CAT_MISSIONS);
105
+
106
+ const [elt] = TestUtils.bootstrap(`
107
+ <sonic-jsonata
108
+ inputProvider="jsonata-test-missions"
109
+ outputProvider="jsonata-test-board"
110
+ expression='$input ~> $$$$invalid'
111
+ ></sonic-jsonata>
112
+ `);
113
+
114
+ await wait(80);
115
+ const state = dp(deriveStateKey(boardKey)).get();
116
+ expect(state.status).toBe("error");
117
+ expect(state.error).toBeTruthy();
118
+ err.mockRestore();
119
+ (elt as HTMLElement).remove();
120
+ });
121
+
122
+ it("branche sonic-states sur le DP /_state_", async () => {
123
+ set(missionsKey, DOCS_SPACE_CAT_MISSIONS);
124
+ set(formKey, { top: 3 });
125
+
126
+ const [wrap] = TestUtils.bootstrap(`
127
+ <div>
128
+ <sonic-jsonata
129
+ inputProvider="jsonata-test-missions"
130
+ outputProvider="jsonata-test-board"
131
+ formDataProvider="jsonata-test-form"
132
+ expression='$input[0].name'
133
+ ></sonic-jsonata>
134
+ <sonic-states dataProvider="jsonata-test-board/_state_" data-path="status">
135
+ <template data-value="ready"><span id="ok">prêt</span></template>
136
+ <template data-value="error"><span id="ko">erreur</span></template>
137
+ </sonic-states>
138
+ </div>
139
+ `);
140
+
141
+ await wait(80);
142
+ expect(wrap.querySelector("#ok")?.textContent).toBe("prêt");
143
+ (wrap as HTMLElement).remove();
144
+ });
145
+
146
+ it("calcule total + moyenne par ligne d’un tableau", async () => {
147
+ const linesKey = new DataProviderKey<
148
+ Array<{ qty: number; unitPrice: number }>
149
+ >("jsonata-test-lines");
150
+ const taxForm = new DataProviderKey<{ taxPercent: number | string }>(
151
+ "jsonata-test-tax-form",
152
+ );
153
+ const totalsKey = new DataProviderKey<{
154
+ count: number;
155
+ subtotal: number;
156
+ average: number;
157
+ tax: number;
158
+ grandTotal: number;
159
+ }>("jsonata-test-totals");
160
+
161
+ set(linesKey, [
162
+ { qty: 4, unitPrice: 3.5 },
163
+ { qty: 2, unitPrice: 6 },
164
+ { qty: 1, unitPrice: 8.5 },
165
+ ]);
166
+ set(taxForm, { taxPercent: 10 });
167
+
168
+ const [elt] = TestUtils.bootstrap(`
169
+ <sonic-jsonata
170
+ inputProvider="jsonata-test-lines"
171
+ outputProvider="jsonata-test-totals"
172
+ formDataProvider="jsonata-test-tax-form"
173
+ expression='(
174
+ $lines := $input.(qty * unitPrice);
175
+ {
176
+ "count": $count($input),
177
+ "subtotal": $round($sum($lines), 2),
178
+ "average": $round($average($lines), 2),
179
+ "tax": $round($sum($lines) * $number($taxPercent) / 100, 2),
180
+ "grandTotal": $round($sum($lines) * (1 + $number($taxPercent) / 100), 2)
181
+ }
182
+ )'
183
+ ></sonic-jsonata>
184
+ `);
185
+
186
+ await wait(80);
187
+ expect(dp(totalsKey).get()).toMatchObject({
188
+ count: 3,
189
+ subtotal: 34.5,
190
+ average: 11.5,
191
+ tax: 3.45,
192
+ grandTotal: 37.95,
193
+ });
194
+
195
+ dp(taxForm).taxPercent.set(20);
196
+ await wait(80);
197
+ expect(dp(totalsKey).get().tax).toBe(6.9);
198
+ expect(dp(totalsKey).get().grandTotal).toBe(41.4);
199
+
200
+ (elt as HTMLElement).remove();
201
+ });
202
+ });
@@ -0,0 +1,116 @@
1
+ import { LitElement, html, css } from "lit";
2
+ import { customElement, property } from "lit/decorators.js";
3
+ import {
4
+ derive,
5
+ type DeriveHandle,
6
+ } from "@supersoniks/concorde/core/utils/derive";
7
+ import {
8
+ compileJsonata,
9
+ validateOutputSchema,
10
+ } from "@supersoniks/concorde/core/utils/jsonataRuntime";
11
+ import { DataProviderKey } from "@supersoniks/concorde/core/utils/dataProviderKey";
12
+
13
+ /**
14
+ * Adaptateur JSONata de `derive` : 1 entrée → expression → 1 sortie (readonly).
15
+ *
16
+ * Contexte d’évaluation :
17
+ * - `$input` = contenu de `inputProvider`
18
+ * - champs du `formDataProvider` optionnel = `$top`, `$roast`, …
19
+ */
20
+ @customElement("sonic-jsonata")
21
+ export default class SonicJsonata extends LitElement {
22
+ static styles = [
23
+ css`
24
+ :host {
25
+ display: contents;
26
+ }
27
+ `,
28
+ ];
29
+
30
+ /** DataProvider d’entrée (source). */
31
+ @property({ type: String })
32
+ inputProvider = "";
33
+
34
+ /** DataProvider de sortie (agrégat, readonly). */
35
+ @property({ type: String })
36
+ outputProvider = "";
37
+
38
+ /**
39
+ * Form / variables optionnelles (`$top`, `$roast`, …).
40
+ * Même id que le `formDataProvider` du formulaire — recalcule sans refetch.
41
+ */
42
+ @property({ type: String })
43
+ formDataProvider = "";
44
+
45
+ @property({ type: String })
46
+ expression = "";
47
+
48
+ @property({ type: Object })
49
+ outputSchema: Record<string, unknown> | null = null;
50
+
51
+ private handle: DeriveHandle | null = null;
52
+
53
+ connectedCallback(): void {
54
+ super.connectedCallback();
55
+ this.restart();
56
+ }
57
+
58
+ disconnectedCallback(): void {
59
+ this.handle?.stop();
60
+ this.handle = null;
61
+ super.disconnectedCallback();
62
+ }
63
+
64
+ protected updated(
65
+ changed: Map<string | number | symbol, unknown>,
66
+ ): void {
67
+ if (
68
+ changed.has("expression") ||
69
+ changed.has("inputProvider") ||
70
+ changed.has("outputProvider") ||
71
+ changed.has("formDataProvider") ||
72
+ changed.has("outputSchema")
73
+ ) {
74
+ this.restart();
75
+ }
76
+ }
77
+
78
+ private restart(): void {
79
+ this.handle?.stop();
80
+ this.handle = null;
81
+ if (!this.isConnected) return;
82
+ if (!this.inputProvider || !this.outputProvider) return;
83
+ if (!this.expression?.trim()) return;
84
+
85
+ const expressionSource = this.expression;
86
+ const schema = this.outputSchema;
87
+
88
+ this.handle = derive({
89
+ inputs: { input: new DataProviderKey(this.inputProvider) },
90
+ output: new DataProviderKey(this.outputProvider),
91
+ variables: this.formDataProvider
92
+ ? new DataProviderKey(this.formDataProvider)
93
+ : undefined,
94
+ compute: async ({ input }, vars) => {
95
+ const expr = compileJsonata(expressionSource);
96
+ const bindings: Record<string, unknown> = {
97
+ ...((vars ?? {}) as Record<string, unknown>),
98
+ input,
99
+ };
100
+ let result: unknown;
101
+ try {
102
+ result = await expr.evaluate({}, bindings);
103
+ } catch (err) {
104
+ const message = err instanceof Error ? err.message : String(err);
105
+ throw new Error(`[sonic-jsonata] Évaluation : ${message}`);
106
+ }
107
+ validateOutputSchema(result, schema);
108
+ return result;
109
+ },
110
+ });
111
+ }
112
+
113
+ render() {
114
+ return html`<slot></slot>`;
115
+ }
116
+ }
@@ -506,8 +506,8 @@ export class Button extends FormCheckable(FormElement(Subscriber(LitElement))) {
506
506
  * L'url
507
507
  */
508
508
  private _href: Route | string = "";
509
- @property({ type: String }) set href(value: Route | string) {
510
- this._href = value;
509
+ @property({ type: String }) set href(value: Route | string | null) {
510
+ this._href = value ?? "";
511
511
  const str = this._href.toString();
512
512
  if (str && str.indexOf("http") != 0) {
513
513
  LocationHandler.onChange(this);
@@ -4,6 +4,8 @@ export { subscribe } from "./subscriber/subscribe";
4
4
  export { onAssign } from "./subscriber/onAssign";
5
5
  export { handle, Skip } from "./subscriber/handle";
6
6
  export type { HandleOptions } from "./subscriber/handle";
7
+ export { derive } from "./subscriber/derive";
8
+ export type { DeriveDecoratorOptions } from "./subscriber/derive";
7
9
  export { autoSubscribe } from "./subscriber/autoSubscribe";
8
10
  export { autoFill } from "./subscriber/autoFill";
9
11
  export { ancestorAttribute } from "./subscriber/ancestorAttribute";
@@ -0,0 +1,95 @@
1
+ import type {
2
+ DataProviderKey,
3
+ DataProviderKeyHost,
4
+ } from "../../utils/dataProviderKey";
5
+ import {
6
+ derive as deriveFn,
7
+ type DeriveHandle,
8
+ } from "../../utils/derive";
9
+
10
+ export type DeriveDecoratorOptions<
11
+ I extends Record<string, unknown>,
12
+ O,
13
+ V = Record<string, unknown>,
14
+ > = {
15
+ from: { [K in keyof I]: DataProviderKey<I[K]> };
16
+ to: DataProviderKey<O>;
17
+ /** Expression JSONata (string) ou fonction TS. */
18
+ expr?: string;
19
+ fn?: (inputs: I, vars: V) => O | Promise<O>;
20
+ variables?: DataProviderKey<V>;
21
+ };
22
+
23
+ const HANDLES = new WeakMap<object, DeriveHandle[]>();
24
+
25
+ /**
26
+ * Décorateur optionnel aligné `@handle` : lance un `derive` au `connectedCallback`
27
+ * et l’arrête au `disconnectedCallback`.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * @derive({
32
+ * from: { s: sessionsKey },
33
+ * to: palmaresKey,
34
+ * variables: formKey,
35
+ * fn: (i, v) => i.s.slice(0, Number(v.top) || 5),
36
+ * })
37
+ * class MyWidget extends LitElement {}
38
+ * ```
39
+ */
40
+ export function deriveDecorator<
41
+ I extends Record<string, unknown>,
42
+ O,
43
+ V = Record<string, unknown>,
44
+ >(options: DeriveDecoratorOptions<I, O, V>) {
45
+ return function <T extends DataProviderKeyHost<object>>(
46
+ ctor: new (...args: any[]) => T,
47
+ ): void {
48
+ const proto = ctor.prototype as T & {
49
+ connectedCallback?: () => void;
50
+ disconnectedCallback?: () => void;
51
+ };
52
+ const connected = proto.connectedCallback;
53
+ const disconnected = proto.disconnectedCallback;
54
+
55
+ proto.connectedCallback = function (this: T) {
56
+ connected?.call(this);
57
+ const compute =
58
+ options.fn ??
59
+ (async (inputs: I, vars: V) => {
60
+ if (!options.expr) {
61
+ throw new Error("@derive : fournir `expr` ou `fn`.");
62
+ }
63
+ const { compileJsonata } = await import(
64
+ "../../utils/jsonataRuntime"
65
+ );
66
+ const expr = compileJsonata(options.expr);
67
+ return expr.evaluate({}, {
68
+ ...(vars as Record<string, unknown>),
69
+ ...inputs,
70
+ }) as O;
71
+ });
72
+ const handle = deriveFn({
73
+ inputs: options.from,
74
+ output: options.to,
75
+ variables: options.variables,
76
+ compute,
77
+ });
78
+ const list = HANDLES.get(this) ?? [];
79
+ list.push(handle);
80
+ HANDLES.set(this, list);
81
+ };
82
+
83
+ proto.disconnectedCallback = function (this: T) {
84
+ const list = HANDLES.get(this);
85
+ if (list) {
86
+ for (const h of list) h.stop();
87
+ HANDLES.delete(this);
88
+ }
89
+ disconnected?.call(this);
90
+ };
91
+ };
92
+ }
93
+
94
+ /** Alias public — éviter le conflit de nom avec la primitive `derive`. */
95
+ export { deriveDecorator as derive };
@@ -0,0 +1,190 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { DataProviderKey } from "@supersoniks/concorde/core/utils/dataProviderKey";
3
+ import {
4
+ derive,
5
+ deriveStateKey,
6
+ } from "@supersoniks/concorde/core/utils/derive";
7
+ import { dp, set } from "@supersoniks/concorde/utils";
8
+
9
+ const sessionsKey = new DataProviderKey<
10
+ Array<{ title: string; revenue: number }>
11
+ >("derive-sessions");
12
+ const formKey = new DataProviderKey<{ top: number }>("derive-form");
13
+ const outKey = new DataProviderKey<Array<{ title: string; ca: number }>>(
14
+ "derive-out",
15
+ );
16
+ const outBKey = new DataProviderKey<unknown>("derive-out-b");
17
+
18
+ function wait(ms: number) {
19
+ return new Promise((r) => setTimeout(r, ms));
20
+ }
21
+
22
+ afterEach(() => {
23
+ // Nettoyage des DPs de test
24
+ set(sessionsKey, []);
25
+ set(formKey, { top: 5 });
26
+ set(outKey, []);
27
+ });
28
+
29
+ describe("derive", () => {
30
+ it("publie un résultat calculé à partir des entrées", async () => {
31
+ set(sessionsKey, [
32
+ { title: "A", revenue: 10 },
33
+ { title: "B", revenue: 30 },
34
+ ]);
35
+ set(formKey, { top: 1 });
36
+
37
+ const handle = derive({
38
+ inputs: { s: sessionsKey },
39
+ variables: formKey,
40
+ output: outKey,
41
+ debounceMs: 10,
42
+ compute: (i, v) =>
43
+ [...i.s]
44
+ .sort((a, b) => b.revenue - a.revenue)
45
+ .slice(0, v.top)
46
+ .map((x) => ({ title: x.title, ca: x.revenue })),
47
+ });
48
+
49
+ await wait(40);
50
+ expect(dp(outKey).get()).toEqual([{ title: "B", ca: 30 }]);
51
+ expect(dp(deriveStateKey(outKey)).get().status).toBe("ready");
52
+ handle.stop();
53
+ });
54
+
55
+ it("recalcule quand les variables changent (sans toucher aux entrées)", async () => {
56
+ set(sessionsKey, [
57
+ { title: "A", revenue: 10 },
58
+ { title: "B", revenue: 30 },
59
+ { title: "C", revenue: 20 },
60
+ ]);
61
+ set(formKey, { top: 2 });
62
+
63
+ const handle = derive({
64
+ inputs: { s: sessionsKey },
65
+ variables: formKey,
66
+ output: outKey,
67
+ debounceMs: 10,
68
+ compute: (i, v) =>
69
+ [...i.s]
70
+ .sort((a, b) => b.revenue - a.revenue)
71
+ .slice(0, Number(v.top))
72
+ .map((x) => ({ title: x.title, ca: x.revenue })),
73
+ });
74
+
75
+ await wait(40);
76
+ expect(dp(outKey).get()).toHaveLength(2);
77
+
78
+ // Comme sonic-input : écriture sur la sous-clé, pas set() racine
79
+ dp(formKey).top.set(1);
80
+ await wait(40);
81
+ expect(dp(outKey).get()).toEqual([{ title: "B", ca: 30 }]);
82
+ handle.stop();
83
+ });
84
+
85
+ it("debounce + génération : seul le dernier calcul compte", async () => {
86
+ set(sessionsKey, [{ title: "A", revenue: 1 }]);
87
+ let calls = 0;
88
+
89
+ const handle = derive({
90
+ inputs: { s: sessionsKey },
91
+ output: outKey,
92
+ debounceMs: 30,
93
+ compute: async (i) => {
94
+ calls += 1;
95
+ const n = calls;
96
+ await wait(40);
97
+ return i.s.map((x) => ({ title: x.title + n, ca: x.revenue }));
98
+ },
99
+ });
100
+
101
+ await wait(5);
102
+ set(sessionsKey, [{ title: "B", revenue: 2 }]);
103
+ await wait(5);
104
+ set(sessionsKey, [{ title: "C", revenue: 3 }]);
105
+ await wait(120);
106
+
107
+ const result = dp(outKey).get();
108
+ expect(result?.[0]?.title).toMatch(/^C/);
109
+ handle.stop();
110
+ });
111
+
112
+ it("refuse un cycle direct sortie ∈ entrées", () => {
113
+ expect(() =>
114
+ derive({
115
+ inputs: { o: outKey },
116
+ output: outKey,
117
+ compute: (i) => i.o,
118
+ }),
119
+ ).toThrow(/Cycle/);
120
+ });
121
+
122
+ it("refuse un cycle en chaîne", () => {
123
+ const a = new DataProviderKey<number>("derive-cycle-a");
124
+ const b = new DataProviderKey<number>("derive-cycle-b");
125
+ const h1 = derive({
126
+ inputs: { a },
127
+ output: b,
128
+ debounceMs: 5,
129
+ compute: (i) => i.a + 1,
130
+ });
131
+ expect(() =>
132
+ derive({
133
+ inputs: { b },
134
+ output: a,
135
+ compute: (i) => (i.b as number) + 1,
136
+ }),
137
+ ).toThrow(/Cycle/);
138
+ h1.stop();
139
+ });
140
+
141
+ it("sortie readonly : écriture externe ignorée + warning", async () => {
142
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
143
+ set(sessionsKey, [{ title: "A", revenue: 5 }]);
144
+ const handle = derive({
145
+ inputs: { s: sessionsKey },
146
+ output: outKey,
147
+ debounceMs: 10,
148
+ compute: (i) => i.s.map((x) => ({ title: x.title, ca: x.revenue })),
149
+ });
150
+ await wait(40);
151
+ expect(dp(outKey).get()).toEqual([{ title: "A", ca: 5 }]);
152
+
153
+ set(outKey, [{ title: "HACK", ca: 0 }]);
154
+ await wait(10);
155
+ expect(dp(outKey).get()).toEqual([{ title: "A", ca: 5 }]);
156
+ expect(warn).toHaveBeenCalled();
157
+ warn.mockRestore();
158
+ handle.stop();
159
+ });
160
+
161
+ it("publie status error puis empty", async () => {
162
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
163
+ set(sessionsKey, [{ title: "A", revenue: 1 }]);
164
+ const handle = derive({
165
+ inputs: { s: sessionsKey },
166
+ output: outBKey,
167
+ debounceMs: 10,
168
+ compute: () => {
169
+ throw new Error("boom-jsonata");
170
+ },
171
+ });
172
+ await wait(40);
173
+ expect(dp(deriveStateKey(outBKey)).get()).toMatchObject({
174
+ status: "error",
175
+ error: expect.stringContaining("boom-jsonata"),
176
+ });
177
+ handle.stop();
178
+ errSpy.mockRestore();
179
+
180
+ const handle2 = derive({
181
+ inputs: { s: sessionsKey },
182
+ output: outKey,
183
+ debounceMs: 10,
184
+ compute: () => [],
185
+ });
186
+ await wait(40);
187
+ expect(dp(deriveStateKey(outKey)).get().status).toBe("empty");
188
+ handle2.stop();
189
+ });
190
+ });