@treenity/react 3.0.1 → 3.0.3
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/README.md +1 -1
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +33 -7
- package/dist/App.js.map +1 -1
- package/dist/ComponentSection.js +1 -1
- package/dist/ComponentSection.js.map +1 -1
- package/dist/ErrorBoundary.d.ts.map +1 -1
- package/dist/ErrorBoundary.js +2 -1
- package/dist/ErrorBoundary.js.map +1 -1
- package/dist/Treenity.d.ts +15 -0
- package/dist/Treenity.d.ts.map +1 -0
- package/dist/Treenity.js +17 -0
- package/dist/Treenity.js.map +1 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +5 -0
- package/dist/cache.js.map +1 -1
- package/dist/client-tree.d.ts.map +1 -1
- package/dist/client-tree.js +2 -2
- package/dist/client-tree.js.map +1 -1
- package/dist/components/ui/button.d.ts +2 -2
- package/dist/components/ui/button.d.ts.map +1 -1
- package/dist/components/ui/button.js +3 -3
- package/dist/components/ui/button.js.map +1 -1
- package/dist/components/ui/pagination.d.ts +2 -2
- package/dist/components/ui/pagination.d.ts.map +1 -1
- package/dist/components/ui/pagination.js +3 -3
- package/dist/components/ui/pagination.js.map +1 -1
- package/dist/components/ui/textarea.js +1 -1
- package/dist/components/ui/textarea.js.map +1 -1
- package/dist/events.d.ts +2 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +47 -2
- package/dist/events.js.map +1 -1
- package/dist/fiber-tree.d.ts.map +1 -1
- package/dist/fiber-tree.js.map +1 -1
- package/dist/hooks.d.ts +9 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +80 -5
- package/dist/hooks.js.map +1 -1
- package/dist/lib/minimd.d.ts.map +1 -1
- package/dist/lib/minimd.js +8 -1
- package/dist/lib/minimd.js.map +1 -1
- package/dist/lib/sanitize-href.d.ts +3 -0
- package/dist/lib/sanitize-href.d.ts.map +1 -0
- package/dist/lib/sanitize-href.js +14 -0
- package/dist/lib/sanitize-href.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +14 -5
- package/dist/main.js.map +1 -1
- package/dist/mods/editor-ui/FieldLabel.d.ts.map +1 -1
- package/dist/mods/editor-ui/FieldLabel.js +2 -1
- package/dist/mods/editor-ui/FieldLabel.js.map +1 -1
- package/dist/mods/editor-ui/default-edit.js +4 -2
- package/dist/mods/editor-ui/default-edit.js.map +1 -1
- package/dist/mods/editor-ui/form-field.d.ts.map +1 -1
- package/dist/mods/editor-ui/form-field.js +3 -2
- package/dist/mods/editor-ui/form-field.js.map +1 -1
- package/dist/mods/editor-ui/type-picker.d.ts.map +1 -1
- package/dist/mods/editor-ui/type-picker.js +2 -1
- package/dist/mods/editor-ui/type-picker.js.map +1 -1
- package/dist/mods/treenity/preview.d.ts.map +1 -1
- package/dist/mods/treenity/preview.js +2 -3
- package/dist/mods/treenity/preview.js.map +1 -1
- package/dist/mods/treenity/ref-view.js +2 -1
- package/dist/mods/treenity/ref-view.js.map +1 -1
- package/dist/mods/treenity/seed.js +3 -2
- package/dist/mods/treenity/seed.js.map +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +11 -5
- package/dist/symbols.js.map +1 -1
- package/package.json +4 -2
- package/src/App.tsx +29 -1
- package/src/ComponentSection.tsx +1 -1
- package/src/ErrorBoundary.tsx +6 -3
- package/src/Treenity.tsx +32 -0
- package/src/cache.ts +7 -0
- package/src/client-tree.ts +7 -7
- package/src/components/ui/button.tsx +4 -5
- package/src/components/ui/pagination.tsx +4 -9
- package/src/components/ui/textarea.tsx +1 -1
- package/src/events.ts +46 -6
- package/src/fiber-tree.ts +3 -3
- package/src/hooks.ts +73 -4
- package/src/lib/minimd.ts +7 -1
- package/src/lib/sanitize-href.ts +13 -0
- package/src/main.tsx +23 -11
- package/src/mods/editor-ui/FieldLabel.tsx +5 -4
- package/src/mods/editor-ui/default-edit.tsx +6 -4
- package/src/mods/editor-ui/form-field.tsx +4 -2
- package/src/mods/editor-ui/type-picker.tsx +3 -2
- package/src/mods/treenity/preview.tsx +6 -7
- package/src/mods/treenity/ref-view.tsx +11 -6
- package/src/mods/treenity/seed.ts +3 -2
- package/src/symbols.ts +12 -5
- package/src/bind/bind.test.ts +0 -316
- package/src/cache.test.ts +0 -139
- package/src/client-tree.test.ts +0 -116
- package/src/optimistic.test.ts +0 -111
- package/src/remote-tree.test.ts +0 -142
package/src/bind/bind.test.ts
DELETED
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
import type { NodeData } from '@treenity/core';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { describe, it } from 'node:test';
|
|
4
|
-
import { clearComputed, getComputed, setComputed, subscribeComputed } from './computed';
|
|
5
|
-
import { evaluateRef, extractArgPaths, hasOnce, isCollectionRef } from './eval';
|
|
6
|
-
import { isRefArg, parseMapExpr } from './parse';
|
|
7
|
-
|
|
8
|
-
// ── Parser ──
|
|
9
|
-
|
|
10
|
-
describe('parseMapExpr', () => {
|
|
11
|
-
it('parses pipe with field chain', () => {
|
|
12
|
-
const expr = parseMapExpr('last().value | div(5)');
|
|
13
|
-
assert.deepEqual(expr.steps, [
|
|
14
|
-
{ type: 'pipe', name: 'last', args: [] },
|
|
15
|
-
{ type: 'field', name: 'value' },
|
|
16
|
-
{ type: 'pipe', name: 'div', args: [5] },
|
|
17
|
-
]);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('parses bare pipe (no parens)', () => {
|
|
21
|
-
const expr = parseMapExpr('round');
|
|
22
|
-
assert.deepEqual(expr.steps, [
|
|
23
|
-
{ type: 'pipe', name: 'round', args: [] },
|
|
24
|
-
]);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('parses multiple args', () => {
|
|
28
|
-
const expr = parseMapExpr('clamp(0, 10)');
|
|
29
|
-
assert.deepEqual(expr.steps, [
|
|
30
|
-
{ type: 'pipe', name: 'clamp', args: [0, 10] },
|
|
31
|
-
]);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('parses field-only chain', () => {
|
|
35
|
-
const expr = parseMapExpr('.status');
|
|
36
|
-
assert.deepEqual(expr.steps, [
|
|
37
|
-
{ type: 'field', name: 'status' },
|
|
38
|
-
]);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('parses complex chain', () => {
|
|
42
|
-
const expr = parseMapExpr('last().value | sub(20) | abs | div(10)');
|
|
43
|
-
assert.deepEqual(expr.steps, [
|
|
44
|
-
{ type: 'pipe', name: 'last', args: [] },
|
|
45
|
-
{ type: 'field', name: 'value' },
|
|
46
|
-
{ type: 'pipe', name: 'sub', args: [20] },
|
|
47
|
-
{ type: 'pipe', name: 'abs', args: [] },
|
|
48
|
-
{ type: 'pipe', name: 'div', args: [10] },
|
|
49
|
-
]);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('parses map pipe', () => {
|
|
53
|
-
const expr = parseMapExpr('map(value) | avg');
|
|
54
|
-
assert.deepEqual(expr.steps, [
|
|
55
|
-
{ type: 'pipe', name: 'map', args: ['value'] },
|
|
56
|
-
{ type: 'pipe', name: 'avg', args: [] },
|
|
57
|
-
]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('parses #field as step (self lookup)', () => {
|
|
61
|
-
const expr = parseMapExpr('#width | mul(#height)');
|
|
62
|
-
assert.deepEqual(expr.steps, [
|
|
63
|
-
{ type: 'field', name: 'width' },
|
|
64
|
-
{ type: 'pipe', name: 'mul', args: [{ $ref: '.', fields: ['height'] }] },
|
|
65
|
-
]);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('parses #/path.field ref arg', () => {
|
|
69
|
-
const expr = parseMapExpr('#price | mul(#/config/tax.rate)');
|
|
70
|
-
const mulStep = expr.steps[1];
|
|
71
|
-
assert.equal(mulStep.type, 'pipe');
|
|
72
|
-
if (mulStep.type === 'pipe') {
|
|
73
|
-
assert.equal(isRefArg(mulStep.args[0]), true);
|
|
74
|
-
assert.deepEqual(mulStep.args[0], { $ref: '/config/tax', fields: ['rate'] });
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('parses #/path without field (whole node)', () => {
|
|
79
|
-
const expr = parseMapExpr('count(#/sensors)');
|
|
80
|
-
const step = expr.steps[0];
|
|
81
|
-
if (step.type === 'pipe') {
|
|
82
|
-
assert.deepEqual(step.args[0], { $ref: '/sensors', fields: [] });
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('parses #/path.deep.field', () => {
|
|
87
|
-
const expr = parseMapExpr('mul(#/config.tax.rate)');
|
|
88
|
-
const step = expr.steps[0];
|
|
89
|
-
if (step.type === 'pipe') {
|
|
90
|
-
assert.deepEqual(step.args[0], { $ref: '/config', fields: ['tax', 'rate'] });
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
// ── Evaluator ──
|
|
96
|
-
|
|
97
|
-
describe('evaluateRef', () => {
|
|
98
|
-
const config = { $path: '/config', $type: 'config', factor: 5, tax: { rate: 0.2 } } as NodeData;
|
|
99
|
-
|
|
100
|
-
const sensors = [
|
|
101
|
-
{ $path: '/s/1', $type: 'reading', value: 10, seq: 0 },
|
|
102
|
-
{ $path: '/s/2', $type: 'reading', value: 20, seq: 1 },
|
|
103
|
-
{ $path: '/s/3', $type: 'reading', value: 30, seq: 2 },
|
|
104
|
-
] as NodeData[];
|
|
105
|
-
|
|
106
|
-
const selfNode = { $path: '/obj', $type: 't3d.object', width: 4, height: 3, price: 100 } as NodeData;
|
|
107
|
-
|
|
108
|
-
const allNodes = [...sensors, config, selfNode];
|
|
109
|
-
|
|
110
|
-
const ctx = {
|
|
111
|
-
getNode: (p: string) => allNodes.find(s => s.$path === p),
|
|
112
|
-
getChildren: (p: string) => p === '/s' ? sensors : [],
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
it('resolves plain ref (no $map)', () => {
|
|
116
|
-
const result = evaluateRef({ $ref: '/s/2' }, ctx);
|
|
117
|
-
assert.equal((result as NodeData)?.value, 20);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('resolves last().value | div(5)', () => {
|
|
121
|
-
const result = evaluateRef({ $ref: '/s', $map: 'last().value | div(5)' }, ctx);
|
|
122
|
-
assert.equal(result, 6); // 30 / 5
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('resolves first().value', () => {
|
|
126
|
-
const result = evaluateRef({ $ref: '/s', $map: 'first().value' }, ctx);
|
|
127
|
-
assert.equal(result, 10);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('resolves count()', () => {
|
|
131
|
-
const result = evaluateRef({ $ref: '/s', $map: 'count()' }, ctx);
|
|
132
|
-
assert.equal(result, 3);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('resolves map + avg', () => {
|
|
136
|
-
const result = evaluateRef({ $ref: '/s', $map: 'map(value) | avg' }, ctx);
|
|
137
|
-
assert.equal(result, 20); // (10+20+30)/3
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('resolves map + sum', () => {
|
|
141
|
-
const result = evaluateRef({ $ref: '/s', $map: 'map(value) | sum' }, ctx);
|
|
142
|
-
assert.equal(result, 60);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('resolves scalar chain', () => {
|
|
146
|
-
const result = evaluateRef({ $ref: '/s', $map: 'last().value | sub(20) | abs | div(2)' }, ctx);
|
|
147
|
-
assert.equal(result, 5); // |30-20| / 2
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('resolves clamp', () => {
|
|
151
|
-
const result = evaluateRef({ $ref: '/s', $map: 'last().value | clamp(0, 25)' }, ctx);
|
|
152
|
-
assert.equal(result, 25); // 30 clamped to 25
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('resolves single node field access (no collection)', () => {
|
|
156
|
-
const result = evaluateRef({ $ref: '/s/1', $map: '.value | mul(3)' }, ctx);
|
|
157
|
-
assert.equal(result, 30); // 10 * 3
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('returns undefined for empty children', () => {
|
|
161
|
-
const result = evaluateRef({ $ref: '/empty', $map: 'last().value' }, ctx);
|
|
162
|
-
assert.equal(result, undefined);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// ── Self-ref + #-args ──
|
|
166
|
-
|
|
167
|
-
it('resolves #field self lookup', () => {
|
|
168
|
-
const result = evaluateRef({ $ref: '/obj', $map: '#width | mul(#height)' }, ctx);
|
|
169
|
-
assert.equal(result, 12); // 4 * 3
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('resolves cross-ref #/path.field arg', () => {
|
|
173
|
-
const result = evaluateRef({ $ref: '/obj', $map: '#price | mul(#/config.tax.rate)' }, ctx);
|
|
174
|
-
assert.equal(result, 20); // 100 * 0.2
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('resolves external source + self arg via #', () => {
|
|
178
|
-
const result = evaluateRef({ $ref: '/s', $map: 'last().value | div(#/config.factor)' }, ctx);
|
|
179
|
-
assert.equal(result, 6); // 30 / 5
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('returns NaN for missing #ref node (loud failure)', () => {
|
|
183
|
-
const result = evaluateRef({ $ref: '/obj', $map: '#width | mul(#/missing.value)' }, ctx);
|
|
184
|
-
assert.equal(Number.isNaN(result), true); // 4 * undefined = NaN
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// ── isCollectionRef ──
|
|
189
|
-
|
|
190
|
-
describe('isCollectionRef', () => {
|
|
191
|
-
it('true for last()', () => {
|
|
192
|
-
assert.equal(isCollectionRef({ $ref: '/s', $map: 'last().value' }), true);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('true for count()', () => {
|
|
196
|
-
assert.equal(isCollectionRef({ $ref: '/s', $map: 'count()' }), true);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('false for field access', () => {
|
|
200
|
-
assert.equal(isCollectionRef({ $ref: '/s/1', $map: '.value' }), false);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it('false for #field self access', () => {
|
|
204
|
-
assert.equal(isCollectionRef({ $ref: '.', $map: '#width' }), false);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('false for no $map', () => {
|
|
208
|
-
assert.equal(isCollectionRef({ $ref: '/s/1' }), false);
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// ── extractArgPaths ──
|
|
213
|
-
|
|
214
|
-
describe('extractArgPaths', () => {
|
|
215
|
-
it('returns external paths from #/path args', () => {
|
|
216
|
-
const paths = extractArgPaths({ $ref: '/obj', $map: '#price | mul(#/config.rate) | add(#/bonus.value)' });
|
|
217
|
-
assert.deepEqual(paths, ['/config', '/bonus']);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it('skips # self refs', () => {
|
|
221
|
-
const paths = extractArgPaths({ $ref: '.', $map: '#width | mul(#height)' });
|
|
222
|
-
assert.deepEqual(paths, []);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it('returns empty for no $map', () => {
|
|
226
|
-
assert.deepEqual(extractArgPaths({ $ref: '/x' }), []);
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
// ── hasOnce ──
|
|
231
|
-
|
|
232
|
-
describe('hasOnce', () => {
|
|
233
|
-
it('true when once in pipe chain', () => {
|
|
234
|
-
assert.equal(hasOnce({ $ref: '/s', $map: 'last().value | div(5) | once' }), true);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('true when once is only step', () => {
|
|
238
|
-
assert.equal(hasOnce({ $ref: '/s', $map: 'once' }), true);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('false for normal pipes', () => {
|
|
242
|
-
assert.equal(hasOnce({ $ref: '/s', $map: 'last().value | div(5)' }), false);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it('false for no $map', () => {
|
|
246
|
-
assert.equal(hasOnce({ $ref: '/s' }), false);
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// ── once pipe (identity) ──
|
|
251
|
-
|
|
252
|
-
describe('once pipe in evaluation', () => {
|
|
253
|
-
const nodes = [
|
|
254
|
-
{ $path: '/s/1', $type: 'r', value: 10 },
|
|
255
|
-
{ $path: '/s/2', $type: 'r', value: 20 },
|
|
256
|
-
] as NodeData[];
|
|
257
|
-
|
|
258
|
-
const ctx = {
|
|
259
|
-
getNode: (p: string) => nodes.find(n => n.$path === p),
|
|
260
|
-
getChildren: (p: string) => p === '/s' ? nodes : [],
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
it('once does not alter the computed value', () => {
|
|
264
|
-
const withOnce = evaluateRef({ $ref: '/s', $map: 'last().value | div(5) | once' }, ctx);
|
|
265
|
-
const without = evaluateRef({ $ref: '/s', $map: 'last().value | div(5)' }, ctx);
|
|
266
|
-
assert.equal(withOnce, without);
|
|
267
|
-
assert.equal(withOnce, 4); // 20 / 5
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
// ── Computed store ──
|
|
272
|
-
|
|
273
|
-
describe('computed store', () => {
|
|
274
|
-
it('set + get', () => {
|
|
275
|
-
setComputed('/test', 'sy', 42);
|
|
276
|
-
const c = getComputed('/test');
|
|
277
|
-
assert.equal(c?.sy, 42);
|
|
278
|
-
clearComputed('/test');
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it('fires subscriber on change', () => {
|
|
282
|
-
let fired = 0;
|
|
283
|
-
const unsub = subscribeComputed('/test2', () => { fired++; });
|
|
284
|
-
setComputed('/test2', 'px', 1);
|
|
285
|
-
assert.equal(fired, 1);
|
|
286
|
-
setComputed('/test2', 'px', 2);
|
|
287
|
-
assert.equal(fired, 2);
|
|
288
|
-
// No-op same value
|
|
289
|
-
setComputed('/test2', 'px', 2);
|
|
290
|
-
assert.equal(fired, 2);
|
|
291
|
-
unsub();
|
|
292
|
-
clearComputed('/test2');
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it('returns new object reference on change (useSyncExternalStore compat)', () => {
|
|
296
|
-
setComputed('/ref-test', 'a', 1);
|
|
297
|
-
const snap1 = getComputed('/ref-test');
|
|
298
|
-
setComputed('/ref-test', 'a', 2);
|
|
299
|
-
const snap2 = getComputed('/ref-test');
|
|
300
|
-
// Must be different references — Object.is must see the change
|
|
301
|
-
assert.notEqual(snap1, snap2);
|
|
302
|
-
assert.equal(snap2?.a, 2);
|
|
303
|
-
// Old snapshot retains old value (immutable)
|
|
304
|
-
assert.equal(snap1?.a, 1);
|
|
305
|
-
clearComputed('/ref-test');
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it('unsubscribe stops notifications', () => {
|
|
309
|
-
let fired = 0;
|
|
310
|
-
const unsub = subscribeComputed('/test3', () => { fired++; });
|
|
311
|
-
unsub();
|
|
312
|
-
setComputed('/test3', 'sy', 99);
|
|
313
|
-
assert.equal(fired, 0);
|
|
314
|
-
clearComputed('/test3');
|
|
315
|
-
});
|
|
316
|
-
});
|
package/src/cache.test.ts
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
// Cache contract tests — plain Map implementation
|
|
2
|
-
|
|
3
|
-
import assert from 'node:assert';
|
|
4
|
-
import { beforeEach, describe, it } from 'node:test';
|
|
5
|
-
import * as cache from './cache';
|
|
6
|
-
|
|
7
|
-
describe('cache', () => {
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
cache.clear();
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it('put() creates entry, get() returns node', () => {
|
|
13
|
-
cache.put({ $path: '/a', $type: 'x', v: 1 } as any);
|
|
14
|
-
const n = cache.get('/a');
|
|
15
|
-
assert.ok(n);
|
|
16
|
-
assert.strictEqual(n!.v, 1);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('second put() updates value', () => {
|
|
20
|
-
cache.put({ $path: '/a', $type: 'x', v: 1 } as any);
|
|
21
|
-
cache.put({ $path: '/a', $type: 'x', v: 2 } as any);
|
|
22
|
-
const n = cache.get('/a');
|
|
23
|
-
assert.strictEqual(n!.v, 2);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('put() with fewer keys replaces node', () => {
|
|
27
|
-
cache.put({ $path: '/a', $type: 'x', old: 1, keep: 2 } as any);
|
|
28
|
-
cache.put({ $path: '/a', $type: 'x', keep: 3 } as any);
|
|
29
|
-
const n = cache.get('/a') as any;
|
|
30
|
-
assert.strictEqual(n.keep, 3);
|
|
31
|
-
assert.strictEqual('old' in n, false);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('subscribePath fires on put()', () => {
|
|
35
|
-
let called = 0;
|
|
36
|
-
cache.subscribePath('/a', () => called++);
|
|
37
|
-
cache.put({ $path: '/a', $type: 'x' } as any);
|
|
38
|
-
assert.strictEqual(called, 1);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('subscribeChildren fires on child put()', () => {
|
|
42
|
-
let called = 0;
|
|
43
|
-
cache.subscribeChildren('/', () => called++);
|
|
44
|
-
cache.put({ $path: '/child', $type: 'x' } as any);
|
|
45
|
-
assert.ok(called >= 1);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('getSnapshot() returns plain cloneable object', () => {
|
|
49
|
-
cache.put({ $path: '/a', $type: 'x', v: 42 } as any);
|
|
50
|
-
const s = cache.getSnapshot('/a');
|
|
51
|
-
assert.ok(s);
|
|
52
|
-
assert.strictEqual(s!.v, 42);
|
|
53
|
-
const cloned = structuredClone(s);
|
|
54
|
-
assert.strictEqual(cloned.v, 42);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('getSnapshot() returns a copy, not the same reference', () => {
|
|
58
|
-
cache.put({ $path: '/a', $type: 'x', v: 42 } as any);
|
|
59
|
-
const s = cache.getSnapshot('/a');
|
|
60
|
-
assert.notStrictEqual(s, cache.get('/a'));
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('notifyPath fires path subs', () => {
|
|
64
|
-
cache.put({ $path: '/a', $type: 'x' } as any);
|
|
65
|
-
let called = 0;
|
|
66
|
-
cache.subscribePath('/a', () => called++);
|
|
67
|
-
cache.notifyPath('/a');
|
|
68
|
-
assert.strictEqual(called, 1);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('getChildren returns sorted children', () => {
|
|
72
|
-
cache.put({ $path: '/b', $type: 'x' } as any);
|
|
73
|
-
cache.put({ $path: '/a', $type: 'x' } as any);
|
|
74
|
-
const kids = cache.getChildren('/');
|
|
75
|
-
assert.strictEqual(kids.length, 2);
|
|
76
|
-
assert.strictEqual(kids[0].$path, '/a');
|
|
77
|
-
assert.strictEqual(kids[1].$path, '/b');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('remove() deletes node and fires subs', () => {
|
|
81
|
-
cache.put({ $path: '/a', $type: 'x' } as any);
|
|
82
|
-
let called = 0;
|
|
83
|
-
cache.subscribePath('/a', () => called++);
|
|
84
|
-
cache.remove('/a');
|
|
85
|
-
assert.strictEqual(cache.get('/a'), undefined);
|
|
86
|
-
assert.strictEqual(called, 1);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('putMany() stores all nodes, fires subs', () => {
|
|
90
|
-
let parentFired = 0;
|
|
91
|
-
cache.subscribeChildren('/p', () => parentFired++);
|
|
92
|
-
cache.putMany([
|
|
93
|
-
{ $path: '/p/a', $type: 'x' } as any,
|
|
94
|
-
{ $path: '/p/b', $type: 'x' } as any,
|
|
95
|
-
], '/p');
|
|
96
|
-
assert.ok(cache.get('/p/a'));
|
|
97
|
-
assert.ok(cache.get('/p/b'));
|
|
98
|
-
assert.ok(parentFired >= 1);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('direct mutation of get() result changes cached value', () => {
|
|
102
|
-
cache.put({ $path: '/a', $type: 'x', v: 1 } as any);
|
|
103
|
-
const n = cache.get('/a') as any;
|
|
104
|
-
n.v = 99;
|
|
105
|
-
assert.strictEqual(cache.get('/a')!.v, 99);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('onNodePut callback fires on every put()', () => {
|
|
109
|
-
const paths: string[] = [];
|
|
110
|
-
cache.onNodePut((p) => paths.push(p));
|
|
111
|
-
cache.put({ $path: '/a', $type: 'x' } as any);
|
|
112
|
-
cache.put({ $path: '/b', $type: 'x' } as any);
|
|
113
|
-
assert.deepStrictEqual(paths, ['/a', '/b']);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('nested component update works', () => {
|
|
117
|
-
cache.put({ $path: '/a', $type: 'x', comp: { $type: 'y', count: 0, label: 'hi' } } as any);
|
|
118
|
-
cache.put({ $path: '/a', $type: 'x', comp: { $type: 'y', count: 5, label: 'hi' } } as any);
|
|
119
|
-
assert.strictEqual((cache.get('/a') as any).comp.count, 5);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('adding a new nested component', () => {
|
|
123
|
-
cache.put({ $path: '/a', $type: 'x' } as any);
|
|
124
|
-
cache.put({ $path: '/a', $type: 'x', comp: { $type: 'y', v: 1 } } as any);
|
|
125
|
-
assert.strictEqual((cache.get('/a') as any).comp.v, 1);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('removing a nested component', () => {
|
|
129
|
-
cache.put({ $path: '/a', $type: 'x', comp: { $type: 'y', v: 1 } } as any);
|
|
130
|
-
cache.put({ $path: '/a', $type: 'x' } as any);
|
|
131
|
-
assert.strictEqual('comp' in (cache.get('/a') as any), false);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('array replaced wholesale', () => {
|
|
135
|
-
cache.put({ $path: '/a', $type: 'x', items: [1, 2, 3] } as any);
|
|
136
|
-
cache.put({ $path: '/a', $type: 'x', items: [4, 5] } as any);
|
|
137
|
-
assert.deepStrictEqual([...(cache.get('/a') as any).items], [4, 5]);
|
|
138
|
-
});
|
|
139
|
-
});
|
package/src/client-tree.test.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import type { NodeData } from '@treenity/core';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { describe, it } from 'node:test';
|
|
4
|
-
import { createClientTree } from './client-tree';
|
|
5
|
-
|
|
6
|
-
// ── Mock tRPC client ──
|
|
7
|
-
|
|
8
|
-
function createMockTrpc(backing: Map<string, NodeData>) {
|
|
9
|
-
let calls = 0;
|
|
10
|
-
|
|
11
|
-
const mock = {
|
|
12
|
-
get calls() { return calls; },
|
|
13
|
-
resetCalls() { calls = 0; },
|
|
14
|
-
|
|
15
|
-
get: {
|
|
16
|
-
query: async ({ path }: { path: string }) => {
|
|
17
|
-
calls++;
|
|
18
|
-
return backing.get(path);
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
getChildren: {
|
|
22
|
-
query: async ({ path }: { path: string; limit?: number; offset?: number }) => {
|
|
23
|
-
calls++;
|
|
24
|
-
const prefix = path === '/' ? '/' : path + '/';
|
|
25
|
-
const items = [...backing.values()].filter(
|
|
26
|
-
n => n.$path.startsWith(prefix) && n.$path !== path
|
|
27
|
-
&& n.$path.slice(prefix.length).indexOf('/') === -1,
|
|
28
|
-
);
|
|
29
|
-
return { items, total: items.length };
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
set: {
|
|
33
|
-
mutate: async ({ node }: { node: Record<string, unknown> }) => {
|
|
34
|
-
calls++;
|
|
35
|
-
backing.set(node.$path as string, node as NodeData);
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
remove: {
|
|
39
|
-
mutate: async ({ path }: { path: string }) => {
|
|
40
|
-
calls++;
|
|
41
|
-
backing.delete(path);
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
return mock;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ── Tests ──
|
|
50
|
-
|
|
51
|
-
describe('createClientTree — unified client tree', () => {
|
|
52
|
-
it('/local/* paths stay in memory, never hit tRPC', async () => {
|
|
53
|
-
const mock = createMockTrpc(new Map());
|
|
54
|
-
const { tree: store } = createClientTree(mock as any);
|
|
55
|
-
|
|
56
|
-
await store.set({ $path: '/local/ui/theme', $type: 'theme', dark: true } as NodeData);
|
|
57
|
-
mock.resetCalls();
|
|
58
|
-
|
|
59
|
-
const node = await store.get('/local/ui/theme');
|
|
60
|
-
assert.equal(mock.calls, 0, 'tRPC should not be called for /local paths');
|
|
61
|
-
assert.equal(node?.$type, 'theme');
|
|
62
|
-
assert.equal((node as any).dark, true);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('non-local paths route through tRPC', async () => {
|
|
66
|
-
const backing = new Map<string, NodeData>();
|
|
67
|
-
backing.set('/orders/1', { $path: '/orders/1', $type: 'order', total: 42 } as NodeData);
|
|
68
|
-
const mock = createMockTrpc(backing);
|
|
69
|
-
const { tree: store } = createClientTree(mock as any);
|
|
70
|
-
|
|
71
|
-
const node = await store.get('/orders/1');
|
|
72
|
-
assert.ok(mock.calls > 0, 'tRPC should be called for remote paths');
|
|
73
|
-
assert.equal((node as any).total, 42);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('getChildren merges local and remote children', async () => {
|
|
77
|
-
const backing = new Map<string, NodeData>();
|
|
78
|
-
backing.set('/cloud', { $path: '/cloud', $type: 'dir' } as NodeData);
|
|
79
|
-
const mock = createMockTrpc(backing);
|
|
80
|
-
const { tree: store } = createClientTree(mock as any);
|
|
81
|
-
|
|
82
|
-
// Write a local node
|
|
83
|
-
await store.set({ $path: '/local', $type: 'dir' } as NodeData);
|
|
84
|
-
|
|
85
|
-
// getChildren('/') should return both
|
|
86
|
-
const { items } = await store.getChildren('/');
|
|
87
|
-
const paths = items.map((n: { $path: string }) => n.$path).sort();
|
|
88
|
-
assert.ok(paths.includes('/local'), 'should include local children');
|
|
89
|
-
assert.ok(paths.includes('/cloud'), 'should include remote children');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('remove /local/* does not call tRPC', async () => {
|
|
93
|
-
const mock = createMockTrpc(new Map());
|
|
94
|
-
const { tree: store } = createClientTree(mock as any);
|
|
95
|
-
|
|
96
|
-
await store.set({ $path: '/local/temp', $type: 'tmp' } as NodeData);
|
|
97
|
-
mock.resetCalls();
|
|
98
|
-
|
|
99
|
-
await store.remove('/local/temp');
|
|
100
|
-
// filterStore tries both, but remote remove is harmless no-op
|
|
101
|
-
const node = await store.get('/local/temp');
|
|
102
|
-
assert.equal(node, undefined, '/local/temp should be gone');
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('cached remote: second get skips tRPC', async () => {
|
|
106
|
-
const backing = new Map<string, NodeData>();
|
|
107
|
-
backing.set('/x', { $path: '/x', $type: 'test' } as NodeData);
|
|
108
|
-
const mock = createMockTrpc(backing);
|
|
109
|
-
const { tree: store } = createClientTree(mock as any);
|
|
110
|
-
|
|
111
|
-
await store.get('/x'); // populates cache
|
|
112
|
-
mock.resetCalls();
|
|
113
|
-
await store.get('/x'); // should hit cache
|
|
114
|
-
assert.equal(mock.calls, 0, 'second get should come from cache');
|
|
115
|
-
});
|
|
116
|
-
});
|
package/src/optimistic.test.ts
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
// Optimistic prediction tests — predictOptimistic updates cache for sync methods
|
|
2
|
-
|
|
3
|
-
import { registerType } from '@treenity/core/comp';
|
|
4
|
-
import assert from 'node:assert';
|
|
5
|
-
import { beforeEach, describe, it } from 'node:test';
|
|
6
|
-
import * as cache from './cache';
|
|
7
|
-
import { predictOptimistic } from './hooks';
|
|
8
|
-
|
|
9
|
-
class Counter {
|
|
10
|
-
count = 0;
|
|
11
|
-
|
|
12
|
-
increment() {
|
|
13
|
-
this.count++;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
setCount(data: { count: number }) {
|
|
17
|
-
this.count = data.count;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
async asyncAction() {
|
|
21
|
-
// Simulates server-only async method
|
|
22
|
-
await Promise.resolve();
|
|
23
|
-
this.count = 999;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
broken() {
|
|
27
|
-
throw new Error('intentional failure');
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
registerType('test.counter', Counter);
|
|
32
|
-
|
|
33
|
-
describe('predictOptimistic', () => {
|
|
34
|
-
beforeEach(() => {
|
|
35
|
-
cache.clear();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('sync method updates cache immediately', () => {
|
|
39
|
-
cache.put({ $path: '/c', $type: 'test.counter', count: 5 } as any);
|
|
40
|
-
|
|
41
|
-
predictOptimistic('/c', Counter, undefined, Counter.prototype.increment, undefined);
|
|
42
|
-
|
|
43
|
-
const node = cache.get('/c');
|
|
44
|
-
assert.strictEqual((node as any).count, 6);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('sync method with data updates cache', () => {
|
|
48
|
-
cache.put({ $path: '/c', $type: 'test.counter', count: 0 } as any);
|
|
49
|
-
|
|
50
|
-
predictOptimistic('/c', Counter, undefined, Counter.prototype.setCount, { count: 42 });
|
|
51
|
-
|
|
52
|
-
const node = cache.get('/c');
|
|
53
|
-
assert.strictEqual((node as any).count, 42);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('does not mutate original cached node', () => {
|
|
57
|
-
cache.put({ $path: '/c', $type: 'test.counter', count: 10 } as any);
|
|
58
|
-
const before = cache.get('/c');
|
|
59
|
-
|
|
60
|
-
predictOptimistic('/c', Counter, undefined, Counter.prototype.increment, undefined);
|
|
61
|
-
|
|
62
|
-
const after = cache.get('/c');
|
|
63
|
-
assert.notStrictEqual(before, after);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('skips async methods', () => {
|
|
67
|
-
cache.put({ $path: '/c', $type: 'test.counter', count: 0 } as any);
|
|
68
|
-
|
|
69
|
-
predictOptimistic('/c', Counter, undefined, Counter.prototype.asyncAction, undefined);
|
|
70
|
-
|
|
71
|
-
const node = cache.get('/c');
|
|
72
|
-
assert.strictEqual((node as any).count, 0);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('skips when path not in cache', () => {
|
|
76
|
-
predictOptimistic('/missing', Counter, undefined, Counter.prototype.increment, undefined);
|
|
77
|
-
|
|
78
|
-
assert.strictEqual(cache.get('/missing'), undefined);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('swallows method errors without updating cache', () => {
|
|
82
|
-
cache.put({ $path: '/c', $type: 'test.counter', count: 5 } as any);
|
|
83
|
-
|
|
84
|
-
predictOptimistic('/c', Counter, undefined, Counter.prototype.broken, undefined);
|
|
85
|
-
|
|
86
|
-
const node = cache.get('/c');
|
|
87
|
-
assert.strictEqual((node as any).count, 5);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('skips when component type not found on node', () => {
|
|
91
|
-
cache.put({ $path: '/c', $type: 'unknown.type', count: 5 } as any);
|
|
92
|
-
|
|
93
|
-
predictOptimistic('/c', Counter, undefined, Counter.prototype.increment, undefined);
|
|
94
|
-
|
|
95
|
-
const node = cache.get('/c');
|
|
96
|
-
assert.strictEqual((node as any).count, 5);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('works with named component key', () => {
|
|
100
|
-
cache.put({
|
|
101
|
-
$path: '/n',
|
|
102
|
-
$type: 'dir',
|
|
103
|
-
stats: { $type: 'test.counter', count: 3 },
|
|
104
|
-
} as any);
|
|
105
|
-
|
|
106
|
-
predictOptimistic('/n', Counter, 'stats', Counter.prototype.increment, undefined);
|
|
107
|
-
|
|
108
|
-
const node = cache.get('/n') as any;
|
|
109
|
-
assert.strictEqual(node.stats.count, 4);
|
|
110
|
-
});
|
|
111
|
-
});
|