@brika/ui-kit 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/package.json +32 -0
- package/src/__tests__/define-brick.test.ts +125 -0
- package/src/__tests__/mutations.test.ts +211 -0
- package/src/__tests__/nodes.test.ts +1595 -0
- package/src/colors.ts +99 -0
- package/src/define-brick.ts +92 -0
- package/src/descriptors.ts +28 -0
- package/src/index.ts +154 -0
- package/src/jsx-dev-runtime.ts +3 -0
- package/src/jsx-runtime.ts +60 -0
- package/src/mutations.ts +79 -0
- package/src/nodes/_shared.ts +129 -0
- package/src/nodes/avatar.ts +36 -0
- package/src/nodes/badge.ts +29 -0
- package/src/nodes/box.ts +69 -0
- package/src/nodes/button.ts +44 -0
- package/src/nodes/callout.ts +23 -0
- package/src/nodes/chart.ts +43 -0
- package/src/nodes/checkbox.ts +27 -0
- package/src/nodes/code-block.ts +27 -0
- package/src/nodes/column.ts +37 -0
- package/src/nodes/divider.ts +25 -0
- package/src/nodes/grid.ts +44 -0
- package/src/nodes/icon.ts +28 -0
- package/src/nodes/image.ts +29 -0
- package/src/nodes/index.ts +54 -0
- package/src/nodes/key-value.ts +31 -0
- package/src/nodes/link.ts +25 -0
- package/src/nodes/markdown.ts +16 -0
- package/src/nodes/progress.ts +28 -0
- package/src/nodes/row.ts +37 -0
- package/src/nodes/section.ts +42 -0
- package/src/nodes/select.ts +44 -0
- package/src/nodes/skeleton.ts +23 -0
- package/src/nodes/slider.ts +40 -0
- package/src/nodes/spacer.ts +17 -0
- package/src/nodes/stat-value.ts +26 -0
- package/src/nodes/status.ts +20 -0
- package/src/nodes/table.ts +35 -0
- package/src/nodes/tabs.ts +52 -0
- package/src/nodes/text-input.ts +53 -0
- package/src/nodes/text.ts +66 -0
- package/src/nodes/toggle.ts +32 -0
- package/src/nodes/video.ts +24 -0
|
@@ -0,0 +1,1595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for all node builder functions and _shared utilities.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - _shared.ts: normalizeChildren, resolveAction, _setActionRegistrar
|
|
6
|
+
* - Simple spread factories: Badge, Chart, Divider, Icon, Image, Progress, Spacer, Stat, Status, Text, Video
|
|
7
|
+
* - Container factories: Box, Grid, Section, Row, Column
|
|
8
|
+
* - Action-resolving factories: Button, Slider, Toggle, Checkbox, Tabs, Select, TextInput
|
|
9
|
+
* - New components: Avatar, CodeBlock, KeyValue, Link, Skeleton, Table
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
13
|
+
import type { I18nRef, IntlRef } from '../nodes';
|
|
14
|
+
import {
|
|
15
|
+
_setActionRegistrar,
|
|
16
|
+
Avatar,
|
|
17
|
+
Badge,
|
|
18
|
+
Box,
|
|
19
|
+
Button,
|
|
20
|
+
Callout,
|
|
21
|
+
Chart,
|
|
22
|
+
Checkbox,
|
|
23
|
+
CodeBlock,
|
|
24
|
+
Column,
|
|
25
|
+
Divider,
|
|
26
|
+
Grid,
|
|
27
|
+
Icon,
|
|
28
|
+
Image,
|
|
29
|
+
i18nRef,
|
|
30
|
+
intlRef,
|
|
31
|
+
isI18nRef,
|
|
32
|
+
isIntlRef,
|
|
33
|
+
KeyValue,
|
|
34
|
+
Link,
|
|
35
|
+
normalizeChildren,
|
|
36
|
+
Progress,
|
|
37
|
+
Row,
|
|
38
|
+
resolveAction,
|
|
39
|
+
Section,
|
|
40
|
+
Select,
|
|
41
|
+
Skeleton,
|
|
42
|
+
Slider,
|
|
43
|
+
Spacer,
|
|
44
|
+
Stat,
|
|
45
|
+
Status,
|
|
46
|
+
Table,
|
|
47
|
+
Tabs,
|
|
48
|
+
Text,
|
|
49
|
+
TextInput,
|
|
50
|
+
Toggle,
|
|
51
|
+
Video,
|
|
52
|
+
} from '../nodes';
|
|
53
|
+
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// _shared utilities
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe('_shared', () => {
|
|
59
|
+
describe('normalizeChildren', () => {
|
|
60
|
+
test('returns empty array for null', () => {
|
|
61
|
+
expect(normalizeChildren(null)).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('returns empty array for undefined', () => {
|
|
65
|
+
expect(normalizeChildren(undefined)).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('returns empty array for false', () => {
|
|
69
|
+
expect(normalizeChildren(false)).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('wraps a single ComponentNode in an array', () => {
|
|
73
|
+
const node = Text({ content: 'hello' });
|
|
74
|
+
const result = normalizeChildren(node);
|
|
75
|
+
expect(result).toEqual([node]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('returns flat array from array of nodes', () => {
|
|
79
|
+
const a = Text({ content: 'a' });
|
|
80
|
+
const b = Text({ content: 'b' });
|
|
81
|
+
expect(normalizeChildren([a, b])).toEqual([a, b]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('flattens nested arrays', () => {
|
|
85
|
+
const a = Text({ content: 'a' });
|
|
86
|
+
const b = Text({ content: 'b' });
|
|
87
|
+
expect(normalizeChildren([[a], [b]])).toEqual([a, b]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('filters out null, undefined, and false from arrays', () => {
|
|
91
|
+
const a = Text({ content: 'a' });
|
|
92
|
+
const result = normalizeChildren([a, null, undefined, false]);
|
|
93
|
+
expect(result).toEqual([a]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('handles empty array', () => {
|
|
97
|
+
expect(normalizeChildren([])).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('handles array of only falsy values', () => {
|
|
101
|
+
expect(normalizeChildren([null, false, undefined])).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('wraps I18nRef into TextNode with i18n field', () => {
|
|
105
|
+
const ref = i18nRef('plugin:weather', 'stats.humidity');
|
|
106
|
+
const result = normalizeChildren(ref);
|
|
107
|
+
expect(result).toEqual([
|
|
108
|
+
{
|
|
109
|
+
type: 'text',
|
|
110
|
+
content: 'stats.humidity',
|
|
111
|
+
i18n: { ns: 'plugin:weather', key: 'stats.humidity', params: undefined },
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('wraps I18nRef with params into TextNode', () => {
|
|
117
|
+
const ref: I18nRef = {
|
|
118
|
+
__i18n: true,
|
|
119
|
+
ns: 'plugin:weather',
|
|
120
|
+
key: 'ui.dayForecast',
|
|
121
|
+
params: { count: 7 },
|
|
122
|
+
};
|
|
123
|
+
const result = normalizeChildren(ref);
|
|
124
|
+
expect(result).toEqual([
|
|
125
|
+
{
|
|
126
|
+
type: 'text',
|
|
127
|
+
content: 'ui.dayForecast',
|
|
128
|
+
i18n: { ns: 'plugin:weather', key: 'ui.dayForecast', params: { count: 7 } },
|
|
129
|
+
},
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('handles mixed I18nRef and ComponentNode in array', () => {
|
|
134
|
+
const textNode = Text({ content: 'plain' });
|
|
135
|
+
const ref = i18nRef('plugin:x', 'hello');
|
|
136
|
+
const result = normalizeChildren([textNode, ref, null, false]);
|
|
137
|
+
expect(result).toHaveLength(2);
|
|
138
|
+
expect(result[0]).toBe(textNode);
|
|
139
|
+
expect(result[1]).toEqual({
|
|
140
|
+
type: 'text',
|
|
141
|
+
content: 'hello',
|
|
142
|
+
i18n: { ns: 'plugin:x', key: 'hello', params: undefined },
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('wraps IntlRef number into TextNode with intl field', () => {
|
|
147
|
+
const ref = intlRef.number(1234);
|
|
148
|
+
const result = normalizeChildren(ref);
|
|
149
|
+
expect(result).toEqual([
|
|
150
|
+
{
|
|
151
|
+
type: 'text',
|
|
152
|
+
content: '1234',
|
|
153
|
+
intl: ref,
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('wraps IntlRef dateTime into TextNode with timestamp fallback', () => {
|
|
159
|
+
const ref: IntlRef = {
|
|
160
|
+
__intl: true,
|
|
161
|
+
type: 'dateTime',
|
|
162
|
+
value: 0,
|
|
163
|
+
options: { dateStyle: 'medium' },
|
|
164
|
+
};
|
|
165
|
+
const result = normalizeChildren(ref);
|
|
166
|
+
expect(result).toEqual([
|
|
167
|
+
{
|
|
168
|
+
type: 'text',
|
|
169
|
+
content: '0',
|
|
170
|
+
intl: ref,
|
|
171
|
+
},
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('wraps IntlRef list into TextNode with joined fallback', () => {
|
|
176
|
+
const ref = intlRef.list(['a', 'b', 'c']);
|
|
177
|
+
const result = normalizeChildren(ref);
|
|
178
|
+
expect(result).toEqual([
|
|
179
|
+
{
|
|
180
|
+
type: 'text',
|
|
181
|
+
content: 'a, b, c',
|
|
182
|
+
intl: ref,
|
|
183
|
+
},
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('handles mixed IntlRef, I18nRef, and ComponentNode in array', () => {
|
|
188
|
+
const textNode = Text({ content: 'plain' });
|
|
189
|
+
const i18n = i18nRef('plugin:x', 'hello');
|
|
190
|
+
const intl = intlRef.number(42);
|
|
191
|
+
const result = normalizeChildren([textNode, i18n, intl, null]);
|
|
192
|
+
expect(result).toHaveLength(3);
|
|
193
|
+
expect(result[0]).toBe(textNode);
|
|
194
|
+
expect((result[1] as { i18n: unknown }).i18n).toBeDefined();
|
|
195
|
+
expect((result[2] as { intl: unknown }).intl).toBe(intl);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('isI18nRef', () => {
|
|
200
|
+
test('returns true for valid I18nRef', () => {
|
|
201
|
+
expect(isI18nRef({ __i18n: true, ns: 'plugin:x', key: 'k' })).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('returns true for I18nRef with params', () => {
|
|
205
|
+
expect(isI18nRef({ __i18n: true, ns: 'n', key: 'k', params: { a: 1 } })).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('returns false for null', () => {
|
|
209
|
+
expect(isI18nRef(null)).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('returns false for undefined', () => {
|
|
213
|
+
expect(isI18nRef(undefined)).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('returns false for string', () => {
|
|
217
|
+
expect(isI18nRef('hello')).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('returns false for number', () => {
|
|
221
|
+
expect(isI18nRef(42)).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('returns false for object without __i18n', () => {
|
|
225
|
+
expect(isI18nRef({ ns: 'x', key: 'k' })).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('returns false for object with __i18n = false', () => {
|
|
229
|
+
expect(isI18nRef({ __i18n: false, ns: 'x', key: 'k' })).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('isIntlRef', () => {
|
|
234
|
+
test('returns true for dateTime ref', () => {
|
|
235
|
+
expect(isIntlRef({ __intl: true, type: 'dateTime', value: 0 })).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('returns true for number ref', () => {
|
|
239
|
+
expect(isIntlRef({ __intl: true, type: 'number', value: 42 })).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('returns true for relativeTime ref', () => {
|
|
243
|
+
expect(isIntlRef({ __intl: true, type: 'relativeTime', value: -1, unit: 'day' })).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('returns true for list ref', () => {
|
|
247
|
+
expect(isIntlRef({ __intl: true, type: 'list', value: ['a', 'b'] })).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('returns false for null', () => {
|
|
251
|
+
expect(isIntlRef(null)).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('returns false for undefined', () => {
|
|
255
|
+
expect(isIntlRef(undefined)).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('returns false for string', () => {
|
|
259
|
+
expect(isIntlRef('hello')).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('returns false for I18nRef', () => {
|
|
263
|
+
expect(isIntlRef({ __i18n: true, ns: 'x', key: 'k' })).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('returns false for object without __intl', () => {
|
|
267
|
+
expect(isIntlRef({ type: 'number', value: 42 })).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('returns false for object with __intl = false', () => {
|
|
271
|
+
expect(isIntlRef({ __intl: false, type: 'number', value: 42 })).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('resolveAction', () => {
|
|
276
|
+
beforeEach(() => {
|
|
277
|
+
_setActionRegistrar(null);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('returns fallback action ID when no registrar is set', () => {
|
|
281
|
+
const handler = () => {};
|
|
282
|
+
const id = resolveAction(handler);
|
|
283
|
+
expect(id).toMatch(/^__action_\d+$/);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('increments fallback counter on subsequent calls', () => {
|
|
287
|
+
const id1 = resolveAction(() => {});
|
|
288
|
+
const id2 = resolveAction(() => {});
|
|
289
|
+
// Extract numbers and ensure id2 > id1
|
|
290
|
+
const n1 = Number(id1.replace('__action_', ''));
|
|
291
|
+
const n2 = Number(id2.replace('__action_', ''));
|
|
292
|
+
expect(n2).toBe(n1 + 1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('delegates to custom registrar when set', () => {
|
|
296
|
+
let capturedHandler: unknown = null;
|
|
297
|
+
_setActionRegistrar((handler) => {
|
|
298
|
+
capturedHandler = handler;
|
|
299
|
+
return 'custom-id-42';
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const myHandler = () => {};
|
|
303
|
+
const id = resolveAction(myHandler);
|
|
304
|
+
expect(id).toBe('custom-id-42');
|
|
305
|
+
expect(capturedHandler).toBe(myHandler);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('clearing the registrar restores fallback behavior', () => {
|
|
309
|
+
_setActionRegistrar(() => 'custom');
|
|
310
|
+
expect(resolveAction(() => {})).toBe('custom');
|
|
311
|
+
|
|
312
|
+
_setActionRegistrar(null);
|
|
313
|
+
const id = resolveAction(() => {});
|
|
314
|
+
expect(id).toMatch(/^__action_\d+$/);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
320
|
+
// Simple spread factories
|
|
321
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
describe('Text', () => {
|
|
324
|
+
test('creates text node with required content', () => {
|
|
325
|
+
const node = Text({ content: 'Hello' });
|
|
326
|
+
expect(node).toEqual({ type: 'text', content: 'Hello' });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('includes optional variant', () => {
|
|
330
|
+
const node = Text({ content: 'Title', variant: 'heading' });
|
|
331
|
+
expect(node.type).toBe('text');
|
|
332
|
+
expect(node.content).toBe('Title');
|
|
333
|
+
expect(node.variant).toBe('heading');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('includes optional color', () => {
|
|
337
|
+
const node = Text({ content: 'red', color: '#ff0000' });
|
|
338
|
+
expect(node.color).toBe('#ff0000');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('all variant values work', () => {
|
|
342
|
+
for (const v of ['body', 'caption', 'heading'] as const) {
|
|
343
|
+
expect(Text({ content: '', variant: v }).variant).toBe(v);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('accepts I18nRef as content and sets i18n field', () => {
|
|
348
|
+
const ref = i18nRef('plugin:weather', 'stats.humidity');
|
|
349
|
+
const node = Text({ content: ref });
|
|
350
|
+
expect(node.type).toBe('text');
|
|
351
|
+
expect(node.content).toBe('stats.humidity');
|
|
352
|
+
expect(node.i18n).toEqual({ ns: 'plugin:weather', key: 'stats.humidity', params: undefined });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('I18nRef with params preserves params in i18n field', () => {
|
|
356
|
+
const ref: I18nRef = {
|
|
357
|
+
__i18n: true,
|
|
358
|
+
ns: 'plugin:weather',
|
|
359
|
+
key: 'ui.dayForecast',
|
|
360
|
+
params: { count: 7 },
|
|
361
|
+
};
|
|
362
|
+
const node = Text({ content: ref, variant: 'heading', weight: 'bold' });
|
|
363
|
+
expect(node.content).toBe('ui.dayForecast');
|
|
364
|
+
expect(node.i18n).toEqual({
|
|
365
|
+
ns: 'plugin:weather',
|
|
366
|
+
key: 'ui.dayForecast',
|
|
367
|
+
params: { count: 7 },
|
|
368
|
+
});
|
|
369
|
+
expect(node.variant).toBe('heading');
|
|
370
|
+
expect(node.weight).toBe('bold');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('string content does not set i18n field', () => {
|
|
374
|
+
const node = Text({ content: 'plain text' });
|
|
375
|
+
expect(node.i18n).toBeUndefined();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('accepts IntlRef number as content and sets intl field', () => {
|
|
379
|
+
const ref: IntlRef = {
|
|
380
|
+
__intl: true,
|
|
381
|
+
type: 'number',
|
|
382
|
+
value: 1234.5,
|
|
383
|
+
options: { minimumFractionDigits: 2 },
|
|
384
|
+
};
|
|
385
|
+
const node = Text({ content: ref });
|
|
386
|
+
expect(node.type).toBe('text');
|
|
387
|
+
expect(node.content).toBe('1234.5');
|
|
388
|
+
expect(node.intl).toBe(ref);
|
|
389
|
+
expect(node.i18n).toBeUndefined();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('accepts IntlRef dateTime as content', () => {
|
|
393
|
+
const ref = intlRef.dateTime(1700000000000);
|
|
394
|
+
const node = Text({ content: ref });
|
|
395
|
+
expect(node.content).toBe('1700000000000');
|
|
396
|
+
expect(node.intl).toBe(ref);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('accepts IntlRef list as content with joined fallback', () => {
|
|
400
|
+
const ref = intlRef.list(['apples', 'oranges']);
|
|
401
|
+
const node = Text({ content: ref });
|
|
402
|
+
expect(node.content).toBe('apples, oranges');
|
|
403
|
+
expect(node.intl).toBe(ref);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('IntlRef preserves other props', () => {
|
|
407
|
+
const ref = intlRef.number(99);
|
|
408
|
+
const node = Text({ content: ref, variant: 'heading', weight: 'bold' });
|
|
409
|
+
expect(node.variant).toBe('heading');
|
|
410
|
+
expect(node.weight).toBe('bold');
|
|
411
|
+
expect(node.intl).toBe(ref);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe('Badge', () => {
|
|
416
|
+
test('creates badge node with required label', () => {
|
|
417
|
+
const node = Badge({ label: 'New' });
|
|
418
|
+
expect(node).toEqual({ type: 'badge', label: 'New' });
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('includes optional variant', () => {
|
|
422
|
+
const node = Badge({ label: 'OK', variant: 'success' });
|
|
423
|
+
expect(node.type).toBe('badge');
|
|
424
|
+
expect(node.variant).toBe('success');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('includes optional icon and color', () => {
|
|
428
|
+
const node = Badge({ label: 'Warn', icon: 'alert-triangle', color: '#f00' });
|
|
429
|
+
expect(node.icon).toBe('alert-triangle');
|
|
430
|
+
expect(node.color).toBe('#f00');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('all variant values work', () => {
|
|
434
|
+
for (const v of [
|
|
435
|
+
'default',
|
|
436
|
+
'secondary',
|
|
437
|
+
'outline',
|
|
438
|
+
'success',
|
|
439
|
+
'warning',
|
|
440
|
+
'destructive',
|
|
441
|
+
] as const) {
|
|
442
|
+
expect(Badge({ label: '', variant: v }).variant).toBe(v);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe('Chart', () => {
|
|
448
|
+
const sampleData = [
|
|
449
|
+
{ ts: 1000, value: 10 },
|
|
450
|
+
{ ts: 2000, value: 20 },
|
|
451
|
+
];
|
|
452
|
+
|
|
453
|
+
test('creates chart node with required fields', () => {
|
|
454
|
+
const node = Chart({ variant: 'line', data: sampleData });
|
|
455
|
+
expect(node.type).toBe('chart');
|
|
456
|
+
expect(node.variant).toBe('line');
|
|
457
|
+
expect(node.data).toEqual(sampleData);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('includes optional fields', () => {
|
|
461
|
+
const node = Chart({
|
|
462
|
+
variant: 'bar',
|
|
463
|
+
data: sampleData,
|
|
464
|
+
color: '#00ff00',
|
|
465
|
+
label: 'Revenue',
|
|
466
|
+
height: 200,
|
|
467
|
+
});
|
|
468
|
+
expect(node.color).toBe('#00ff00');
|
|
469
|
+
expect(node.label).toBe('Revenue');
|
|
470
|
+
expect(node.height).toBe(200);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test('all variant values work', () => {
|
|
474
|
+
for (const v of ['line', 'area', 'bar'] as const) {
|
|
475
|
+
expect(Chart({ variant: v, data: [] }).variant).toBe(v);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe('Divider', () => {
|
|
481
|
+
test('creates divider node with no props', () => {
|
|
482
|
+
const node = Divider();
|
|
483
|
+
expect(node.type).toBe('divider');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('accepts direction', () => {
|
|
487
|
+
expect(Divider({ direction: 'horizontal' }).direction).toBe('horizontal');
|
|
488
|
+
expect(Divider({ direction: 'vertical' }).direction).toBe('vertical');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test('accepts color', () => {
|
|
492
|
+
const node = Divider({ color: '#ccc' });
|
|
493
|
+
expect(node.color).toBe('#ccc');
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
describe('Icon', () => {
|
|
498
|
+
test('creates icon node with required name', () => {
|
|
499
|
+
const node = Icon({ name: 'star' });
|
|
500
|
+
expect(node).toEqual({ type: 'icon', name: 'star' });
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('includes optional size and color', () => {
|
|
504
|
+
const node = Icon({ name: 'heart', size: 'lg', color: 'red' });
|
|
505
|
+
expect(node.size).toBe('lg');
|
|
506
|
+
expect(node.color).toBe('red');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('all size values work', () => {
|
|
510
|
+
for (const s of ['sm', 'md', 'lg'] as const) {
|
|
511
|
+
expect(Icon({ name: 'x', size: s }).size).toBe(s);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
describe('Image', () => {
|
|
517
|
+
test('creates image node with required src', () => {
|
|
518
|
+
const node = Image({ src: 'https://img.example.com/a.png' });
|
|
519
|
+
expect(node.type).toBe('image');
|
|
520
|
+
expect(node.src).toBe('https://img.example.com/a.png');
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test('includes all optional fields', () => {
|
|
524
|
+
const node = Image({
|
|
525
|
+
src: 'pic.jpg',
|
|
526
|
+
alt: 'A picture',
|
|
527
|
+
width: 100,
|
|
528
|
+
height: '50%',
|
|
529
|
+
fit: 'cover',
|
|
530
|
+
rounded: true,
|
|
531
|
+
aspectRatio: '16/9',
|
|
532
|
+
caption: 'Nice pic',
|
|
533
|
+
});
|
|
534
|
+
expect(node.alt).toBe('A picture');
|
|
535
|
+
expect(node.width).toBe(100);
|
|
536
|
+
expect(node.height).toBe('50%');
|
|
537
|
+
expect(node.fit).toBe('cover');
|
|
538
|
+
expect(node.rounded).toBe(true);
|
|
539
|
+
expect(node.aspectRatio).toBe('16/9');
|
|
540
|
+
expect(node.caption).toBe('Nice pic');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test('width/height accept both number and string', () => {
|
|
544
|
+
const n1 = Image({ src: 'a', width: 200, height: 100 });
|
|
545
|
+
expect(n1.width).toBe(200);
|
|
546
|
+
expect(n1.height).toBe(100);
|
|
547
|
+
|
|
548
|
+
const n2 = Image({ src: 'a', width: '30%', height: '50%' });
|
|
549
|
+
expect(n2.width).toBe('30%');
|
|
550
|
+
expect(n2.height).toBe('50%');
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
describe('Progress', () => {
|
|
555
|
+
test('creates progress node with required value', () => {
|
|
556
|
+
const node = Progress({ value: 42 });
|
|
557
|
+
expect(node).toEqual({ type: 'progress', value: 42 });
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test('includes optional fields', () => {
|
|
561
|
+
const node = Progress({
|
|
562
|
+
value: 75,
|
|
563
|
+
label: 'Upload',
|
|
564
|
+
color: 'blue',
|
|
565
|
+
showValue: true,
|
|
566
|
+
});
|
|
567
|
+
expect(node.label).toBe('Upload');
|
|
568
|
+
expect(node.color).toBe('blue');
|
|
569
|
+
expect(node.showValue).toBe(true);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('handles boundary values', () => {
|
|
573
|
+
expect(Progress({ value: 0 }).value).toBe(0);
|
|
574
|
+
expect(Progress({ value: 100 }).value).toBe(100);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
describe('Spacer', () => {
|
|
579
|
+
test('creates spacer node with no props', () => {
|
|
580
|
+
const node = Spacer();
|
|
581
|
+
expect(node.type).toBe('spacer');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test('accepts size', () => {
|
|
585
|
+
for (const s of ['sm', 'md', 'lg'] as const) {
|
|
586
|
+
expect(Spacer({ size: s }).size).toBe(s);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test('omits size when not specified', () => {
|
|
591
|
+
const node = Spacer();
|
|
592
|
+
expect(node.size).toBeUndefined();
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
describe('Stat', () => {
|
|
597
|
+
test('creates stat-value node with required label and value', () => {
|
|
598
|
+
const node = Stat({ label: 'Temp', value: 21.5 });
|
|
599
|
+
expect(node.type).toBe('stat-value');
|
|
600
|
+
expect(node.label).toBe('Temp');
|
|
601
|
+
expect(node.value).toBe(21.5);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test('value can be a string', () => {
|
|
605
|
+
const node = Stat({ label: 'Status', value: 'OK' });
|
|
606
|
+
expect(node.value).toBe('OK');
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test('includes all optional fields', () => {
|
|
610
|
+
const node = Stat({
|
|
611
|
+
label: 'Sales',
|
|
612
|
+
value: 1234,
|
|
613
|
+
unit: '$',
|
|
614
|
+
icon: 'dollar-sign',
|
|
615
|
+
trend: 'up',
|
|
616
|
+
color: 'green',
|
|
617
|
+
});
|
|
618
|
+
expect(node.unit).toBe('$');
|
|
619
|
+
expect(node.icon).toBe('dollar-sign');
|
|
620
|
+
expect(node.trend).toBe('up');
|
|
621
|
+
expect(node.color).toBe('green');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('all trend values work', () => {
|
|
625
|
+
for (const t of ['up', 'down', 'flat'] as const) {
|
|
626
|
+
expect(Stat({ label: '', value: 0, trend: t }).trend).toBe(t);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
describe('Status', () => {
|
|
632
|
+
test('creates status node with required label and status', () => {
|
|
633
|
+
const node = Status({ label: 'Server', status: 'online' });
|
|
634
|
+
expect(node.type).toBe('status');
|
|
635
|
+
expect(node.label).toBe('Server');
|
|
636
|
+
expect(node.status).toBe('online');
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test('includes optional icon and color', () => {
|
|
640
|
+
const node = Status({ label: 'DB', status: 'error', icon: 'database', color: '#f00' });
|
|
641
|
+
expect(node.icon).toBe('database');
|
|
642
|
+
expect(node.color).toBe('#f00');
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test('all status values work', () => {
|
|
646
|
+
for (const s of ['online', 'offline', 'warning', 'error', 'idle'] as const) {
|
|
647
|
+
expect(Status({ label: '', status: s }).status).toBe(s);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
describe('Video', () => {
|
|
653
|
+
test('creates video node with required fields', () => {
|
|
654
|
+
const node = Video({ src: 'https://stream.example.com/live.m3u8', format: 'hls' });
|
|
655
|
+
expect(node.type).toBe('video');
|
|
656
|
+
expect(node.src).toBe('https://stream.example.com/live.m3u8');
|
|
657
|
+
expect(node.format).toBe('hls');
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test('includes optional fields', () => {
|
|
661
|
+
const node = Video({
|
|
662
|
+
src: 'cam.mjpeg',
|
|
663
|
+
format: 'mjpeg',
|
|
664
|
+
poster: 'thumb.jpg',
|
|
665
|
+
aspectRatio: '4/3',
|
|
666
|
+
muted: true,
|
|
667
|
+
});
|
|
668
|
+
expect(node.poster).toBe('thumb.jpg');
|
|
669
|
+
expect(node.aspectRatio).toBe('4/3');
|
|
670
|
+
expect(node.muted).toBe(true);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test('both format values work', () => {
|
|
674
|
+
expect(Video({ src: '', format: 'hls' }).format).toBe('hls');
|
|
675
|
+
expect(Video({ src: '', format: 'mjpeg' }).format).toBe('mjpeg');
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
680
|
+
// Container factories (use normalizeChildren)
|
|
681
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
682
|
+
|
|
683
|
+
describe('Box', () => {
|
|
684
|
+
test('creates box node with empty children by default', () => {
|
|
685
|
+
const node = Box({});
|
|
686
|
+
expect(node.type).toBe('box');
|
|
687
|
+
expect(node.children).toEqual([]);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test('normalizes single child', () => {
|
|
691
|
+
const child = Text({ content: 'hi' });
|
|
692
|
+
const node = Box({ children: child });
|
|
693
|
+
expect(node.children).toEqual([child]);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test('normalizes array children', () => {
|
|
697
|
+
const a = Text({ content: 'a' });
|
|
698
|
+
const b = Text({ content: 'b' });
|
|
699
|
+
const node = Box({ children: [a, b] });
|
|
700
|
+
expect(node.children).toEqual([a, b]);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test('filters out falsy children', () => {
|
|
704
|
+
const a = Text({ content: 'a' });
|
|
705
|
+
const node = Box({ children: [a, null, false, undefined] });
|
|
706
|
+
expect(node.children).toEqual([a]);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test('includes all optional props', () => {
|
|
710
|
+
const node = Box({
|
|
711
|
+
background: '#333',
|
|
712
|
+
backgroundImage: 'bg.jpg',
|
|
713
|
+
backgroundFit: 'cover',
|
|
714
|
+
backgroundPosition: 'center',
|
|
715
|
+
blur: 'md',
|
|
716
|
+
opacity: 0.8,
|
|
717
|
+
padding: 'lg',
|
|
718
|
+
rounded: 'sm',
|
|
719
|
+
grow: true,
|
|
720
|
+
});
|
|
721
|
+
expect(node.background).toBe('#333');
|
|
722
|
+
expect(node.backgroundImage).toBe('bg.jpg');
|
|
723
|
+
expect(node.backgroundFit).toBe('cover');
|
|
724
|
+
expect(node.backgroundPosition).toBe('center');
|
|
725
|
+
expect(node.blur).toBe('md');
|
|
726
|
+
expect(node.opacity).toBe(0.8);
|
|
727
|
+
expect(node.padding).toBe('lg');
|
|
728
|
+
expect(node.rounded).toBe('sm');
|
|
729
|
+
expect(node.grow).toBe(true);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test('does not leak children into rest props', () => {
|
|
733
|
+
const node = Box({ children: Text({ content: 'x' }), padding: 'sm' });
|
|
734
|
+
// The children key should be the normalized array, not the raw input
|
|
735
|
+
expect(Array.isArray(node.children)).toBe(true);
|
|
736
|
+
expect(node.padding).toBe('sm');
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
describe('Grid', () => {
|
|
741
|
+
test('creates grid node with empty children by default', () => {
|
|
742
|
+
const node = Grid({});
|
|
743
|
+
expect(node.type).toBe('grid');
|
|
744
|
+
expect(node.children).toEqual([]);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test('normalizes children', () => {
|
|
748
|
+
const a = Text({ content: 'a' });
|
|
749
|
+
const b = Text({ content: 'b' });
|
|
750
|
+
const node = Grid({ children: [a, b] });
|
|
751
|
+
expect(node.children).toEqual([a, b]);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test('includes optional fields', () => {
|
|
755
|
+
const node = Grid({
|
|
756
|
+
columns: 3,
|
|
757
|
+
gap: 'md',
|
|
758
|
+
autoFit: true,
|
|
759
|
+
minColumnWidth: 200,
|
|
760
|
+
});
|
|
761
|
+
expect(node.columns).toBe(3);
|
|
762
|
+
expect(node.gap).toBe('md');
|
|
763
|
+
expect(node.autoFit).toBe(true);
|
|
764
|
+
expect(node.minColumnWidth).toBe(200);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test('filters falsy children', () => {
|
|
768
|
+
const a = Text({ content: 'a' });
|
|
769
|
+
const node = Grid({ children: [a, null, false] });
|
|
770
|
+
expect(node.children).toEqual([a]);
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
describe('Section', () => {
|
|
775
|
+
test('creates section node with title and empty children', () => {
|
|
776
|
+
const node = Section({ title: 'Settings' });
|
|
777
|
+
expect(node.type).toBe('section');
|
|
778
|
+
expect(node.title).toBe('Settings');
|
|
779
|
+
expect(node.children).toEqual([]);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test('normalizes children', () => {
|
|
783
|
+
const child = Text({ content: 'hello' });
|
|
784
|
+
const node = Section({ title: 'Main', children: child });
|
|
785
|
+
expect(node.children).toEqual([child]);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test('normalizes array children with falsy values', () => {
|
|
789
|
+
const a = Text({ content: 'a' });
|
|
790
|
+
const node = Section({ title: 'S', children: [a, null, false] });
|
|
791
|
+
expect(node.children).toEqual([a]);
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
describe('Row', () => {
|
|
796
|
+
beforeEach(() => {
|
|
797
|
+
_setActionRegistrar(null);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test('creates row node with empty children by default', () => {
|
|
801
|
+
const node = Row({});
|
|
802
|
+
expect(node.type).toBe('row');
|
|
803
|
+
expect(node.children).toEqual([]);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test('normalizes children', () => {
|
|
807
|
+
const a = Text({ content: 'a' });
|
|
808
|
+
const b = Text({ content: 'b' });
|
|
809
|
+
const node = Row({ children: [a, b] });
|
|
810
|
+
expect(node.children).toEqual([a, b]);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test('includes all FlexLayoutProps', () => {
|
|
814
|
+
const node = Row({
|
|
815
|
+
gap: 'lg',
|
|
816
|
+
align: 'center',
|
|
817
|
+
justify: 'between',
|
|
818
|
+
wrap: true,
|
|
819
|
+
grow: true,
|
|
820
|
+
});
|
|
821
|
+
expect(node.gap).toBe('lg');
|
|
822
|
+
expect(node.align).toBe('center');
|
|
823
|
+
expect(node.justify).toBe('between');
|
|
824
|
+
expect(node.wrap).toBe(true);
|
|
825
|
+
expect(node.grow).toBe(true);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test('resolves onPress to action ID', () => {
|
|
829
|
+
const node = Row({ onPress: () => {} });
|
|
830
|
+
expect(node.onPress).toMatch(/^__action_\d+$/);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test('filters falsy children', () => {
|
|
834
|
+
const a = Text({ content: 'a' });
|
|
835
|
+
const node = Row({ children: [a, null, undefined, false] });
|
|
836
|
+
expect(node.children).toEqual([a]);
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
describe('Column', () => {
|
|
841
|
+
beforeEach(() => {
|
|
842
|
+
_setActionRegistrar(null);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test('creates column node with empty children by default', () => {
|
|
846
|
+
const node = Column({});
|
|
847
|
+
expect(node.type).toBe('column');
|
|
848
|
+
expect(node.children).toEqual([]);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test('normalizes children', () => {
|
|
852
|
+
const a = Text({ content: 'a' });
|
|
853
|
+
const b = Text({ content: 'b' });
|
|
854
|
+
const node = Column({ children: [a, b] });
|
|
855
|
+
expect(node.children).toEqual([a, b]);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test('includes all FlexLayoutProps', () => {
|
|
859
|
+
const node = Column({
|
|
860
|
+
gap: 'sm',
|
|
861
|
+
align: 'stretch',
|
|
862
|
+
justify: 'around',
|
|
863
|
+
wrap: false,
|
|
864
|
+
grow: true,
|
|
865
|
+
});
|
|
866
|
+
expect(node.gap).toBe('sm');
|
|
867
|
+
expect(node.align).toBe('stretch');
|
|
868
|
+
expect(node.justify).toBe('around');
|
|
869
|
+
expect(node.wrap).toBe(false);
|
|
870
|
+
expect(node.grow).toBe(true);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test('resolves onPress to action ID', () => {
|
|
874
|
+
const node = Column({ onPress: () => {} });
|
|
875
|
+
expect(node.onPress).toMatch(/^__action_\d+$/);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test('filters falsy children', () => {
|
|
879
|
+
const a = Text({ content: 'a' });
|
|
880
|
+
const node = Column({ children: [a, null, undefined, false] });
|
|
881
|
+
expect(node.children).toEqual([a]);
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
886
|
+
// Action-resolving factories
|
|
887
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
888
|
+
|
|
889
|
+
describe('Button', () => {
|
|
890
|
+
beforeEach(() => {
|
|
891
|
+
_setActionRegistrar(null);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test('creates button node with label only', () => {
|
|
895
|
+
const node = Button({ label: 'Click me' });
|
|
896
|
+
expect(node.type).toBe('button');
|
|
897
|
+
expect(node.label).toBe('Click me');
|
|
898
|
+
expect(node.onPress).toBeUndefined();
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test('resolves onPress handler to an action ID', () => {
|
|
902
|
+
const handler = () => {};
|
|
903
|
+
const node = Button({ label: 'Go', onPress: handler });
|
|
904
|
+
expect(node.type).toBe('button');
|
|
905
|
+
expect(node.onPress).toMatch(/^__action_\d+$/);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test('uses custom registrar for onPress', () => {
|
|
909
|
+
_setActionRegistrar(() => 'btn-action-1');
|
|
910
|
+
const node = Button({ label: 'Go', onPress: () => {} });
|
|
911
|
+
expect(node.onPress).toBe('btn-action-1');
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test('includes url without onPress', () => {
|
|
915
|
+
const node = Button({ label: 'Link', url: 'https://example.com' });
|
|
916
|
+
expect(node.url).toBe('https://example.com');
|
|
917
|
+
expect(node.onPress).toBeUndefined();
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test('includes optional icon, variant, color', () => {
|
|
921
|
+
const node = Button({
|
|
922
|
+
label: 'Delete',
|
|
923
|
+
icon: 'trash',
|
|
924
|
+
variant: 'destructive',
|
|
925
|
+
color: '#f00',
|
|
926
|
+
onPress: () => {},
|
|
927
|
+
});
|
|
928
|
+
expect(node.icon).toBe('trash');
|
|
929
|
+
expect(node.variant).toBe('destructive');
|
|
930
|
+
expect(node.color).toBe('#f00');
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
test('all variant values work', () => {
|
|
934
|
+
for (const v of ['default', 'secondary', 'outline', 'ghost', 'destructive', 'link'] as const) {
|
|
935
|
+
expect(Button({ variant: v }).variant).toBe(v);
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
describe('Slider', () => {
|
|
941
|
+
beforeEach(() => {
|
|
942
|
+
_setActionRegistrar(null);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test('creates slider node with required fields', () => {
|
|
946
|
+
const handler = () => {};
|
|
947
|
+
const node = Slider({ value: 50, min: 0, max: 100, onChange: handler });
|
|
948
|
+
expect(node.type).toBe('slider');
|
|
949
|
+
expect(node.value).toBe(50);
|
|
950
|
+
expect(node.min).toBe(0);
|
|
951
|
+
expect(node.max).toBe(100);
|
|
952
|
+
expect(node.onChange).toMatch(/^__action_\d+$/);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
test('uses custom registrar for onChange', () => {
|
|
956
|
+
_setActionRegistrar(() => 'slider-action-1');
|
|
957
|
+
const node = Slider({ value: 10, min: 0, max: 20, onChange: () => {} });
|
|
958
|
+
expect(node.onChange).toBe('slider-action-1');
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
test('includes all optional fields', () => {
|
|
962
|
+
const node = Slider({
|
|
963
|
+
value: 5,
|
|
964
|
+
min: 0,
|
|
965
|
+
max: 10,
|
|
966
|
+
step: 0.5,
|
|
967
|
+
unit: 'kg',
|
|
968
|
+
label: 'Weight',
|
|
969
|
+
icon: 'scale',
|
|
970
|
+
color: '#0f0',
|
|
971
|
+
onChange: () => {},
|
|
972
|
+
});
|
|
973
|
+
expect(node.step).toBe(0.5);
|
|
974
|
+
expect(node.unit).toBe('kg');
|
|
975
|
+
expect(node.label).toBe('Weight');
|
|
976
|
+
expect(node.icon).toBe('scale');
|
|
977
|
+
expect(node.color).toBe('#0f0');
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
describe('Toggle', () => {
|
|
982
|
+
beforeEach(() => {
|
|
983
|
+
_setActionRegistrar(null);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
test('creates toggle node with required fields', () => {
|
|
987
|
+
const handler = () => {};
|
|
988
|
+
const node = Toggle({ label: 'Dark mode', checked: false, onToggle: handler });
|
|
989
|
+
expect(node.type).toBe('toggle');
|
|
990
|
+
expect(node.label).toBe('Dark mode');
|
|
991
|
+
expect(node.checked).toBe(false);
|
|
992
|
+
expect(node.onToggle).toMatch(/^__action_\d+$/);
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
test('checked can be true', () => {
|
|
996
|
+
const node = Toggle({ label: 'On', checked: true, onToggle: () => {} });
|
|
997
|
+
expect(node.checked).toBe(true);
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test('uses custom registrar for onToggle', () => {
|
|
1001
|
+
_setActionRegistrar(() => 'toggle-action-1');
|
|
1002
|
+
const node = Toggle({ label: 'LED', checked: true, onToggle: () => {} });
|
|
1003
|
+
expect(node.onToggle).toBe('toggle-action-1');
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
test('includes optional icon and color', () => {
|
|
1007
|
+
const node = Toggle({
|
|
1008
|
+
label: 'Mute',
|
|
1009
|
+
checked: false,
|
|
1010
|
+
onToggle: () => {},
|
|
1011
|
+
icon: 'volume-x',
|
|
1012
|
+
color: 'orange',
|
|
1013
|
+
});
|
|
1014
|
+
expect(node.icon).toBe('volume-x');
|
|
1015
|
+
expect(node.color).toBe('orange');
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test('includes disabled prop', () => {
|
|
1019
|
+
const node = Toggle({ label: 'Off', checked: false, onToggle: () => {}, disabled: true });
|
|
1020
|
+
expect(node.disabled).toBe(true);
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1025
|
+
// New props on existing components
|
|
1026
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1027
|
+
|
|
1028
|
+
describe('Text (new props)', () => {
|
|
1029
|
+
beforeEach(() => {
|
|
1030
|
+
_setActionRegistrar(null);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test('includes align and weight', () => {
|
|
1034
|
+
const node = Text({ content: 'hi', align: 'center', weight: 'bold' });
|
|
1035
|
+
expect(node.align).toBe('center');
|
|
1036
|
+
expect(node.weight).toBe('bold');
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
test('includes maxLines', () => {
|
|
1040
|
+
const node = Text({ content: 'long', maxLines: 3 });
|
|
1041
|
+
expect(node.maxLines).toBe(3);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
test('includes size', () => {
|
|
1045
|
+
for (const s of ['xs', 'sm', 'md', 'lg', 'xl'] as const) {
|
|
1046
|
+
expect(Text({ content: '', size: s }).size).toBe(s);
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
test('resolves onPress to action ID', () => {
|
|
1051
|
+
const node = Text({ content: 'click', onPress: () => {} });
|
|
1052
|
+
expect(node.onPress).toMatch(/^__action_\d+$/);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test('omits onPress when not provided', () => {
|
|
1056
|
+
const node = Text({ content: 'plain' });
|
|
1057
|
+
expect(node.onPress).toBeUndefined();
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
test('accepts children as alias for content', () => {
|
|
1061
|
+
const node = Text({ children: 'hello' });
|
|
1062
|
+
expect(node).toEqual({ type: 'text', content: 'hello' });
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
test('children works with I18nRef', () => {
|
|
1066
|
+
const ref = i18nRef('plugin:test', 'greeting');
|
|
1067
|
+
const node = Text({ children: ref });
|
|
1068
|
+
expect(node.content).toBe('greeting');
|
|
1069
|
+
expect(node.i18n).toEqual({ ns: 'plugin:test', key: 'greeting' });
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test('content takes precedence over children', () => {
|
|
1073
|
+
const node = Text({ content: 'from-content', children: 'from-children' });
|
|
1074
|
+
expect(node.content).toBe('from-content');
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
test('defaults to empty string when neither content nor children provided', () => {
|
|
1078
|
+
const node = Text({});
|
|
1079
|
+
expect(node.content).toBe('');
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
describe('Button (new props)', () => {
|
|
1084
|
+
beforeEach(() => {
|
|
1085
|
+
_setActionRegistrar(null);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test('includes disabled and loading', () => {
|
|
1089
|
+
const node = Button({ label: 'Go', disabled: true, loading: true });
|
|
1090
|
+
expect(node.disabled).toBe(true);
|
|
1091
|
+
expect(node.loading).toBe(true);
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
test('includes size and fullWidth', () => {
|
|
1095
|
+
const node = Button({ label: 'Big', size: 'lg', fullWidth: true });
|
|
1096
|
+
expect(node.size).toBe('lg');
|
|
1097
|
+
expect(node.fullWidth).toBe(true);
|
|
1098
|
+
});
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
describe('Stat (new props)', () => {
|
|
1102
|
+
test('includes trendValue and description', () => {
|
|
1103
|
+
const node = Stat({ label: 'Rev', value: 100, trendValue: '+5.2%', description: 'Monthly' });
|
|
1104
|
+
expect(node.trendValue).toBe('+5.2%');
|
|
1105
|
+
expect(node.description).toBe('Monthly');
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
describe('Divider (new props)', () => {
|
|
1110
|
+
test('includes label', () => {
|
|
1111
|
+
const node = Divider({ label: 'OR' });
|
|
1112
|
+
expect(node.label).toBe('OR');
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
describe('Badge (onPress)', () => {
|
|
1117
|
+
beforeEach(() => {
|
|
1118
|
+
_setActionRegistrar(null);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
test('resolves onPress to action ID', () => {
|
|
1122
|
+
const node = Badge({ label: 'Tag', onPress: () => {} });
|
|
1123
|
+
expect(node.onPress).toMatch(/^__action_\d+$/);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
test('omits onPress when not provided', () => {
|
|
1127
|
+
const node = Badge({ label: 'Static' });
|
|
1128
|
+
expect(node.onPress).toBeUndefined();
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
describe('Icon (onPress)', () => {
|
|
1133
|
+
beforeEach(() => {
|
|
1134
|
+
_setActionRegistrar(null);
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
test('resolves onPress to action ID', () => {
|
|
1138
|
+
const node = Icon({ name: 'star', onPress: () => {} });
|
|
1139
|
+
expect(node.onPress).toMatch(/^__action_\d+$/);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
test('omits onPress when not provided', () => {
|
|
1143
|
+
const node = Icon({ name: 'star' });
|
|
1144
|
+
expect(node.onPress).toBeUndefined();
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
describe('Slider (disabled)', () => {
|
|
1149
|
+
beforeEach(() => {
|
|
1150
|
+
_setActionRegistrar(null);
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
test('includes disabled prop', () => {
|
|
1154
|
+
const node = Slider({ value: 5, min: 0, max: 10, onChange: () => {}, disabled: true });
|
|
1155
|
+
expect(node.disabled).toBe(true);
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
describe('Section (new props)', () => {
|
|
1160
|
+
test('includes gap and icon', () => {
|
|
1161
|
+
const node = Section({ title: 'Info', gap: 'lg', icon: 'settings' });
|
|
1162
|
+
expect(node.gap).toBe('lg');
|
|
1163
|
+
expect(node.icon).toBe('settings');
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
describe('Video (new props)', () => {
|
|
1168
|
+
test('includes controls and loop', () => {
|
|
1169
|
+
const node = Video({ src: 'test.m3u8', format: 'hls', controls: true, loop: true });
|
|
1170
|
+
expect(node.controls).toBe(true);
|
|
1171
|
+
expect(node.loop).toBe(true);
|
|
1172
|
+
});
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
describe('Progress (new props)', () => {
|
|
1176
|
+
test('includes size and variant', () => {
|
|
1177
|
+
const node = Progress({ value: 50, size: 'lg', variant: 'ring' });
|
|
1178
|
+
expect(node.size).toBe('lg');
|
|
1179
|
+
expect(node.variant).toBe('ring');
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
test('all size values work', () => {
|
|
1183
|
+
for (const s of ['sm', 'md', 'lg'] as const) {
|
|
1184
|
+
expect(Progress({ value: 50, size: s }).size).toBe(s);
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
describe('Chart (new props)', () => {
|
|
1190
|
+
test('includes series', () => {
|
|
1191
|
+
const series = [
|
|
1192
|
+
{ key: 'temp', data: [{ ts: 1, value: 20 }], color: 'red' },
|
|
1193
|
+
{ key: 'humidity', label: 'Humid', data: [{ ts: 1, value: 60 }] },
|
|
1194
|
+
];
|
|
1195
|
+
const node = Chart({ variant: 'line', data: [], series });
|
|
1196
|
+
expect(node.series).toEqual(series);
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
test('includes axis and grid controls', () => {
|
|
1200
|
+
const node = Chart({
|
|
1201
|
+
variant: 'area',
|
|
1202
|
+
data: [],
|
|
1203
|
+
showXAxis: true,
|
|
1204
|
+
showYAxis: true,
|
|
1205
|
+
showGrid: true,
|
|
1206
|
+
showLegend: true,
|
|
1207
|
+
});
|
|
1208
|
+
expect(node.showXAxis).toBe(true);
|
|
1209
|
+
expect(node.showYAxis).toBe(true);
|
|
1210
|
+
expect(node.showGrid).toBe(true);
|
|
1211
|
+
expect(node.showLegend).toBe(true);
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1216
|
+
// New components
|
|
1217
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1218
|
+
|
|
1219
|
+
describe('Callout', () => {
|
|
1220
|
+
test('creates callout with required fields', () => {
|
|
1221
|
+
const node = Callout({ variant: 'info', message: 'Hello' });
|
|
1222
|
+
expect(node.type).toBe('callout');
|
|
1223
|
+
expect(node.variant).toBe('info');
|
|
1224
|
+
expect(node.message).toBe('Hello');
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
test('includes optional title and icon', () => {
|
|
1228
|
+
const node = Callout({
|
|
1229
|
+
variant: 'warning',
|
|
1230
|
+
message: 'Watch out',
|
|
1231
|
+
title: 'Heads up',
|
|
1232
|
+
icon: 'zap',
|
|
1233
|
+
});
|
|
1234
|
+
expect(node.title).toBe('Heads up');
|
|
1235
|
+
expect(node.icon).toBe('zap');
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
test('all variant values work', () => {
|
|
1239
|
+
for (const v of ['info', 'warning', 'error', 'success'] as const) {
|
|
1240
|
+
expect(Callout({ variant: v, message: '' }).variant).toBe(v);
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
describe('TextInput', () => {
|
|
1246
|
+
beforeEach(() => {
|
|
1247
|
+
_setActionRegistrar(null);
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
test('creates text-input with required fields', () => {
|
|
1251
|
+
const node = TextInput({ value: 'hello', onChange: () => {} });
|
|
1252
|
+
expect(node.type).toBe('text-input');
|
|
1253
|
+
expect(node.value).toBe('hello');
|
|
1254
|
+
expect(node.onChange).toMatch(/^__action_\d+$/);
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
test('resolves onSubmit when provided', () => {
|
|
1258
|
+
const node = TextInput({ value: '', onChange: () => {}, onSubmit: () => {} });
|
|
1259
|
+
expect(node.onSubmit).toMatch(/^__action_\d+$/);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
test('omits onSubmit when not provided', () => {
|
|
1263
|
+
const node = TextInput({ value: '', onChange: () => {} });
|
|
1264
|
+
expect(node.onSubmit).toBeUndefined();
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
test('includes all optional props', () => {
|
|
1268
|
+
const node = TextInput({
|
|
1269
|
+
value: '',
|
|
1270
|
+
onChange: () => {},
|
|
1271
|
+
placeholder: 'Type here',
|
|
1272
|
+
label: 'Name',
|
|
1273
|
+
icon: 'user',
|
|
1274
|
+
disabled: true,
|
|
1275
|
+
inputType: 'email',
|
|
1276
|
+
});
|
|
1277
|
+
expect(node.placeholder).toBe('Type here');
|
|
1278
|
+
expect(node.label).toBe('Name');
|
|
1279
|
+
expect(node.icon).toBe('user');
|
|
1280
|
+
expect(node.disabled).toBe(true);
|
|
1281
|
+
expect(node.inputType).toBe('email');
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
test('all inputType values work', () => {
|
|
1285
|
+
for (const t of ['text', 'password', 'email', 'number'] as const) {
|
|
1286
|
+
expect(TextInput({ value: '', onChange: () => {}, inputType: t }).inputType).toBe(t);
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
describe('Select', () => {
|
|
1292
|
+
beforeEach(() => {
|
|
1293
|
+
_setActionRegistrar(null);
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
const opts = [
|
|
1297
|
+
{ value: 'a', label: 'Alpha' },
|
|
1298
|
+
{ value: 'b', label: 'Beta' },
|
|
1299
|
+
];
|
|
1300
|
+
|
|
1301
|
+
test('creates select with required fields', () => {
|
|
1302
|
+
const node = Select({ value: 'a', options: opts, onChange: () => {} });
|
|
1303
|
+
expect(node.type).toBe('select');
|
|
1304
|
+
expect(node.value).toBe('a');
|
|
1305
|
+
expect(node.options).toEqual(opts);
|
|
1306
|
+
expect(node.onChange).toMatch(/^__action_\d+$/);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test('includes all optional props', () => {
|
|
1310
|
+
const node = Select({
|
|
1311
|
+
value: 'a',
|
|
1312
|
+
options: opts,
|
|
1313
|
+
onChange: () => {},
|
|
1314
|
+
label: 'Pick one',
|
|
1315
|
+
placeholder: 'Choose…',
|
|
1316
|
+
disabled: true,
|
|
1317
|
+
icon: 'list',
|
|
1318
|
+
});
|
|
1319
|
+
expect(node.label).toBe('Pick one');
|
|
1320
|
+
expect(node.placeholder).toBe('Choose…');
|
|
1321
|
+
expect(node.disabled).toBe(true);
|
|
1322
|
+
expect(node.icon).toBe('list');
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
test('uses custom registrar for onChange', () => {
|
|
1326
|
+
_setActionRegistrar(() => 'sel-action');
|
|
1327
|
+
const node = Select({ value: 'a', options: opts, onChange: () => {} });
|
|
1328
|
+
expect(node.onChange).toBe('sel-action');
|
|
1329
|
+
});
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1333
|
+
// New components (Row/Column era)
|
|
1334
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1335
|
+
|
|
1336
|
+
describe('Table', () => {
|
|
1337
|
+
const cols = [
|
|
1338
|
+
{ key: 'name', label: 'Name' },
|
|
1339
|
+
{ key: 'age', label: 'Age', align: 'right' as const },
|
|
1340
|
+
];
|
|
1341
|
+
const rows = [
|
|
1342
|
+
{ name: 'Alice', age: 30 },
|
|
1343
|
+
{ name: 'Bob', age: 25 },
|
|
1344
|
+
];
|
|
1345
|
+
|
|
1346
|
+
test('creates table with required fields', () => {
|
|
1347
|
+
const node = Table({ columns: cols, rows });
|
|
1348
|
+
expect(node.type).toBe('table');
|
|
1349
|
+
expect(node.columns).toEqual(cols);
|
|
1350
|
+
expect(node.rows).toEqual(rows);
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
test('includes optional props', () => {
|
|
1354
|
+
const node = Table({ columns: cols, rows, striped: true, compact: true, maxRows: 5 });
|
|
1355
|
+
expect(node.striped).toBe(true);
|
|
1356
|
+
expect(node.compact).toBe(true);
|
|
1357
|
+
expect(node.maxRows).toBe(5);
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
test('resolves onRowPress', () => {
|
|
1361
|
+
_setActionRegistrar(null);
|
|
1362
|
+
const node = Table({ columns: cols, rows, onRowPress: () => {} });
|
|
1363
|
+
expect(node.onRowPress).toMatch(/^__action_\d+$/);
|
|
1364
|
+
});
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
describe('KeyValue', () => {
|
|
1368
|
+
const items = [
|
|
1369
|
+
{ label: 'Host', value: 'localhost' },
|
|
1370
|
+
{ label: 'Port', value: 8080 },
|
|
1371
|
+
];
|
|
1372
|
+
|
|
1373
|
+
test('creates key-value with required fields', () => {
|
|
1374
|
+
const node = KeyValue({ items });
|
|
1375
|
+
expect(node.type).toBe('key-value');
|
|
1376
|
+
expect(node.items).toEqual(items);
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
test('includes optional props', () => {
|
|
1380
|
+
const node = KeyValue({ items, layout: 'stacked', dividers: true, compact: true });
|
|
1381
|
+
expect(node.layout).toBe('stacked');
|
|
1382
|
+
expect(node.dividers).toBe(true);
|
|
1383
|
+
expect(node.compact).toBe(true);
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
test('items support icon, color, copyable', () => {
|
|
1387
|
+
const node = KeyValue({
|
|
1388
|
+
items: [{ label: 'IP', value: '127.0.0.1', icon: 'globe', color: '#0f0', copyable: true }],
|
|
1389
|
+
});
|
|
1390
|
+
const item = node.items[0];
|
|
1391
|
+
expect(item?.icon).toBe('globe');
|
|
1392
|
+
expect(item?.color).toBe('#0f0');
|
|
1393
|
+
expect(item?.copyable).toBe(true);
|
|
1394
|
+
});
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
describe('Avatar', () => {
|
|
1398
|
+
beforeEach(() => {
|
|
1399
|
+
_setActionRegistrar(null);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
test('creates avatar with defaults', () => {
|
|
1403
|
+
const node = Avatar({});
|
|
1404
|
+
expect(node.type).toBe('avatar');
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test('includes all optional props', () => {
|
|
1408
|
+
const node = Avatar({
|
|
1409
|
+
src: 'photo.jpg',
|
|
1410
|
+
fallback: 'JD',
|
|
1411
|
+
alt: 'John Doe',
|
|
1412
|
+
size: 'lg',
|
|
1413
|
+
shape: 'square',
|
|
1414
|
+
status: 'online',
|
|
1415
|
+
});
|
|
1416
|
+
expect(node.src).toBe('photo.jpg');
|
|
1417
|
+
expect(node.fallback).toBe('JD');
|
|
1418
|
+
expect(node.alt).toBe('John Doe');
|
|
1419
|
+
expect(node.size).toBe('lg');
|
|
1420
|
+
expect(node.shape).toBe('square');
|
|
1421
|
+
expect(node.status).toBe('online');
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
test('resolves onPress', () => {
|
|
1425
|
+
const node = Avatar({ onPress: () => {} });
|
|
1426
|
+
expect(node.onPress).toMatch(/^__action_\d+$/);
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
test('all status values work', () => {
|
|
1430
|
+
for (const s of ['online', 'offline', 'busy', 'away'] as const) {
|
|
1431
|
+
expect(Avatar({ status: s }).status).toBe(s);
|
|
1432
|
+
}
|
|
1433
|
+
});
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
describe('Link', () => {
|
|
1437
|
+
test('creates link with required fields', () => {
|
|
1438
|
+
const node = Link({ label: 'Docs', url: 'https://docs.example.com' });
|
|
1439
|
+
expect(node.type).toBe('link');
|
|
1440
|
+
expect(node.label).toBe('Docs');
|
|
1441
|
+
expect(node.url).toBe('https://docs.example.com');
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
test('includes optional props', () => {
|
|
1445
|
+
const node = Link({
|
|
1446
|
+
label: 'API',
|
|
1447
|
+
url: 'https://api.example.com',
|
|
1448
|
+
icon: 'external-link',
|
|
1449
|
+
variant: 'underline',
|
|
1450
|
+
size: 'xs',
|
|
1451
|
+
});
|
|
1452
|
+
expect(node.icon).toBe('external-link');
|
|
1453
|
+
expect(node.variant).toBe('underline');
|
|
1454
|
+
expect(node.size).toBe('xs');
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
test('all variant values work', () => {
|
|
1458
|
+
for (const v of ['default', 'muted', 'underline'] as const) {
|
|
1459
|
+
expect(Link({ label: '', url: '', variant: v }).variant).toBe(v);
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
describe('Tabs', () => {
|
|
1465
|
+
beforeEach(() => {
|
|
1466
|
+
_setActionRegistrar(null);
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
test('creates tabs with required fields', () => {
|
|
1470
|
+
const node = Tabs({
|
|
1471
|
+
value: 'a',
|
|
1472
|
+
tabs: [
|
|
1473
|
+
{ key: 'a', label: 'Tab A' },
|
|
1474
|
+
{ key: 'b', label: 'Tab B' },
|
|
1475
|
+
],
|
|
1476
|
+
onChange: () => {},
|
|
1477
|
+
});
|
|
1478
|
+
expect(node.type).toBe('tabs');
|
|
1479
|
+
expect(node.value).toBe('a');
|
|
1480
|
+
expect(node.tabs).toHaveLength(2);
|
|
1481
|
+
expect(node.onChange).toMatch(/^__action_\d+$/);
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
test('normalizes tab children', () => {
|
|
1485
|
+
const child = Text({ content: 'content' });
|
|
1486
|
+
const node = Tabs({
|
|
1487
|
+
value: 'a',
|
|
1488
|
+
tabs: [{ key: 'a', label: 'Tab', children: child }],
|
|
1489
|
+
onChange: () => {},
|
|
1490
|
+
});
|
|
1491
|
+
expect(node.tabs[0]?.children).toEqual([child]);
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
test('includes optional variant and icon', () => {
|
|
1495
|
+
const node = Tabs({
|
|
1496
|
+
value: 'a',
|
|
1497
|
+
tabs: [{ key: 'a', label: 'Tab', icon: 'star' }],
|
|
1498
|
+
onChange: () => {},
|
|
1499
|
+
variant: 'pills',
|
|
1500
|
+
});
|
|
1501
|
+
expect(node.variant).toBe('pills');
|
|
1502
|
+
expect(node.tabs[0]?.icon).toBe('star');
|
|
1503
|
+
});
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
describe('CodeBlock', () => {
|
|
1507
|
+
test('creates code-block with required fields', () => {
|
|
1508
|
+
const node = CodeBlock({ code: 'console.log("hi")' });
|
|
1509
|
+
expect(node.type).toBe('code-block');
|
|
1510
|
+
expect(node.code).toBe('console.log("hi")');
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
test('includes all optional props', () => {
|
|
1514
|
+
const node = CodeBlock({
|
|
1515
|
+
code: 'fn main() {}',
|
|
1516
|
+
language: 'rust',
|
|
1517
|
+
showLineNumbers: true,
|
|
1518
|
+
maxLines: 20,
|
|
1519
|
+
copyable: true,
|
|
1520
|
+
label: 'main.rs',
|
|
1521
|
+
});
|
|
1522
|
+
expect(node.language).toBe('rust');
|
|
1523
|
+
expect(node.showLineNumbers).toBe(true);
|
|
1524
|
+
expect(node.maxLines).toBe(20);
|
|
1525
|
+
expect(node.copyable).toBe(true);
|
|
1526
|
+
expect(node.label).toBe('main.rs');
|
|
1527
|
+
});
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
describe('Checkbox', () => {
|
|
1531
|
+
beforeEach(() => {
|
|
1532
|
+
_setActionRegistrar(null);
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
test('creates checkbox with required fields', () => {
|
|
1536
|
+
const node = Checkbox({ label: 'Accept terms', checked: false, onToggle: () => {} });
|
|
1537
|
+
expect(node.type).toBe('checkbox');
|
|
1538
|
+
expect(node.label).toBe('Accept terms');
|
|
1539
|
+
expect(node.checked).toBe(false);
|
|
1540
|
+
expect(node.onToggle).toMatch(/^__action_\d+$/);
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
test('includes optional props', () => {
|
|
1544
|
+
const node = Checkbox({
|
|
1545
|
+
label: 'Enable',
|
|
1546
|
+
checked: true,
|
|
1547
|
+
onToggle: () => {},
|
|
1548
|
+
description: 'Turn it on',
|
|
1549
|
+
icon: 'check',
|
|
1550
|
+
disabled: true,
|
|
1551
|
+
});
|
|
1552
|
+
expect(node.description).toBe('Turn it on');
|
|
1553
|
+
expect(node.icon).toBe('check');
|
|
1554
|
+
expect(node.disabled).toBe(true);
|
|
1555
|
+
});
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
describe('Skeleton', () => {
|
|
1559
|
+
test('creates skeleton with required variant', () => {
|
|
1560
|
+
const node = Skeleton({ variant: 'text' });
|
|
1561
|
+
expect(node.type).toBe('skeleton');
|
|
1562
|
+
expect(node.variant).toBe('text');
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
test('includes optional props', () => {
|
|
1566
|
+
const node = Skeleton({ variant: 'rect', width: '100px', height: '50px', lines: 3 });
|
|
1567
|
+
expect(node.width).toBe('100px');
|
|
1568
|
+
expect(node.height).toBe('50px');
|
|
1569
|
+
expect(node.lines).toBe(3);
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
test('all variant values work', () => {
|
|
1573
|
+
for (const v of ['text', 'circle', 'rect'] as const) {
|
|
1574
|
+
expect(Skeleton({ variant: v }).variant).toBe(v);
|
|
1575
|
+
}
|
|
1576
|
+
});
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
describe('TextInput (multiline)', () => {
|
|
1580
|
+
beforeEach(() => {
|
|
1581
|
+
_setActionRegistrar(null);
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
test('includes multiline and rows', () => {
|
|
1585
|
+
const node = TextInput({ value: '', onChange: () => {}, multiline: true, rows: 5 });
|
|
1586
|
+
expect(node.multiline).toBe(true);
|
|
1587
|
+
expect(node.rows).toBe(5);
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
test('multiline defaults are omitted when not set', () => {
|
|
1591
|
+
const node = TextInput({ value: '', onChange: () => {} });
|
|
1592
|
+
expect(node.multiline).toBeUndefined();
|
|
1593
|
+
expect(node.rows).toBeUndefined();
|
|
1594
|
+
});
|
|
1595
|
+
});
|