native-document 1.0.168 → 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.
- package/CHANGELOG.md +99 -4
- package/components.js +3 -1
- package/dist/native-document.components.min.js +197 -171
- package/dist/native-document.dev.js +92 -75
- package/dist/native-document.dev.js.map +1 -1
- package/dist/native-document.min.js +1 -1
- package/docs/components/icons.md +321 -0
- package/docs/vitepress-conventions.md +2 -2
- package/package.json +11 -4
- package/src/components/BaseComponent.js +43 -1
- package/src/components/accordion/Accordion.js +1 -0
- package/src/components/accordion/AccordionItem.js +1 -0
- package/src/components/alert/Alert.js +4 -0
- package/src/components/avatar/Avatar.js +1 -0
- package/src/components/avatar/AvatarGroup.js +1 -0
- package/src/components/badge/Badge.js +1 -0
- package/src/components/breadcrumb/BreadCrumb.js +4 -0
- package/src/components/button/Button.js +1 -0
- package/src/components/card/Card.js +1 -0
- package/src/components/context-menu/ContextMenu.js +2 -0
- package/src/components/context-menu/ContextMenuGroup.js +2 -0
- package/src/components/context-menu/ContextMenuItem.js +2 -0
- package/src/components/divider/Divider.js +2 -0
- package/src/components/dropdown/Dropdown.js +1 -0
- package/src/components/dropdown/DropdownDivider.js +1 -0
- package/src/components/dropdown/DropdownGroup.js +1 -0
- package/src/components/dropdown/DropdownItem.js +1 -0
- package/src/components/dropdown/DropdownTrigger.js +1 -0
- package/src/components/form/FormControl.js +1 -0
- package/src/components/form/field/Field.js +1 -0
- package/src/components/form/field/types/FileField.js +3 -3
- package/src/components/form/field/types/StringField.js +1 -1
- package/src/components/icon/Icon.js +108 -0
- package/src/components/icon/icon-getters.js +142 -0
- package/src/components/icon/icons.js +171 -0
- package/src/components/icon/index.js +17 -0
- package/src/components/icon/types/Icon.d.ts +191 -0
- package/src/components/index.d.ts +6 -1
- package/src/components/list/ListItem.js +1 -0
- package/src/components/list/types/List.d.ts +45 -32
- package/src/components/list/types/ListDivider.ts +16 -0
- package/src/components/list/types/ListGroup.d.ts +51 -29
- package/src/components/list/types/ListItem.d.ts +20 -21
- package/src/components/menu/Menu.js +3 -0
- package/src/components/menu/MenuDivider.js +1 -0
- package/src/components/menu/MenuGroup.js +1 -0
- package/src/components/menu/MenuItem.js +1 -0
- package/src/components/menu/MenuLink.js +1 -0
- package/src/components/modal/Modal.js +4 -0
- package/src/components/pagination/Pagination.js +1 -0
- package/src/components/popover/Popover.js +1 -0
- package/src/components/popover/PopoverFooter.js +1 -0
- package/src/components/popover/PopoverHeader.js +1 -0
- package/src/components/progress/Progress.js +5 -0
- package/src/components/skeleton/Skeleton.js +4 -0
- package/src/components/slider/Slider.js +1 -0
- package/src/components/spacer/Spacer.js +1 -0
- package/src/components/spinner/Spinner.js +1 -0
- package/src/components/splitter/Splitter.js +1 -0
- package/src/components/splitter/SplitterGutter.js +6 -0
- package/src/components/splitter/SplitterPanel.js +1 -0
- package/src/components/stacks/AbsoluteStack.js +1 -0
- package/src/components/stacks/FixedStack.js +1 -0
- package/src/components/stacks/HStack.js +1 -0
- package/src/components/stacks/PositionStack.js +1 -0
- package/src/components/stacks/RelativeStack.js +1 -0
- package/src/components/stacks/Stack.js +1 -0
- package/src/components/stacks/VStack.js +1 -0
- package/src/components/stepper/Stepper.js +1 -0
- package/src/components/stepper/StepperStep.js +1 -0
- package/src/components/switch/Switch.js +1 -0
- package/src/components/table/DataTable.js +1 -0
- package/src/components/table/SimpleTable.js +1 -0
- package/src/components/tabs/Tabs.js +1 -0
- package/src/components/toast/Toast.js +1 -0
- package/src/components/tooltip/Tooltip.js +1 -0
- package/src/core/data/MemoryManager.js +6 -1
- package/src/core/data/ObservableItem.js +28 -10
- package/src/core/elements/anchor/anchor.js +4 -3
- package/src/core/elements/anchor/one-child-anchor-overwriting.js +4 -4
- package/src/core/elements/content-formatter.js +35 -2
- package/src/core/elements/control/for-each-array-cache.js +59 -0
- package/src/core/elements/control/for-each-array.js +15 -23
- package/src/core/elements/form.js +1 -1
- package/src/core/elements/img.js +1 -1
- package/src/core/elements/medias.js +2 -2
- package/src/core/elements/meta-data.js +1 -1
- package/src/core/wrappers/AttributesWrapper.js +44 -29
- package/src/core/wrappers/ElementCreator.js +38 -22
- package/src/core/wrappers/HtmlElementWrapper.js +33 -9
- package/src/core/wrappers/NDElement.js +35 -9
- package/src/core/wrappers/NdPrototype.js +31 -39
- package/src/core/wrappers/constants.js +12 -12
- package/src/core/wrappers/prototypes/attributes-extensions.js +24 -24
- package/src/core/wrappers/prototypes/nd-element-extensions.js +128 -65
- package/src/ui/components/accordion/AccordionItemRender.js +29 -7
- package/src/ui/components/alert/AlertRender.js +6 -0
- package/src/ui/components/breadcrumb/BreadcrumbRender.js +9 -1
- package/src/ui/components/button/ButtonRender.js +3 -0
- package/src/ui/components/contextmenu/ContextmenuRender.js +2 -0
- package/src/ui/components/dropdown/DropdownRender.js +3 -0
- package/src/ui/components/dropdown/item/DropdownItemRender.js +6 -1
- package/src/ui/components/form/FieldCollectionRender.js +4 -0
- package/src/ui/components/form/fields/CheckboxFieldRender.js +4 -0
- package/src/ui/components/form/fields/CheckboxGroupFieldRender.js +4 -0
- package/src/ui/components/form/fields/RadioFieldRender.js +4 -0
- package/src/ui/components/form/fields/RangeFieldRender.js +4 -0
- package/src/ui/components/form/fields/SelectFieldRender.js +5 -0
- package/src/ui/components/form/fields/SliderFieldRender.js +3 -0
- package/src/ui/components/icon/material/MaterialIconRender.js +71 -0
- package/src/ui/components/icon/material/material.css +15 -0
- package/src/ui/components/icon/material/material.map.js +170 -0
- package/src/ui/components/icon/phosphor/PhosphorIconRender.js +71 -0
- package/src/ui/components/icon/phosphor/phosphor.css +17 -0
- package/src/ui/components/icon/phosphor/phosphor.map.js +165 -0
- package/src/ui/components/icon/tabler/TablerIconRender.js +58 -0
- package/src/ui/components/icon/tabler/tabler.css +14 -0
- package/src/ui/components/icon/tabler/tabler.map.js +165 -0
- package/src/ui/components/list/item/ListItemRender.js +4 -0
- package/src/ui/components/menu/MenuItemRender.js +8 -0
- package/src/ui/components/menu/MenuLinkRender.js +11 -0
- package/src/ui/components/menu/MenuRender.js +5 -0
- package/src/ui/components/menu/helpers.js +2 -1
- package/src/ui/components/modal/ModalRender.js +8 -2
- package/src/ui/components/progress/ProgressRender.js +9 -0
- package/src/ui/components/skeleton/SkeletonRender.js +2 -0
- package/src/ui/components/spinner/SpinnerRender.js +3 -0
- package/src/ui/components/splitter/SplitterGutterRender.js +4 -0
- package/src/ui/components/splitter/SplitterPanelRender.js +4 -0
- package/src/ui/components/splitter/SplitterRender.js +2 -0
- package/src/ui/components/stepper/StepperRender.js +2 -1
- package/src/ui/components/stepper/StepperStepRender.js +8 -1
- package/src/ui/components/switch/SwitchRender.js +3 -0
- package/src/ui/components/table/data-table/DataTableRender.js +4 -0
- package/src/ui/components/table/simple-table/SimpleTableRender.js +2 -0
- package/src/ui/components/toast/ToastRender.js +4 -0
- package/src/ui/index.js +5 -0
- package/src/ui/utils/aria.js +19 -0
- package/tests/integration/ui/attributes-wrapper.test.js +177 -0
- package/tests/integration/ui/for-each-array.test.js +167 -0
- package/tests/integration/ui/lifecycle.test.js +146 -0
- package/tests/integration/ui/show-if.test.js +168 -0
- package/tests/unit/core/observable-array.test.js +404 -0
- package/tests/unit/core/observable-item.test.js +428 -0
- package/tests/unit/core/observable-object.test.js +239 -0
- package/tests/unit/core/observable-resource.test.js +323 -0
- package/tests/unit/core/store.test.js +347 -0
- package/tests/unit/router/route-matching.test.js +169 -0
- package/tests/unit/router/router-guards.test.js +189 -0
- package/tests/unit/utils/cache.test.js +120 -0
- package/tests/unit/utils/filters.test.js +394 -0
- package/types/elements.d.ts +5 -0
- package/vitest.config.js +15 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import ObservableItem from '../../../src/core/data/ObservableItem';
|
|
3
|
+
import { Observable } from '../../../src/core/data/Observable';
|
|
4
|
+
|
|
5
|
+
describe('ObservableItem', () => {
|
|
6
|
+
|
|
7
|
+
// -- constructor -----------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
describe('constructor', () => {
|
|
10
|
+
it('initializes with the given value', () => {
|
|
11
|
+
const obs = new ObservableItem(42);
|
|
12
|
+
expect(obs.val()).toBe(42);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('initializes with null', () => {
|
|
16
|
+
const obs = new ObservableItem(null);
|
|
17
|
+
expect(obs.val()).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('unwraps an observable passed as initial value', () => {
|
|
21
|
+
const inner = new ObservableItem(10);
|
|
22
|
+
const obs = new ObservableItem(inner);
|
|
23
|
+
expect(obs.val()).toBe(10);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('stores initial value for reset when { reset: true }', ()=> {
|
|
27
|
+
const obs = new ObservableItem(5, { reset: true });
|
|
28
|
+
obs.set(99);
|
|
29
|
+
obs.reset();
|
|
30
|
+
expect(obs.val()).toBe(5);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('does not store initial value without { reset: true }', () => {
|
|
34
|
+
const obs = new ObservableItem(5);
|
|
35
|
+
obs.set(99);
|
|
36
|
+
obs.reset();
|
|
37
|
+
expect(obs.val()).toBe(99); // reset() is a no-op
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// -- val() -----------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
describe('val()', () => {
|
|
44
|
+
it('returns the current value', () => {
|
|
45
|
+
const obs = new ObservableItem('hello');
|
|
46
|
+
expect(obs.val()).toBe('hello');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns the updated value after set()', () => {
|
|
50
|
+
const obs = new ObservableItem(0);
|
|
51
|
+
obs.set(7);
|
|
52
|
+
expect(obs.val()).toBe(7);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// -- set() -----------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe('set()', () => {
|
|
59
|
+
it('updates the current value', () => {
|
|
60
|
+
const obs = new ObservableItem(1);
|
|
61
|
+
obs.set(2);
|
|
62
|
+
expect(obs.val()).toBe(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not notify if value is unchanged', () => {
|
|
66
|
+
const obs = new ObservableItem('same');
|
|
67
|
+
const cb = vi.fn();
|
|
68
|
+
obs.subscribe(cb);
|
|
69
|
+
obs.set('same');
|
|
70
|
+
expect(cb).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('notifies subscribers when value changes', () => {
|
|
74
|
+
const obs = new ObservableItem(0);
|
|
75
|
+
let received = null;
|
|
76
|
+
obs.subscribe((current, previous, ops) => { received = { current, previous, ops }; });
|
|
77
|
+
obs.set(1);
|
|
78
|
+
expect(received.current).toBe(1);
|
|
79
|
+
expect(received.previous).toBe(0);
|
|
80
|
+
expect(received.ops).toMatchObject({ action: 'set' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('accepts a function and passes current value', () => {
|
|
84
|
+
const obs = new ObservableItem(5);
|
|
85
|
+
obs.set((current) => current * 2);
|
|
86
|
+
expect(obs.val()).toBe(10);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('unwraps an observable value passed to set()', () => {
|
|
90
|
+
const obs = new ObservableItem(0);
|
|
91
|
+
const other = new ObservableItem(99);
|
|
92
|
+
obs.set(other);
|
|
93
|
+
expect(obs.val()).toBe(99);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// -- subscribe() / unsubscribe() -------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe('subscribe() / unsubscribe()', () => {
|
|
100
|
+
it('subscribe() registers a callback called on change', () => {
|
|
101
|
+
const obs = new ObservableItem(0);
|
|
102
|
+
const cb = vi.fn();
|
|
103
|
+
obs.subscribe(cb);
|
|
104
|
+
obs.set(1);
|
|
105
|
+
expect(cb).toHaveBeenCalledOnce();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('subscribe() receives current and previous value', () => {
|
|
109
|
+
const obs = new ObservableItem(10);
|
|
110
|
+
let received = null;
|
|
111
|
+
obs.subscribe((current, previous, ops) => { received = { current, previous }; });
|
|
112
|
+
obs.set(20);
|
|
113
|
+
expect(received.current).toBe(20);
|
|
114
|
+
expect(received.previous).toBe(10);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('multiple subscribers are all notified', () => {
|
|
118
|
+
const obs = new ObservableItem(0);
|
|
119
|
+
const cb1 = vi.fn();
|
|
120
|
+
const cb2 = vi.fn();
|
|
121
|
+
const cb3 = vi.fn();
|
|
122
|
+
obs.subscribe(cb1);
|
|
123
|
+
obs.subscribe(cb2);
|
|
124
|
+
obs.subscribe(cb3);
|
|
125
|
+
obs.set(1);
|
|
126
|
+
expect(cb1).toHaveBeenCalledOnce();
|
|
127
|
+
expect(cb2).toHaveBeenCalledOnce();
|
|
128
|
+
expect(cb3).toHaveBeenCalledOnce();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('unsubscribe() stops receiving updates', () => {
|
|
132
|
+
const obs = new ObservableItem(0);
|
|
133
|
+
const cb = vi.fn();
|
|
134
|
+
obs.subscribe(cb);
|
|
135
|
+
obs.unsubscribe(cb);
|
|
136
|
+
obs.set(1);
|
|
137
|
+
expect(cb).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('unsubscribe() with unknown callback does nothing', () => {
|
|
141
|
+
const obs = new ObservableItem(0);
|
|
142
|
+
expect(() => obs.unsubscribe(() => {})).not.toThrow();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// -- on() / off() ----------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
describe('on() / off()', () => {
|
|
149
|
+
it('on() fires callback when value matches', () => {
|
|
150
|
+
const obs = new ObservableItem('idle');
|
|
151
|
+
const cb = vi.fn();
|
|
152
|
+
obs.on('loading', cb);
|
|
153
|
+
obs.set('loading');
|
|
154
|
+
expect(cb).toHaveBeenCalledOnce();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('on() does not fire for other values', () => {
|
|
158
|
+
const obs = new ObservableItem('idle');
|
|
159
|
+
const cb = vi.fn();
|
|
160
|
+
obs.on('loading', cb);
|
|
161
|
+
obs.set('error');
|
|
162
|
+
expect(cb).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('on() supports multiple callbacks for same value', () => {
|
|
166
|
+
const obs = new ObservableItem('idle');
|
|
167
|
+
const cb1 = vi.fn();
|
|
168
|
+
const cb2 = vi.fn();
|
|
169
|
+
obs.on('loading', cb1);
|
|
170
|
+
obs.on('loading', cb2);
|
|
171
|
+
obs.set('loading');
|
|
172
|
+
expect(cb1).toHaveBeenCalledOnce();
|
|
173
|
+
expect(cb2).toHaveBeenCalledOnce();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('off() removes all handlers for a value', () => {
|
|
177
|
+
const obs = new ObservableItem('idle');
|
|
178
|
+
const cb = vi.fn();
|
|
179
|
+
obs.on('loading', cb);
|
|
180
|
+
obs.off('loading');
|
|
181
|
+
obs.set('loading');
|
|
182
|
+
expect(cb).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// -- once() ----------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
describe('once()', () => {
|
|
189
|
+
it('fires callback exactly once when value matches', () => {
|
|
190
|
+
const obs = new ObservableItem('idle');
|
|
191
|
+
const cb = vi.fn();
|
|
192
|
+
obs.once('ready', cb);
|
|
193
|
+
obs.set('ready');
|
|
194
|
+
obs.set('idle');
|
|
195
|
+
obs.set('ready');
|
|
196
|
+
expect(cb).toHaveBeenCalledOnce();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('fires callback when function predicate returns true', () => {
|
|
200
|
+
const obs = new ObservableItem(0);
|
|
201
|
+
const cb = vi.fn();
|
|
202
|
+
obs.once((v) => v > 5, cb);
|
|
203
|
+
obs.set(3);
|
|
204
|
+
obs.set(10);
|
|
205
|
+
obs.set(20);
|
|
206
|
+
expect(cb).toHaveBeenCalledOnce();
|
|
207
|
+
expect(cb).toHaveBeenCalledWith(10);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// -- toggle() --------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
describe('toggle()', () => {
|
|
214
|
+
it('flips false to true', () => {
|
|
215
|
+
const obs = new ObservableItem(false);
|
|
216
|
+
obs.toggle();
|
|
217
|
+
expect(obs.val()).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('flips true to false', () => {
|
|
221
|
+
const obs = new ObservableItem(true);
|
|
222
|
+
obs.toggle();
|
|
223
|
+
expect(obs.val()).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('notifies subscribers', () => {
|
|
227
|
+
const obs = new ObservableItem(false);
|
|
228
|
+
const cb = vi.fn();
|
|
229
|
+
obs.subscribe(cb);
|
|
230
|
+
obs.toggle();
|
|
231
|
+
expect(cb).toHaveBeenCalledOnce();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// -- reset() ---------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
describe('reset()', () => {
|
|
238
|
+
it('restores initial value when { reset: true }', () => {
|
|
239
|
+
const obs = new ObservableItem(0, { reset: true });
|
|
240
|
+
obs.set(42);
|
|
241
|
+
obs.reset();
|
|
242
|
+
expect(obs.val()).toBe(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('does nothing without { reset: true }', () => {
|
|
246
|
+
const obs = new ObservableItem(0);
|
|
247
|
+
obs.set(42);
|
|
248
|
+
obs.reset();
|
|
249
|
+
expect(obs.val()).toBe(42);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('notifies subscribers after reset', () => {
|
|
253
|
+
const obs = new ObservableItem(0, { reset: true });
|
|
254
|
+
obs.set(99);
|
|
255
|
+
const cb = vi.fn();
|
|
256
|
+
obs.subscribe(cb);
|
|
257
|
+
obs.reset();
|
|
258
|
+
expect(cb).toHaveBeenCalledOnce();
|
|
259
|
+
// With a single subscriber, callback receives (current, previous, ops)
|
|
260
|
+
// only if callback.length > 0 (assocTrigger optimization)
|
|
261
|
+
expect(obs.val()).toBe(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('deep clones object initial value on reset', () => {
|
|
265
|
+
const initial = { x: 1 };
|
|
266
|
+
const obs = new ObservableItem(initial, { reset: true });
|
|
267
|
+
obs.set({ x: 99 });
|
|
268
|
+
obs.reset();
|
|
269
|
+
expect(obs.val()).toEqual({ x: 1 });
|
|
270
|
+
expect(obs.val()).not.toBe(initial); // deep clone, not same ref
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// -- transform() -----------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
describe('transform()', () => {
|
|
277
|
+
it('returns a derived observable', () => {
|
|
278
|
+
const obs = new ObservableItem(2);
|
|
279
|
+
const doubled = obs.transform((v) => v * 2);
|
|
280
|
+
expect(doubled.val()).toBe(4);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('derived observable updates when source changes', () => {
|
|
284
|
+
const obs = new ObservableItem(2);
|
|
285
|
+
const doubled = obs.transform((v) => v * 2);
|
|
286
|
+
obs.set(5);
|
|
287
|
+
expect(doubled.val()).toBe(10);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('notifies derived subscribers on source change', () => {
|
|
291
|
+
const obs = new ObservableItem(1);
|
|
292
|
+
const doubled = obs.transform((v) => v * 2);
|
|
293
|
+
const cb = vi.fn();
|
|
294
|
+
doubled.subscribe(cb);
|
|
295
|
+
obs.set(3);
|
|
296
|
+
expect(doubled.val()).toBe(6); // derived value updated
|
|
297
|
+
expect(cb).toHaveBeenCalledOnce();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// -- equals() --------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
describe('equals()', () => {
|
|
304
|
+
it('returns true when values are equal', () => {
|
|
305
|
+
const obs = new ObservableItem(5);
|
|
306
|
+
expect(obs.equals(5)).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('returns false when values differ', () => {
|
|
310
|
+
const obs = new ObservableItem(5);
|
|
311
|
+
expect(obs.equals(10)).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('compares with another observable', () => {
|
|
315
|
+
const a = new ObservableItem(5);
|
|
316
|
+
const b = new ObservableItem(5);
|
|
317
|
+
const c = new ObservableItem(9);
|
|
318
|
+
expect(a.equals(b)).toBe(true);
|
|
319
|
+
expect(a.equals(c)).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// -- clone() ---------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
describe('clone()', () => {
|
|
326
|
+
it('returns a new independent observable', () => {
|
|
327
|
+
const obs = new ObservableItem(42);
|
|
328
|
+
const clone = obs.clone();
|
|
329
|
+
expect(clone.val()).toBe(42);
|
|
330
|
+
expect(clone).not.toBe(obs);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('mutations on clone do not affect source', () => {
|
|
334
|
+
const obs = new ObservableItem(1);
|
|
335
|
+
const clone = obs.clone();
|
|
336
|
+
clone.set(99);
|
|
337
|
+
expect(obs.val()).toBe(1);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('deep clones object values', () => {
|
|
341
|
+
const obs = new ObservableItem({ x: 1 });
|
|
342
|
+
const clone = obs.clone();
|
|
343
|
+
expect(clone.val()).toEqual({ x: 1 });
|
|
344
|
+
expect(clone.val()).not.toBe(obs.val());
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// -- cleanup() -------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
describe('cleanup()', () => {
|
|
351
|
+
it('runs onCleanup callbacks', () => {
|
|
352
|
+
const obs = new ObservableItem(0);
|
|
353
|
+
const cb = vi.fn();
|
|
354
|
+
obs.onCleanup(cb);
|
|
355
|
+
obs.cleanup();
|
|
356
|
+
expect(cb).toHaveBeenCalledOnce();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('disconnects all listeners after cleanup', () => {
|
|
360
|
+
const obs = new ObservableItem(0);
|
|
361
|
+
const cb = vi.fn();
|
|
362
|
+
obs.subscribe(cb);
|
|
363
|
+
obs.cleanup();
|
|
364
|
+
expect(obs.$listeners).toBeNull();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// -- intercept() -----------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
describe('intercept()', () => {
|
|
371
|
+
it('transforms value via interceptor before set()', () => {
|
|
372
|
+
const obs = new ObservableItem(0);
|
|
373
|
+
obs.intercept((newVal) => Math.max(0, newVal));
|
|
374
|
+
obs.set(-5);
|
|
375
|
+
expect(obs.val()).toBe(0);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('uses interceptor return value', () => {
|
|
379
|
+
const obs = new ObservableItem(0);
|
|
380
|
+
obs.intercept(() => 42);
|
|
381
|
+
obs.set(1);
|
|
382
|
+
expect(obs.val()).toBe(42);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('falls back to original value if interceptor returns undefined', () => {
|
|
386
|
+
const obs = new ObservableItem(0);
|
|
387
|
+
obs.intercept(() => undefined);
|
|
388
|
+
obs.set(7);
|
|
389
|
+
expect(obs.val()).toBe(7);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// -- assocTrigger() — internal optimisation --------------------------------
|
|
394
|
+
|
|
395
|
+
describe('assocTrigger() — internal optimisation', () => {
|
|
396
|
+
it('uses noneTrigger with no subscribers', () => {
|
|
397
|
+
const obs = new ObservableItem(0);
|
|
398
|
+
// trigger should be the noneTrigger (no-op) — no error on set
|
|
399
|
+
expect(() => obs.set(1)).not.toThrow();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('uses triggerFirstListener with a single subscriber', () => {
|
|
403
|
+
const obs = new ObservableItem(0);
|
|
404
|
+
const cb = vi.fn();
|
|
405
|
+
obs.subscribe(cb);
|
|
406
|
+
expect(obs.$firstListener).toBe(cb);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('uses triggerListeners with multiple subscribers', () => {
|
|
410
|
+
const obs = new ObservableItem(0);
|
|
411
|
+
obs.subscribe(vi.fn());
|
|
412
|
+
obs.subscribe(vi.fn());
|
|
413
|
+
// With 2+ listeners, $firstListener is set to first listener
|
|
414
|
+
// and trigger delegates to triggerListeners
|
|
415
|
+
expect(obs.$listeners.length).toBe(2);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('reverts to noneTrigger after unsubscribing all', () => {
|
|
419
|
+
const obs = new ObservableItem(0);
|
|
420
|
+
const cb = vi.fn();
|
|
421
|
+
obs.subscribe(cb);
|
|
422
|
+
obs.unsubscribe(cb);
|
|
423
|
+
expect(obs.$listeners.length).toBe(0);
|
|
424
|
+
expect(obs.$firstListener).toBeNull();
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { ObservableObject } from '../../../src/core/data/ObservableObject';
|
|
3
|
+
import ObservableItem from '../../../src/core/data/ObservableItem';
|
|
4
|
+
import ObservableArray from '../../../src/core/data/ObservableArray';
|
|
5
|
+
|
|
6
|
+
const obj = (target, configs) => new ObservableObject(target, configs);
|
|
7
|
+
|
|
8
|
+
describe('ObservableObject', () => {
|
|
9
|
+
|
|
10
|
+
// -- constructor ----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
describe('constructor', () => {
|
|
13
|
+
it('initializes with given properties', () => {
|
|
14
|
+
const obs = obj({ name: 'John', age: 25 });
|
|
15
|
+
expect(obs.get('name')).toBe('John');
|
|
16
|
+
expect(obs.get('age')).toBe(25);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('wraps scalar values in ObservableItem', () => {
|
|
20
|
+
const obs = obj({ name: 'John' });
|
|
21
|
+
expect(obs.$observables['name'].__$Observable).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('wraps nested objects in ObservableObject', () => {
|
|
25
|
+
const obs = obj({ address: { city: 'Paris' } });
|
|
26
|
+
expect(obs.$observables['address'].__$isObservableObject).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('wraps array values in ObservableArray', () => {
|
|
30
|
+
const obs = obj({ tags: ['a', 'b'] });
|
|
31
|
+
expect(obs.$observables['tags'].__$isObservableArray).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not wrap nested array items when deep=false', () => {
|
|
35
|
+
const obs = obj({ tags: [{ name: 'a' }] }, { deep: false });
|
|
36
|
+
// With deep=false, array items are not wrapped in ObservableObject
|
|
37
|
+
expect(obs.$observables['tags'].__$isObservableArray).toBe(true);
|
|
38
|
+
expect(obs.$observables['tags'].at(0).__$isObservableObject).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// -- val() ----------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
describe('val()', () => {
|
|
45
|
+
it('returns a plain object snapshot', () => {
|
|
46
|
+
const obs = obj({ name: 'John', age: 25 });
|
|
47
|
+
expect(obs.val()).toEqual({ name: 'John', age: 25 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('unwraps nested observables', () => {
|
|
51
|
+
const obs = obj({ address: { city: 'Paris' } });
|
|
52
|
+
expect(obs.val()).toEqual({ address: { city: 'Paris' } });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('unwraps array values', () => {
|
|
56
|
+
const obs = obj({ tags: ['a', 'b'] });
|
|
57
|
+
expect(obs.val()).toEqual({ tags: ['a', 'b'] });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// -- get() ----------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
describe('get()', () => {
|
|
64
|
+
it('returns value for a property key', () => {
|
|
65
|
+
const obs = obj({ name: 'John' });
|
|
66
|
+
expect(obs.get('name')).toBe('John');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('$get() is an alias for get()', () => {
|
|
70
|
+
const obs = obj({ name: 'John' });
|
|
71
|
+
expect(obs.$get('name')).toBe('John');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// -- set() ----------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
describe('set()', () => {
|
|
78
|
+
it('updates a single property', () => {
|
|
79
|
+
const obs = obj({ name: 'John', age: 25 });
|
|
80
|
+
obs.set({ name: 'Jane' });
|
|
81
|
+
expect(obs.get('name')).toBe('Jane');
|
|
82
|
+
expect(obs.get('age')).toBe(25);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('supports partial updates', () => {
|
|
86
|
+
const obs = obj({ a: 1, b: 2, c: 3 });
|
|
87
|
+
obs.set({ b: 99 });
|
|
88
|
+
expect(obs.val()).toEqual({ a: 1, b: 99, c: 3 });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('updates nested object properties', () => {
|
|
92
|
+
const obs = obj({ address: { city: 'Paris', zip: '75000' } });
|
|
93
|
+
obs.set({ address: { city: 'Lyon' } });
|
|
94
|
+
expect(obs.get('address').city).toBe('Lyon');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('$set() is an alias for set()', () => {
|
|
98
|
+
const obs = obj({ name: 'John' });
|
|
99
|
+
obs.$set({ name: 'Jane' });
|
|
100
|
+
expect(obs.get('name')).toBe('Jane');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('update() is an alias for set()', () => {
|
|
104
|
+
const obs = obj({ name: 'John' });
|
|
105
|
+
obs.update({ name: 'Jane' });
|
|
106
|
+
expect(obs.get('name')).toBe('Jane');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// -- direct property assignment -------------------------------------------
|
|
111
|
+
|
|
112
|
+
describe('direct property assignment', () => {
|
|
113
|
+
it('obs.name = value updates via setter', () => {
|
|
114
|
+
const obs = obj({ name: 'John' });
|
|
115
|
+
obs.name = 'Jane';
|
|
116
|
+
expect(obs.get('name')).toBe('Jane');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('obs.name returns the ObservableItem', () => {
|
|
120
|
+
const obs = obj({ name: 'John' });
|
|
121
|
+
expect(obs.name.__$Observable).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// -- subscribe() ----------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
describe('subscribe()', () => {
|
|
128
|
+
it('fires when any property changes', async () => {
|
|
129
|
+
const obs = obj({ name: 'John', age: 25 });
|
|
130
|
+
const cb = vi.fn();
|
|
131
|
+
obs.subscribe(cb);
|
|
132
|
+
obs.set({ name: 'Jane' });
|
|
133
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
134
|
+
expect(cb).toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('fires when nested observable changes', async () => {
|
|
138
|
+
const obs = obj({ name: 'John' });
|
|
139
|
+
const cb = vi.fn();
|
|
140
|
+
obs.subscribe(cb);
|
|
141
|
+
obs.name.set('Jane');
|
|
142
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
143
|
+
expect(cb).toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('batches multiple simultaneous changes into one notification', async () => {
|
|
147
|
+
const obs = obj({ a: 1, b: 2 });
|
|
148
|
+
const cb = vi.fn();
|
|
149
|
+
obs.subscribe(cb);
|
|
150
|
+
obs.set({ a: 10, b: 20 });
|
|
151
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
152
|
+
expect(cb).toHaveBeenCalledOnce();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// -- keys() ---------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe('keys()', () => {
|
|
159
|
+
it('returns all property names', () => {
|
|
160
|
+
const obs = obj({ name: 'John', age: 25 });
|
|
161
|
+
expect(obs.keys()).toEqual(['name', 'age']);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('$keys() is an alias for keys()', () => {
|
|
165
|
+
const obs = obj({ name: 'John' });
|
|
166
|
+
expect(obs.$keys()).toEqual(['name']);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// -- observables() --------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe('observables()', () => {
|
|
173
|
+
it('returns all internal observable instances', () => {
|
|
174
|
+
const obs = obj({ name: 'John', age: 25 });
|
|
175
|
+
const list = obs.observables();
|
|
176
|
+
expect(list).toHaveLength(2);
|
|
177
|
+
expect(list.every((o) => o.__$Observable)).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// -- reset() --------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
describe('reset()', () => {
|
|
184
|
+
it('restores all scalar properties to initial values', () => {
|
|
185
|
+
const obs = obj({ name: 'John', age: 25 }, { reset: true });
|
|
186
|
+
obs.set({ name: 'Jane', age: 99 });
|
|
187
|
+
obs.reset();
|
|
188
|
+
expect(obs.get('name')).toBe('John');
|
|
189
|
+
expect(obs.get('age')).toBe(25);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('restores nested object properties to initial values', () => {
|
|
193
|
+
const obs = obj({ address: { city: 'Paris' } }, { reset: true });
|
|
194
|
+
obs.set({ address: { city: 'Lyon' } });
|
|
195
|
+
obs.reset();
|
|
196
|
+
expect(obs.get('address').city).toBe('Paris');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('restores array properties to initial values', () => {
|
|
200
|
+
const obs = obj({ tags: ['a', 'b'] }, { reset: true });
|
|
201
|
+
const tagsObs = obs.$observables['tags'];
|
|
202
|
+
tagsObs.push('c');
|
|
203
|
+
expect(tagsObs.length).toBe(3);
|
|
204
|
+
obs.reset();
|
|
205
|
+
expect(tagsObs.length).toBe(2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('does nothing without { reset: true }', () => {
|
|
209
|
+
const obs = obj({ name: 'John' });
|
|
210
|
+
obs.set({ name: 'Jane' });
|
|
211
|
+
obs.reset();
|
|
212
|
+
expect(obs.get('name')).toBe('Jane');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// -- clone() --------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
describe('clone()', () => {
|
|
219
|
+
it('returns a new independent ObservableObject', () => {
|
|
220
|
+
const obs = obj({ name: 'John' });
|
|
221
|
+
const clone = obs.clone();
|
|
222
|
+
expect(clone.val()).toEqual({ name: 'John' });
|
|
223
|
+
expect(clone).not.toBe(obs);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('mutations on clone do not affect source', () => {
|
|
227
|
+
const obs = obj({ name: 'John' });
|
|
228
|
+
const clone = obs.clone();
|
|
229
|
+
clone.set({ name: 'Jane' });
|
|
230
|
+
expect(obs.get('name')).toBe('John');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('$clone() is an alias for clone()', () => {
|
|
234
|
+
const obs = obj({ name: 'John' });
|
|
235
|
+
expect(obs.$clone()).not.toBe(obs);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
});
|