@treenity/react 3.0.1 → 3.0.2

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 (93) hide show
  1. package/README.md +1 -1
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +33 -7
  4. package/dist/App.js.map +1 -1
  5. package/dist/ComponentSection.js +1 -1
  6. package/dist/ComponentSection.js.map +1 -1
  7. package/dist/ErrorBoundary.d.ts.map +1 -1
  8. package/dist/ErrorBoundary.js +2 -1
  9. package/dist/ErrorBoundary.js.map +1 -1
  10. package/dist/cache.d.ts.map +1 -1
  11. package/dist/cache.js +5 -0
  12. package/dist/cache.js.map +1 -1
  13. package/dist/client-tree.d.ts.map +1 -1
  14. package/dist/client-tree.js +2 -2
  15. package/dist/client-tree.js.map +1 -1
  16. package/dist/components/ui/button.d.ts +2 -2
  17. package/dist/components/ui/button.d.ts.map +1 -1
  18. package/dist/components/ui/button.js +3 -3
  19. package/dist/components/ui/button.js.map +1 -1
  20. package/dist/components/ui/pagination.d.ts +2 -2
  21. package/dist/components/ui/pagination.d.ts.map +1 -1
  22. package/dist/components/ui/pagination.js +3 -3
  23. package/dist/components/ui/pagination.js.map +1 -1
  24. package/dist/components/ui/textarea.js +1 -1
  25. package/dist/components/ui/textarea.js.map +1 -1
  26. package/dist/events.d.ts +2 -0
  27. package/dist/events.d.ts.map +1 -1
  28. package/dist/events.js +47 -2
  29. package/dist/events.js.map +1 -1
  30. package/dist/fiber-tree.d.ts.map +1 -1
  31. package/dist/fiber-tree.js.map +1 -1
  32. package/dist/hooks.d.ts +9 -0
  33. package/dist/hooks.d.ts.map +1 -1
  34. package/dist/hooks.js +80 -5
  35. package/dist/hooks.js.map +1 -1
  36. package/dist/lib/minimd.d.ts.map +1 -1
  37. package/dist/lib/minimd.js +8 -1
  38. package/dist/lib/minimd.js.map +1 -1
  39. package/dist/lib/sanitize-href.d.ts +3 -0
  40. package/dist/lib/sanitize-href.d.ts.map +1 -0
  41. package/dist/lib/sanitize-href.js +14 -0
  42. package/dist/lib/sanitize-href.js.map +1 -0
  43. package/dist/main.js +8 -2
  44. package/dist/main.js.map +1 -1
  45. package/dist/mods/editor-ui/FieldLabel.d.ts.map +1 -1
  46. package/dist/mods/editor-ui/FieldLabel.js +2 -1
  47. package/dist/mods/editor-ui/FieldLabel.js.map +1 -1
  48. package/dist/mods/editor-ui/default-edit.js +4 -2
  49. package/dist/mods/editor-ui/default-edit.js.map +1 -1
  50. package/dist/mods/editor-ui/form-field.d.ts.map +1 -1
  51. package/dist/mods/editor-ui/form-field.js +3 -2
  52. package/dist/mods/editor-ui/form-field.js.map +1 -1
  53. package/dist/mods/editor-ui/type-picker.d.ts.map +1 -1
  54. package/dist/mods/editor-ui/type-picker.js +2 -1
  55. package/dist/mods/editor-ui/type-picker.js.map +1 -1
  56. package/dist/mods/treenity/preview.d.ts.map +1 -1
  57. package/dist/mods/treenity/preview.js +2 -3
  58. package/dist/mods/treenity/preview.js.map +1 -1
  59. package/dist/mods/treenity/ref-view.js +2 -1
  60. package/dist/mods/treenity/ref-view.js.map +1 -1
  61. package/dist/mods/treenity/seed.js +3 -2
  62. package/dist/mods/treenity/seed.js.map +1 -1
  63. package/dist/symbols.d.ts.map +1 -1
  64. package/dist/symbols.js +11 -5
  65. package/dist/symbols.js.map +1 -1
  66. package/package.json +4 -2
  67. package/src/App.tsx +29 -1
  68. package/src/ComponentSection.tsx +1 -1
  69. package/src/ErrorBoundary.tsx +6 -3
  70. package/src/cache.ts +7 -0
  71. package/src/client-tree.ts +7 -7
  72. package/src/components/ui/button.tsx +4 -5
  73. package/src/components/ui/pagination.tsx +4 -9
  74. package/src/components/ui/textarea.tsx +1 -1
  75. package/src/events.ts +46 -6
  76. package/src/fiber-tree.ts +3 -3
  77. package/src/hooks.ts +73 -4
  78. package/src/lib/minimd.ts +7 -1
  79. package/src/lib/sanitize-href.ts +13 -0
  80. package/src/main.tsx +11 -3
  81. package/src/mods/editor-ui/FieldLabel.tsx +5 -4
  82. package/src/mods/editor-ui/default-edit.tsx +6 -4
  83. package/src/mods/editor-ui/form-field.tsx +4 -2
  84. package/src/mods/editor-ui/type-picker.tsx +3 -2
  85. package/src/mods/treenity/preview.tsx +6 -7
  86. package/src/mods/treenity/ref-view.tsx +11 -6
  87. package/src/mods/treenity/seed.ts +3 -2
  88. package/src/symbols.ts +12 -5
  89. package/src/bind/bind.test.ts +0 -316
  90. package/src/cache.test.ts +0 -139
  91. package/src/client-tree.test.ts +0 -116
  92. package/src/optimistic.test.ts +0 -111
  93. package/src/remote-tree.test.ts +0 -142
@@ -1,3 +1,5 @@
1
+ import { Checkbox } from '#components/ui/checkbox';
2
+ import { Input } from '#components/ui/input';
1
3
  import { useSchema } from '#schema-loader';
2
4
  import { type ComponentData, isRef, register, resolve } from '@treenity/core';
3
5
  import { createElement } from 'react';
@@ -57,12 +59,12 @@ function DefaultEditForm({ value, onChange }: { value: ComponentData; onChange?:
57
59
  <FieldLabel label={k} value={v} onChange={onCh} />
58
60
  {typeof v === 'boolean' ? (
59
61
  <label className="flex items-center gap-2 cursor-pointer">
60
- <input type="checkbox" checked={!!data[k]} className="w-auto"
61
- onChange={(e) => setData((prev) => ({ ...prev, [k]: e.target.checked }))} />
62
+ <Checkbox checked={!!data[k]}
63
+ onChange={(e) => setData((prev) => ({ ...prev, [k]: (e.target as HTMLInputElement).checked }))} />
62
64
  {data[k] ? 'true' : 'false'}
63
65
  </label>
64
66
  ) : typeof v === 'number' ? (
65
- <input type="number" value={String(data[k] ?? 0)}
67
+ <Input type="number" className="h-7 text-xs" value={String(data[k] ?? 0)}
66
68
  onChange={(e) => setData((prev) => ({ ...prev, [k]: Number(e.target.value) }))} />
67
69
  ) : Array.isArray(v) ? (
68
70
  <StringArrayField value={data[k] as unknown[]}
@@ -78,7 +80,7 @@ function DefaultEditForm({ value, onChange }: { value: ComponentData; onChange?:
78
80
  : <pre className="text-[11px] font-mono text-foreground/60">{JSON.stringify(data[k], null, 2)}</pre>;
79
81
  })()
80
82
  ) : (
81
- <input value={String(data[k] ?? '')}
83
+ <Input className="h-7 text-xs" value={String(data[k] ?? '')}
82
84
  onChange={(e) => setData((prev) => ({ ...prev, [k]: e.target.value }))} />
83
85
  )}
84
86
  </div>
@@ -1,5 +1,6 @@
1
1
  import { Button } from '#components/ui/button';
2
2
  import { Input } from '#components/ui/input';
3
+ import { Textarea } from '#components/ui/textarea';
3
4
  import { isRef, resolveExact } from '@treenity/core';
4
5
  import { createElement, useState } from 'react';
5
6
  import { FieldLabel, RefEditor } from './FieldLabel';
@@ -42,7 +43,7 @@ export function renderField(
42
43
  const handler = resolveExact(fieldSchema.type, ctx) ?? resolveExact('string', ctx);
43
44
  if (!handler)
44
45
  return (
45
- <div key={name} className="text-[--danger] text-xs">
46
+ <div key={name} className="text-destructive text-xs">
46
47
  No form handler: {fieldSchema.type}
47
48
  </div>
48
49
  );
@@ -91,7 +92,8 @@ export function StringArrayField({
91
92
 
92
93
  if (!isStrings) {
93
94
  return (
94
- <textarea
95
+ <Textarea
96
+ className="min-h-16 text-xs font-mono"
95
97
  value={JSON.stringify(value, null, 2)}
96
98
  onChange={(e) => {
97
99
  try {
@@ -1,6 +1,7 @@
1
1
  import { Button } from '#components/ui/button';
2
2
  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '#components/ui/command';
3
3
  import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '#components/ui/dialog';
4
+ import { Input } from '#components/ui/input';
4
5
  import { trpc } from '#trpc';
5
6
  import { isOfType, type NodeData } from '@treenity/core';
6
7
  import { useEffect, useRef, useState } from 'react';
@@ -123,9 +124,9 @@ export function TypePicker({
123
124
  </Command>
124
125
 
125
126
  <div className="px-4 py-3 border-t border-border">
126
- <input
127
+ <Input
127
128
  ref={nameRef}
128
- className="w-full"
129
+ className="h-8 text-sm"
129
130
  placeholder={nameLabel}
130
131
  value={name}
131
132
  onChange={(e) => { setName(e.target.value); setNameManual(true); }}
@@ -1,5 +1,6 @@
1
1
  // Reusable type preview — Storybook-like: context switcher + live render + schema form editor
2
2
 
3
+ import { Button } from '#components/ui/button';
3
4
  import { Render, RenderContext } from '#context';
4
5
  import { type ComponentData, getContextsForType, type NodeData } from '@treenity/core';
5
6
  import { useMemo, useState } from 'react';
@@ -94,17 +95,15 @@ export function TypePreview({ typeName, properties }: {
94
95
  <div>
95
96
  <div className="flex gap-1.5 mb-2">
96
97
  {reactContexts.map(c => (
97
- <button
98
+ <Button
98
99
  key={c}
100
+ variant={previewCtx === c ? 'default' : 'outline'}
101
+ size="sm"
102
+ className="h-auto rounded-full px-2.5 py-0.5 text-xs font-mono"
99
103
  onClick={() => setPreviewCtx(prev => prev === c ? null : c)}
100
- className={`px-2.5 py-0.5 rounded-full text-xs font-mono border cursor-pointer transition-colors ${
101
- previewCtx === c
102
- ? 'bg-primary text-primary-foreground border-primary'
103
- : 'bg-muted border-border text-muted-foreground hover:border-primary/50'
104
- }`}
105
104
  >
106
105
  {c}
107
- </button>
106
+ </Button>
108
107
  ))}
109
108
  </div>
110
109
 
@@ -1,5 +1,6 @@
1
1
  // Ref node/component view — shows target path, resolve button, inline preview
2
2
 
3
+ import { Button } from '#components/ui/button';
3
4
  import { Render } from '#context';
4
5
  import { usePath } from '#hooks';
5
6
  import { type NodeData, register } from '@treenity/core';
@@ -45,20 +46,24 @@ function RefDisplay({ target, onSelect }: { target: string; onSelect?: (p: strin
45
46
  <span className="text-sm font-mono text-primary">{target}</span>
46
47
 
47
48
  {onSelect && (
48
- <button
49
- className="text-xs px-2 py-0.5 rounded bg-muted hover:bg-muted/80 text-foreground border border-border"
49
+ <Button
50
+ variant="outline"
51
+ size="sm"
52
+ className="h-auto px-2 py-0.5 text-xs"
50
53
  onClick={() => onSelect(target)}
51
54
  >
52
55
  Go to
53
- </button>
56
+ </Button>
54
57
  )}
55
58
 
56
- <button
57
- className="text-xs px-2 py-0.5 rounded bg-muted hover:bg-muted/80 text-foreground border border-border"
59
+ <Button
60
+ variant="outline"
61
+ size="sm"
62
+ className="h-auto px-2 py-0.5 text-xs"
58
63
  onClick={() => setResolved(!resolved)}
59
64
  >
60
65
  {resolved ? 'Collapse' : 'Resolve'}
61
- </button>
66
+ </Button>
62
67
  </div>
63
68
 
64
69
  {/* Resolved target */}
@@ -1,9 +1,9 @@
1
- import { type NodeData, R, S } from '@treenity/core';
1
+ import { type NodeData, A, R, S, W } from '@treenity/core';
2
2
  import { registerPrefab } from '@treenity/core/mod';
3
3
 
4
4
  registerPrefab('core', 'seed', [
5
5
  { $path: 'sys', $type: 'treenity.system' },
6
- { $path: 'auth', $type: 'dir' },
6
+ { $path: 'auth', $type: 'dir', $acl: [{ g: 'admins', p: R | W | A | S }, { g: 'public', p: 0 }] },
7
7
  { $path: 'auth/users', $type: 'mount-point',
8
8
  connection: { $type: 'connection', db: 'treenity', collection: 'users' },
9
9
  mount: { $type: 't.mount.mongo' },
@@ -20,6 +20,7 @@ registerPrefab('core', 'seed', [
20
20
  { $path: 'auth/sessions', $type: 'mount-point',
21
21
  connection: { $type: 'connection', db: 'treenity', collection: 'sessions' },
22
22
  mount: { $type: 't.mount.mongo' },
23
+ $acl: [{ g: 'admins', p: R | W | A | S }, { g: 'authenticated', p: 0 }, { g: 'public', p: 0 }],
23
24
  },
24
25
  { $path: 'mnt', $type: 'dir' },
25
26
  { $path: 'mnt/orders', $type: 't.mount.mongo',
package/src/symbols.ts CHANGED
@@ -1,18 +1,25 @@
1
1
  // Symbol-based component location metadata.
2
- // Stamped on deserialization (cache.put). Survive spread, invisible to JSON/keys/entries.
2
+ // Stamped on deserialization (cache.put). Non-enumerable so they're
3
+ // invisible to structuredClone, spread, and JSON/keys/entries.
3
4
 
4
5
  import { isComponent, type NodeData } from '@treenity/core';
5
6
 
6
7
  export const $key = Symbol.for('treenity.$key');
7
8
  export const $node = Symbol.for('treenity.$node');
8
9
 
10
+ function hide(obj: object, sym: symbol, value: unknown): void {
11
+ Object.defineProperty(obj, sym, { value, enumerable: false, writable: false, configurable: true });
12
+ }
13
+
9
14
  export function stampNode(node: NodeData): void {
10
- (node as any)[$key] = '';
11
- (node as any)[$node] = node;
15
+ if ((node as any)[$node] === node) return;
16
+
17
+ hide(node, $key, '');
18
+ hide(node, $node, node);
12
19
 
13
20
  for (const [k, v] of Object.entries(node)) {
14
21
  if (k.startsWith('$') || !isComponent(v)) continue;
15
- (v as any)[$key] = k;
16
- (v as any)[$node] = node;
22
+ hide(v, $key, k);
23
+ hide(v, $node, node);
17
24
  }
18
25
  }
@@ -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
- });