native-document 1.0.169 → 1.0.172

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 (123) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/docs/vitepress-conventions.md +2 -2
  3. package/package.json +11 -4
  4. package/src/components/BaseComponent.js +43 -1
  5. package/src/components/accordion/Accordion.js +1 -0
  6. package/src/components/accordion/AccordionItem.js +1 -0
  7. package/src/components/alert/Alert.js +4 -0
  8. package/src/components/avatar/Avatar.js +1 -0
  9. package/src/components/avatar/AvatarGroup.js +1 -0
  10. package/src/components/badge/Badge.js +1 -0
  11. package/src/components/breadcrumb/BreadCrumb.js +4 -0
  12. package/src/components/button/Button.js +1 -0
  13. package/src/components/card/Card.js +1 -0
  14. package/src/components/context-menu/ContextMenu.js +2 -0
  15. package/src/components/context-menu/ContextMenuGroup.js +2 -0
  16. package/src/components/context-menu/ContextMenuItem.js +2 -0
  17. package/src/components/divider/Divider.js +2 -0
  18. package/src/components/dropdown/Dropdown.js +1 -0
  19. package/src/components/dropdown/DropdownDivider.js +1 -0
  20. package/src/components/dropdown/DropdownGroup.js +1 -0
  21. package/src/components/dropdown/DropdownItem.js +1 -0
  22. package/src/components/dropdown/DropdownTrigger.js +1 -0
  23. package/src/components/form/FormControl.js +1 -0
  24. package/src/components/form/field/Field.js +1 -0
  25. package/src/components/form/field/types/StringField.js +1 -1
  26. package/src/components/icon/Icon.js +1 -0
  27. package/src/components/list/ListItem.js +1 -0
  28. package/src/components/menu/Menu.js +3 -0
  29. package/src/components/menu/MenuDivider.js +1 -0
  30. package/src/components/menu/MenuGroup.js +1 -0
  31. package/src/components/menu/MenuItem.js +1 -0
  32. package/src/components/menu/MenuLink.js +1 -0
  33. package/src/components/modal/Modal.js +4 -0
  34. package/src/components/pagination/Pagination.js +1 -0
  35. package/src/components/popover/Popover.js +1 -0
  36. package/src/components/popover/PopoverFooter.js +1 -0
  37. package/src/components/popover/PopoverHeader.js +1 -0
  38. package/src/components/progress/Progress.js +5 -0
  39. package/src/components/skeleton/Skeleton.js +4 -0
  40. package/src/components/slider/Slider.js +1 -0
  41. package/src/components/spacer/Spacer.js +1 -0
  42. package/src/components/spinner/Spinner.js +1 -0
  43. package/src/components/splitter/Splitter.js +1 -0
  44. package/src/components/splitter/SplitterGutter.js +6 -0
  45. package/src/components/splitter/SplitterPanel.js +1 -0
  46. package/src/components/stacks/AbsoluteStack.js +1 -0
  47. package/src/components/stacks/FixedStack.js +1 -0
  48. package/src/components/stacks/HStack.js +1 -0
  49. package/src/components/stacks/PositionStack.js +1 -0
  50. package/src/components/stacks/RelativeStack.js +1 -0
  51. package/src/components/stacks/Stack.js +1 -0
  52. package/src/components/stacks/VStack.js +1 -0
  53. package/src/components/stepper/Stepper.js +1 -0
  54. package/src/components/stepper/StepperStep.js +1 -0
  55. package/src/components/switch/Switch.js +1 -0
  56. package/src/components/table/DataTable.js +1 -0
  57. package/src/components/table/SimpleTable.js +1 -0
  58. package/src/components/tabs/Tabs.js +1 -0
  59. package/src/components/toast/Toast.js +1 -0
  60. package/src/components/tooltip/Tooltip.js +1 -0
  61. package/src/core/data/MemoryManager.js +6 -1
  62. package/src/core/data/ObservableItem.js +28 -10
  63. package/src/core/elements/anchor/anchor.js +4 -3
  64. package/src/core/elements/anchor/one-child-anchor-overwriting.js +4 -4
  65. package/src/core/elements/content-formatter.js +19 -0
  66. package/src/core/elements/control/for-each-array-cache.js +59 -0
  67. package/src/core/elements/control/for-each-array.js +15 -23
  68. package/src/core/wrappers/AttributesWrapper.js +12 -12
  69. package/src/core/wrappers/ElementCreator.js +33 -10
  70. package/src/core/wrappers/HtmlElementWrapper.js +9 -4
  71. package/src/core/wrappers/NDElement.js +23 -8
  72. package/src/core/wrappers/NdPrototype.js +31 -39
  73. package/src/core/wrappers/constants.js +12 -12
  74. package/src/core/wrappers/prototypes/nd-element-extensions.js +127 -57
  75. package/src/ui/components/accordion/AccordionItemRender.js +29 -7
  76. package/src/ui/components/alert/AlertRender.js +6 -0
  77. package/src/ui/components/breadcrumb/BreadcrumbRender.js +9 -1
  78. package/src/ui/components/button/ButtonRender.js +3 -0
  79. package/src/ui/components/contextmenu/ContextmenuRender.js +2 -0
  80. package/src/ui/components/dropdown/DropdownRender.js +3 -0
  81. package/src/ui/components/dropdown/item/DropdownItemRender.js +6 -1
  82. package/src/ui/components/form/FieldCollectionRender.js +4 -0
  83. package/src/ui/components/form/fields/CheckboxFieldRender.js +4 -0
  84. package/src/ui/components/form/fields/CheckboxGroupFieldRender.js +4 -0
  85. package/src/ui/components/form/fields/RadioFieldRender.js +4 -0
  86. package/src/ui/components/form/fields/RangeFieldRender.js +4 -0
  87. package/src/ui/components/form/fields/SelectFieldRender.js +5 -0
  88. package/src/ui/components/form/fields/SliderFieldRender.js +3 -0
  89. package/src/ui/components/icon/tabler/TablerIconRender.js +0 -1
  90. package/src/ui/components/list/item/ListItemRender.js +4 -0
  91. package/src/ui/components/menu/MenuItemRender.js +8 -0
  92. package/src/ui/components/menu/MenuLinkRender.js +11 -0
  93. package/src/ui/components/menu/MenuRender.js +5 -0
  94. package/src/ui/components/menu/helpers.js +2 -1
  95. package/src/ui/components/modal/ModalRender.js +8 -2
  96. package/src/ui/components/progress/ProgressRender.js +9 -0
  97. package/src/ui/components/skeleton/SkeletonRender.js +2 -0
  98. package/src/ui/components/spinner/SpinnerRender.js +3 -0
  99. package/src/ui/components/splitter/SplitterGutterRender.js +4 -0
  100. package/src/ui/components/splitter/SplitterPanelRender.js +4 -0
  101. package/src/ui/components/splitter/SplitterRender.js +2 -0
  102. package/src/ui/components/stepper/StepperRender.js +2 -1
  103. package/src/ui/components/stepper/StepperStepRender.js +8 -1
  104. package/src/ui/components/switch/SwitchRender.js +3 -0
  105. package/src/ui/components/table/data-table/DataTableRender.js +4 -0
  106. package/src/ui/components/table/simple-table/SimpleTableRender.js +2 -0
  107. package/src/ui/components/toast/ToastRender.js +4 -0
  108. package/src/ui/utils/aria.js +19 -0
  109. package/tests/integration/ui/attributes-wrapper.test.js +177 -0
  110. package/tests/integration/ui/for-each-array.test.js +167 -0
  111. package/tests/integration/ui/lifecycle.test.js +146 -0
  112. package/tests/integration/ui/show-if.test.js +168 -0
  113. package/tests/unit/core/observable-array.test.js +404 -0
  114. package/tests/unit/core/observable-item.test.js +428 -0
  115. package/tests/unit/core/observable-object.test.js +239 -0
  116. package/tests/unit/core/observable-resource.test.js +323 -0
  117. package/tests/unit/core/store.test.js +347 -0
  118. package/tests/unit/router/route-matching.test.js +169 -0
  119. package/tests/unit/router/router-guards.test.js +189 -0
  120. package/tests/unit/utils/cache.test.js +120 -0
  121. package/tests/unit/utils/filters.test.js +394 -0
  122. package/types/elements.d.ts +5 -0
  123. package/vitest.config.js +15 -0
@@ -0,0 +1,323 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Observable } from '../../../src/core/data/Observable';
3
+ import ObservableResource from '../../../src/core/data/ObservableResource';
4
+ import ObservableItem from '../../../src/core/data/ObservableItem';
5
+
6
+ const resource = (fn, deps = [], config = {}) =>
7
+ new ObservableResource(fn, deps, config);
8
+
9
+ const resolved = (value) => () => Promise.resolve(value);
10
+ const rejected = (err) => () => Promise.reject(err);
11
+
12
+ describe('ObservableResource', () => {
13
+
14
+ // -- initial state --------------------------------------------------------
15
+
16
+ describe('initial state', () => {
17
+ it('state is unresolved before any fetch', () => {
18
+ const r = resource(resolved('data'), []);
19
+ expect(r.state.val()).toBe('unresolved');
20
+ });
21
+
22
+ it('data is null before any fetch', () => {
23
+ const r = resource(resolved('data'), []);
24
+ expect(r.data.val()).toBeNull();
25
+ });
26
+
27
+ it('isUnresolved() returns true before first fetch', () => {
28
+ const r = resource(resolved('data'), []);
29
+ expect(r.isUnresolved().val()).toBe(true);
30
+ });
31
+
32
+ it('loading is false before any fetch', () => {
33
+ const r = resource(resolved('data'), []);
34
+ expect(r.loading.val()).toBe(false);
35
+ });
36
+ });
37
+
38
+ // -- fetch() --------------------------------------------------------------
39
+
40
+ describe('fetch()', () => {
41
+ it('transitions to pending then ready', async () => {
42
+ const r = resource(resolved('hello'), []);
43
+ r.fetch();
44
+ expect(r.state.val()).toBe('pending');
45
+ await new Promise((res) => setTimeout(res, 0));
46
+ expect(r.state.val()).toBe('ready');
47
+ });
48
+
49
+ it('sets data on success', async () => {
50
+ const r = resource(resolved({ id: 1 }), []);
51
+ r.fetch();
52
+ await new Promise((res) => setTimeout(res, 0));
53
+ expect(r.data.val()).toEqual({ id: 1 });
54
+ });
55
+
56
+ it('sets error on failure', async () => {
57
+ const err = new Error('Network error');
58
+ const r = resource(rejected(err), []);
59
+ r.fetch();
60
+ await new Promise((res) => setTimeout(res, 0));
61
+ expect(r.state.val()).toBe('errored');
62
+ expect(r.error.val()).toBe(err);
63
+ });
64
+
65
+ it('clears error on successful re-fetch', async () => {
66
+ const r = resource(rejected(new Error('fail')), []);
67
+ r.fetch();
68
+ await new Promise((res) => setTimeout(res, 0));
69
+ r.$fn = resolved('ok');
70
+ r.fetch();
71
+ await new Promise((res) => setTimeout(res, 0));
72
+ expect(r.error.val()).toBeNull();
73
+ });
74
+
75
+ it('state is pending while fetching', async () => {
76
+ let resolve;
77
+ // fn must declare signal param so $run uses AbortController path (async)
78
+ const fn = (_signal) => new Promise((r) => { resolve = r; });
79
+ const r = resource(fn, []);
80
+ r.fetch();
81
+ expect(r.state.val()).toBe('pending');
82
+ resolve('done');
83
+ await new Promise((res) => setTimeout(res, 0));
84
+ expect(r.state.val()).toBe('ready');
85
+ });
86
+
87
+ it('isReady() returns true after success', async () => {
88
+ const r = resource(resolved('ok'), []);
89
+ r.fetch();
90
+ await new Promise((res) => setTimeout(res, 0));
91
+ expect(r.isReady().val()).toBe(true);
92
+ });
93
+
94
+ it('isErrored() returns true after failure', async () => {
95
+ const r = resource(rejected(new Error('fail')), []);
96
+ r.fetch();
97
+ await new Promise((res) => setTimeout(res, 0));
98
+ expect(r.isErrored().val()).toBe(true);
99
+ });
100
+ });
101
+
102
+ // -- refetch() ------------------------------------------------------------
103
+
104
+ describe('refetch()', () => {
105
+ it('transitions to refreshing when data already exists', async () => {
106
+ const r = resource(resolved('first'), []);
107
+ r.fetch();
108
+ await new Promise((res) => setTimeout(res, 0));
109
+
110
+ let capturedState = null;
111
+ r.state.subscribe((state) => { capturedState = state; });
112
+ r.refetch();
113
+ expect(capturedState).toBe('refreshing');
114
+ });
115
+
116
+ it('updates data on success', async () => {
117
+ const r = resource(resolved('first'), []);
118
+ r.fetch();
119
+ await new Promise((res) => setTimeout(res, 0));
120
+
121
+ r.$fn = resolved('second');
122
+ r.refetch();
123
+ await new Promise((res) => setTimeout(res, 0));
124
+ expect(r.data.val()).toBe('second');
125
+ });
126
+ });
127
+
128
+ // -- mutate() -------------------------------------------------------------
129
+
130
+ describe('mutate()', () => {
131
+ it('updates data without triggering a fetch', () => {
132
+ const fn = vi.fn(resolved('original'));
133
+ const r = resource(fn, []);
134
+ r.mutate('overridden');
135
+ expect(r.data.val()).toBe('overridden');
136
+ expect(fn).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it('sets state to ready', () => {
140
+ const r = resource(resolved('data'), []);
141
+ r.mutate('value');
142
+ expect(r.state.val()).toBe('ready');
143
+ });
144
+
145
+ it('notifies data subscribers', () => {
146
+ const r = resource(resolved('data'), []);
147
+ const cb = vi.fn();
148
+ r.data.subscribe(cb);
149
+ r.mutate('new');
150
+ expect(cb).toHaveBeenCalledOnce();
151
+ });
152
+ });
153
+
154
+ // -- abort() --------------------------------------------------------------
155
+
156
+ describe('abort via AbortController', () => {
157
+ it('does not update data after abort', async () => {
158
+ let externalResolve;
159
+ const fn = (_signal) => new Promise((res) => { externalResolve = res; });
160
+ const r = resource(fn, []);
161
+ r.fetch();
162
+ r.$abort();
163
+ externalResolve('late data');
164
+ await new Promise((res) => setTimeout(res, 0));
165
+ expect(r.data.val()).toBeNull();
166
+ });
167
+ });
168
+
169
+ // -- auto fetch with deps -------------------------------------------------
170
+
171
+ describe('auto fetch with deps', () => {
172
+ it('fetches automatically when { auto: true } and no deps', async () => {
173
+ const r = resource(resolved('auto'), [], { auto: true });
174
+ await new Promise((res) => setTimeout(res, 0));
175
+ expect(r.data.val()).toBe('auto');
176
+ });
177
+
178
+ it('re-fetches when a dependency changes', async () => {
179
+ const dep = Observable(1);
180
+ const fn = vi.fn((val) => Promise.resolve(val * 10));
181
+ const r = resource(fn, [dep], { auto: true });
182
+ await new Promise((res) => setTimeout(res, 0));
183
+ expect(r.data.val()).toBe(10);
184
+
185
+ dep.set(2);
186
+ await new Promise((res) => setTimeout(res, 0));
187
+ expect(r.data.val()).toBe(20);
188
+ });
189
+
190
+ it('does not fetch immediately when { lazy: true }', async () => {
191
+ const dep = Observable(1);
192
+ const fn = vi.fn(resolved('data'));
193
+ resource(fn, [dep], { auto: true, lazy: true });
194
+ await new Promise((res) => setTimeout(res, 0));
195
+ expect(fn).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it('fetches after dep change when { lazy: true }', async () => {
199
+ const dep = Observable(1);
200
+ const fn = vi.fn(resolved('data'));
201
+ resource(fn, [dep], { auto: true, lazy: true });
202
+ dep.set(2);
203
+ await new Promise((res) => setTimeout(res, 0));
204
+ expect(fn).toHaveBeenCalledOnce();
205
+ });
206
+ });
207
+
208
+ // -- onSuccess() / onError() ----------------------------------------------
209
+
210
+ describe('onSuccess() / onError()', () => {
211
+ it('onSuccess fires after successful fetch', async () => {
212
+ // fn must declare _signal to use async path
213
+ const r = resource((_signal) => Promise.resolve('ok'), []);
214
+ r.fetch();
215
+ await new Promise((res) => setTimeout(res, 0));
216
+ expect(r.state.val()).toBe('ready');
217
+ expect(r.data.val()).toBe('ok');
218
+ });
219
+
220
+ it('onError fires after failed fetch', async () => {
221
+ const err = new Error('fail');
222
+ const cb = vi.fn();
223
+ const r = resource(rejected(err), []);
224
+ r.onError(cb);
225
+ r.fetch();
226
+ await new Promise((res) => setTimeout(res, 0));
227
+ expect(cb).toHaveBeenCalledWith(err);
228
+ });
229
+ });
230
+
231
+ // -- into() ---------------------------------------------------------------
232
+
233
+ describe('into()', () => {
234
+ it('writes result into provided observable', async () => {
235
+ const target = Observable(null);
236
+ const r = resource(resolved('result'), []);
237
+ r.into(target);
238
+ r.fetch();
239
+ await new Promise((res) => setTimeout(res, 0));
240
+ expect(target.val()).toBe('result');
241
+ });
242
+
243
+ it('this.data points to the target observable', () => {
244
+ const target = Observable(null);
245
+ const r = resource(resolved('data'), []);
246
+ r.into(target);
247
+ expect(r.data).toBe(target);
248
+ });
249
+ });
250
+
251
+ // -- apply() --------------------------------------------------------------
252
+
253
+ describe('apply()', () => {
254
+ it('transforms data with provided function', async () => {
255
+ const r = resource(resolved({ items: [1, 2, 3] }), []);
256
+ r.apply((result, data) => data.set(result.items));
257
+ r.fetch();
258
+ await new Promise((res) => setTimeout(res, 0));
259
+ expect(r.data.val()).toEqual([1, 2, 3]);
260
+ });
261
+ });
262
+
263
+ // -- destroy() ------------------------------------------------------------
264
+
265
+ describe('destroy()', () => {
266
+ it('aborts in-flight request', () => {
267
+ let resolve;
268
+ const fn = (_signal) => new Promise((r) => { resolve = r; });
269
+ const r = resource(fn, []);
270
+ r.fetch();
271
+ const controller = r.$controller;
272
+ r.destroy();
273
+ expect(controller.signal.aborted).toBe(true);
274
+ });
275
+
276
+ it('unsubscribes from all dependencies', () => {
277
+ const dep = Observable(1);
278
+ const fn = vi.fn(resolved('data'));
279
+ const r = resource(fn, [dep], { auto: true });
280
+ r.destroy();
281
+ const callCount = fn.mock.calls.length;
282
+ dep.set(2);
283
+ expect(fn.mock.calls.length).toBe(callCount);
284
+ });
285
+ });
286
+
287
+ // -- race condition -------------------------------------------------------
288
+
289
+ describe('race condition', () => {
290
+ it('last fetch call wins — stale response is ignored', async () => {
291
+ let resolveFirst;
292
+ let resolveSecond;
293
+
294
+ // Wrapper with explicit signal param — forces AbortController path
295
+ const makePromise = () => {
296
+ let res;
297
+ const p = new Promise((r) => { res = r; });
298
+ return { promise: p, resolve: res };
299
+ };
300
+
301
+ const p1 = makePromise();
302
+ const p2 = makePromise();
303
+ let call = 0;
304
+
305
+ // fn.length must be > 0 to trigger AbortController path
306
+ const fn = function(_signal) {
307
+ call++;
308
+ return call === 1 ? p1.promise : p2.promise;
309
+ };
310
+
311
+ const r = resource(fn, []);
312
+ r.fetch(); // first — will be aborted by second fetch
313
+ r.fetch(); // second — wins
314
+
315
+ p2.resolve('second');
316
+ p1.resolve('first'); // aborted, ignored
317
+
318
+ await new Promise((res) => setTimeout(res, 0));
319
+ expect(r.data.val()).toBe('second');
320
+ });
321
+ });
322
+
323
+ });
@@ -0,0 +1,347 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // DebugManager is an empty object when NODE_ENV is not 'development' or 'production'
4
+ // Mock it before importing Store to avoid 'warn is not a function' errors
5
+ vi.mock('../../../src/core/utils/debug-manager', () => ({
6
+ default: {
7
+ log: vi.fn(),
8
+ warn: vi.fn(),
9
+ error: vi.fn(),
10
+ disable: vi.fn(),
11
+ },
12
+ }));
13
+
14
+ import { StoreFactory } from '../../../src/core/data/Store';
15
+
16
+ // Each test uses a fresh isolated store to avoid name collisions
17
+ let Store;
18
+
19
+ beforeEach(() => {
20
+ Store = StoreFactory();
21
+ });
22
+
23
+ describe('Store', () => {
24
+
25
+ // -- create() -------------------------------------------------------------
26
+
27
+ describe('create()', () => {
28
+ it('creates a store and returns an observable', () => {
29
+ const obs = Store.create('count', 0);
30
+ expect(obs.val()).toBe(0);
31
+ });
32
+
33
+ it('creates a store with an array value', () => {
34
+ const obs = Store.create('items', [1, 2, 3]);
35
+ expect(obs.val()).toEqual([1, 2, 3]);
36
+ });
37
+
38
+ it('creates a store with an object value', () => {
39
+ const obs = Store.create('user', { name: 'John' });
40
+ expect(obs.get('name')).toBe('John');
41
+ });
42
+
43
+ it('throws if store name already exists', () => {
44
+ Store.create('x', 1);
45
+ expect(() => Store.create('x', 2)).toThrow();
46
+ });
47
+
48
+ it('two stores are fully independent', () => {
49
+ const a = Store.create('a', 1);
50
+ const b = Store.create('b', 2);
51
+ a.set(99);
52
+ expect(b.val()).toBe(2);
53
+ });
54
+ });
55
+
56
+ // -- createResettable() ---------------------------------------------------
57
+
58
+ describe('createResettable()', () => {
59
+ it('creates a resettable store', () => {
60
+ const obs = Store.createResettable('theme', 'light');
61
+ obs.set('dark');
62
+ Store.reset('theme');
63
+ expect(obs.val()).toBe('light');
64
+ });
65
+
66
+ it('throws if store name already exists', () => {
67
+ Store.createResettable('x', 1);
68
+ expect(() => Store.createResettable('x', 2)).toThrow();
69
+ });
70
+ });
71
+
72
+ // -- createComposed() -----------------------------------------------------
73
+
74
+ describe('createComposed()', () => {
75
+ it('creates a computed store derived from other stores', async () => {
76
+ Store.create('a', 2);
77
+ Store.create('b', 3);
78
+ const comp = Store.createComposed('sum', () => {
79
+ return Store.get('a').val() + Store.get('b').val();
80
+ }, ['a', 'b']);
81
+ expect(comp.val()).toBe(5);
82
+ });
83
+
84
+ it('updates when a dependency changes', async () => {
85
+ Store.create('x', 10);
86
+ const comp = Store.createComposed('doubled', () => Store.get('x').val() * 2, ['x']);
87
+ Store.get('x').set(20);
88
+ await new Promise((r) => setTimeout(r, 0));
89
+ expect(comp.val()).toBe(40);
90
+ });
91
+
92
+ it('throws if computation is not a function', () => {
93
+ expect(() => Store.createComposed('bad', 'not-a-fn', ['x'])).toThrow();
94
+ });
95
+
96
+ it('throws if dependencies is empty', () => {
97
+ expect(() => Store.createComposed('bad', () => {}, [])).toThrow();
98
+ });
99
+
100
+ it('throws if dependency store does not exist', () => {
101
+ expect(() => Store.createComposed('bad', () => {}, ['missing'])).toThrow();
102
+ });
103
+ });
104
+
105
+ // -- has() ----------------------------------------------------------------
106
+
107
+ describe('has()', () => {
108
+ it('returns true for existing store', () => {
109
+ Store.create('x', 1);
110
+ expect(Store.has('x')).toBe(true);
111
+ });
112
+
113
+ it('returns false for unknown store', () => {
114
+ expect(Store.has('unknown')).toBe(false);
115
+ });
116
+ });
117
+
118
+ // -- get() ----------------------------------------------------------------
119
+
120
+ describe('get()', () => {
121
+ it('returns the raw observable', () => {
122
+ const obs = Store.create('count', 42);
123
+ expect(Store.get('count')).toBe(obs);
124
+ });
125
+
126
+ it('returns null for unknown store', () => {
127
+ expect(Store.get('unknown')).toBeNull();
128
+ });
129
+ });
130
+
131
+ // -- use() ----------------------------------------------------------------
132
+
133
+ describe('use()', () => {
134
+ it('returns a two-way synchronized follower', () => {
135
+ Store.create('count', 0);
136
+ const follower = Store.use('count');
137
+ expect(follower.val()).toBe(0);
138
+ });
139
+
140
+ it('follower reflects store changes', () => {
141
+ Store.create('count', 0);
142
+ const follower = Store.use('count');
143
+ Store.get('count').set(5);
144
+ expect(follower.val()).toBe(5);
145
+ });
146
+
147
+ it('store reflects follower changes', () => {
148
+ Store.create('count', 0);
149
+ const follower = Store.use('count');
150
+ follower.set(10);
151
+ expect(Store.get('count').val()).toBe(10);
152
+ });
153
+
154
+ it('destroy() unsubscribes the follower', () => {
155
+ Store.create('count', 0);
156
+ const follower = Store.use('count');
157
+ const cb = vi.fn();
158
+ follower.subscribe(cb);
159
+ follower.destroy();
160
+ Store.get('count').set(99);
161
+ // After destroy, follower is cleaned up and no longer notified
162
+ expect(cb).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it('throws for a composed store', () => {
166
+ Store.create('dep', 1);
167
+ Store.createComposed('comp', () => 1, ['dep']);
168
+ expect(() => Store.use('comp')).toThrow();
169
+ });
170
+ });
171
+
172
+ // -- follow() -------------------------------------------------------------
173
+
174
+ describe('follow()', () => {
175
+ it('returns a read-only follower', () => {
176
+ Store.create('count', 0);
177
+ const follower = Store.follow('count');
178
+ expect(follower.val()).toBe(0);
179
+ });
180
+
181
+ it('follower reflects store changes', () => {
182
+ Store.create('count', 0);
183
+ const follower = Store.follow('count');
184
+ Store.get('count').set(5);
185
+ expect(follower.val()).toBe(5);
186
+ });
187
+
188
+ it('set() on follower throws', () => {
189
+ Store.create('count', 0);
190
+ const follower = Store.follow('count');
191
+ expect(() => follower.set(99)).toThrow();
192
+ });
193
+
194
+ it('toggle() on follower throws', () => {
195
+ Store.create('active', false);
196
+ const follower = Store.follow('active');
197
+ expect(() => follower.toggle()).toThrow();
198
+ });
199
+
200
+ it('destroy() unsubscribes the follower', () => {
201
+ Store.create('count', 0);
202
+ const follower = Store.follow('count');
203
+ const original = follower.val();
204
+ follower.destroy();
205
+ Store.get('count').set(99);
206
+ // After destroy, follower is no longer tracking the store
207
+ expect(original).toBe(0);
208
+ });
209
+ });
210
+
211
+ // -- reset() --------------------------------------------------------------
212
+
213
+ describe('reset()', () => {
214
+ it('resets a resettable store to initial value', () => {
215
+ Store.createResettable('count', 0);
216
+ Store.get('count').set(99);
217
+ Store.reset('count');
218
+ expect(Store.get('count').val()).toBe(0);
219
+ });
220
+
221
+ it('throws for a non-resettable store', () => {
222
+ Store.create('count', 0);
223
+ expect(() => Store.reset('count')).toThrow();
224
+ });
225
+
226
+ it('throws for a composed store', () => {
227
+ Store.create('dep', 1);
228
+ Store.createComposed('comp', () => 1, ['dep']);
229
+ expect(() => Store.reset('comp')).toThrow();
230
+ });
231
+
232
+ it('throws for unknown store', () => {
233
+ expect(() => Store.reset('unknown')).toThrow();
234
+ });
235
+ });
236
+
237
+ // -- delete() -------------------------------------------------------------
238
+
239
+ describe('delete()', () => {
240
+ it('removes a store', () => {
241
+ Store.create('temp', 1);
242
+ Store.delete('temp');
243
+ expect(Store.has('temp')).toBe(false);
244
+ });
245
+
246
+ it('destroys all followers on delete', () => {
247
+ Store.create('count', 0);
248
+ const follower = Store.use('count');
249
+ Store.delete('count');
250
+ expect(follower.$listeners?.length ?? 0).toBe(0);
251
+ });
252
+
253
+ it('does nothing for unknown store', () => {
254
+ expect(() => Store.delete('unknown')).not.toThrow();
255
+ });
256
+ });
257
+
258
+ // -- group() --------------------------------------------------------------
259
+
260
+ describe('group()', () => {
261
+ it('creates an isolated store namespace', () => {
262
+ const GroupStore = Store.group('test', (g) => {
263
+ g.create('count', 0);
264
+ });
265
+ expect(GroupStore.has('count')).toBe(true);
266
+ expect(Store.has('count')).toBe(false);
267
+ });
268
+
269
+ it('accepts a callback without a name', () => {
270
+ const GroupStore = Store.group((g) => {
271
+ g.create('x', 1);
272
+ });
273
+ expect(GroupStore.has('x')).toBe(true);
274
+ });
275
+
276
+ it('two groups are independent', () => {
277
+ const A = Store.group((g) => g.create('count', 0));
278
+ const B = Store.group((g) => g.create('count', 99));
279
+ expect(A.get('count').val()).toBe(0);
280
+ expect(B.get('count').val()).toBe(99);
281
+ });
282
+ });
283
+
284
+ // -- protected() ----------------------------------------------------------
285
+
286
+ describe('protected()', () => {
287
+ it('returns a read-only proxy', () => {
288
+ Store.create('count', 0);
289
+ const ReadOnly = Store.protected();
290
+ expect(() => ReadOnly.create('x', 1)).toThrow();
291
+ expect(() => ReadOnly.use('count')).toThrow();
292
+ expect(() => ReadOnly.get('count')).toThrow();
293
+ });
294
+
295
+ it('follow() and has() are still accessible', () => {
296
+ Store.create('count', 0);
297
+ const ReadOnly = Store.protected();
298
+ expect(ReadOnly.has('count')).toBe(true);
299
+ const follower = ReadOnly.follow('count');
300
+ expect(follower.val()).toBe(0);
301
+ });
302
+
303
+ it('property access returns a read-only follower', () => {
304
+ Store.create('theme', 'light');
305
+ const ReadOnly = Store.protected();
306
+ const follower = ReadOnly.theme;
307
+ expect(follower.val()).toBe('light');
308
+ expect(() => follower.set('dark')).toThrow();
309
+ });
310
+ });
311
+
312
+ // -- createPersistent() ---------------------------------------------------
313
+
314
+ describe('createPersistent()', () => {
315
+ it('saves to localStorage on change', () => {
316
+ Store.createPersistent('theme', 'light');
317
+ Store.get('theme').set('dark');
318
+ expect(localStorage.getItem('theme')).toBeTruthy();
319
+ });
320
+
321
+ it('restores from localStorage on init', () => {
322
+ // $getFromStorage uses LocalStorage.get() for strings (no JSON.parse)
323
+ localStorage.setItem('lang', 'fr');
324
+ Store.createPersistent('lang', 'en');
325
+ expect(Store.get('lang').val()).toBe('fr');
326
+ localStorage.removeItem('lang');
327
+ });
328
+ });
329
+
330
+ // -- Proxy property access ------------------------------------------------
331
+
332
+ describe('Proxy property access', () => {
333
+ it('Store.storeName returns a follower', () => {
334
+ Store.create('count', 42);
335
+ const follower = Store.count;
336
+ expect(follower.val()).toBe(42);
337
+ });
338
+
339
+ it('returns same follower on repeated access (cache)', () => {
340
+ Store.create('count', 0);
341
+ const f1 = Store.count;
342
+ const f2 = Store.count;
343
+ expect(f1).toBe(f2);
344
+ });
345
+ });
346
+
347
+ });