@ccheever/exact-renderer 0.1.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.
Files changed (80) hide show
  1. package/package.json +118 -0
  2. package/src/__tests__/adapter-window-state.test.tsx +190 -0
  3. package/src/__tests__/attrs.test.ts +157 -0
  4. package/src/__tests__/classname.test.ts +332 -0
  5. package/src/__tests__/color.test.ts +169 -0
  6. package/src/__tests__/dom-mirror.test.ts +682 -0
  7. package/src/__tests__/dom-shim.test.ts +274 -0
  8. package/src/__tests__/fixtures/SvelteCounter.svelte +7 -0
  9. package/src/__tests__/fixtures/SvelteInput.svelte +8 -0
  10. package/src/__tests__/host-config.test.ts +51 -0
  11. package/src/__tests__/host-ops.test.ts +2234 -0
  12. package/src/__tests__/image-source.test.ts +135 -0
  13. package/src/__tests__/liquid-glass.test.ts +72 -0
  14. package/src/__tests__/multi-root.test.ts +118 -0
  15. package/src/__tests__/native-view-events.test.ts +102 -0
  16. package/src/__tests__/nodes.test.ts +399 -0
  17. package/src/__tests__/normalize.test.ts +576 -0
  18. package/src/__tests__/paragraph-lowering.test.tsx +144 -0
  19. package/src/__tests__/props.test.ts +518 -0
  20. package/src/__tests__/protocol-encoder.test.ts +732 -0
  21. package/src/__tests__/protocol-fixture-bytes.test.ts +41 -0
  22. package/src/__tests__/reconciler.test.tsx +241 -0
  23. package/src/__tests__/svelte-adapter.test.ts +166 -0
  24. package/src/__tests__/svg-source.test.ts +71 -0
  25. package/src/__tests__/tags.test.ts +354 -0
  26. package/src/__tests__/toggle.test.ts +441 -0
  27. package/src/__tests__/transitions.test.ts +106 -0
  28. package/src/__tests__/web-primitives.test.tsx +454 -0
  29. package/src/__tests__/window-hooks.test.tsx +447 -0
  30. package/src/adapter-contract.ts +68 -0
  31. package/src/attrs.ts +596 -0
  32. package/src/classname-contract.ts +87 -0
  33. package/src/classname-resolve.ts +553 -0
  34. package/src/classname-runtime.ts +29 -0
  35. package/src/components.ts +214 -0
  36. package/src/css-variable-context.ts +83 -0
  37. package/src/dom-hydration.ts +160 -0
  38. package/src/dom-mirror.ts +1459 -0
  39. package/src/dom-shim.ts +1736 -0
  40. package/src/group-context.ts +69 -0
  41. package/src/host-config.ts +431 -0
  42. package/src/host-ops.ts +3167 -0
  43. package/src/image-source.native.ts +703 -0
  44. package/src/image-source.ts +554 -0
  45. package/src/index.ts +278 -0
  46. package/src/inspector-runtime.ts +244 -0
  47. package/src/inspector.ts +3570 -0
  48. package/src/jsx-augmentations.ts +54 -0
  49. package/src/keyboard-avoidance.ts +217 -0
  50. package/src/native-primitives.ts +43 -0
  51. package/src/native-view-events.ts +322 -0
  52. package/src/native-view.ts +60 -0
  53. package/src/nodes/index.ts +41 -0
  54. package/src/nodes/node.ts +531 -0
  55. package/src/peer-context.ts +100 -0
  56. package/src/primitives.native.ts +8 -0
  57. package/src/primitives.ts +8 -0
  58. package/src/props/index.ts +14 -0
  59. package/src/props/normalize.ts +816 -0
  60. package/src/protocol/encoder.ts +940 -0
  61. package/src/protocol/index.ts +33 -0
  62. package/src/reconciler.ts +581 -0
  63. package/src/runtime.ts +11 -0
  64. package/src/safe-area.ts +543 -0
  65. package/src/solid.ts +490 -0
  66. package/src/style/color.js +1 -0
  67. package/src/style/color.ts +15 -0
  68. package/src/style/index.js +1 -0
  69. package/src/style/index.ts +22 -0
  70. package/src/style/normalize.js +1 -0
  71. package/src/style/normalize.ts +1426 -0
  72. package/src/svelte.ts +349 -0
  73. package/src/svg-source.ts +222 -0
  74. package/src/tags/index.ts +21 -0
  75. package/src/tags/tag-map.ts +289 -0
  76. package/src/text/paragraph-lowering.ts +310 -0
  77. package/src/types.ts +1175 -0
  78. package/src/vue.ts +535 -0
  79. package/src/web-host.ts +19 -0
  80. package/src/web-primitives.ts +1654 -0
@@ -0,0 +1,144 @@
1
+ import React from 'react';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { Text } from '../components.js';
5
+ import { hostConfig } from '../host-config.js';
6
+ import { createInstance } from '../host-ops.js';
7
+ import { lowerTextPropsToParagraphSpec } from '../text/paragraph-lowering.js';
8
+
9
+ describe('lowerTextPropsToParagraphSpec', () => {
10
+ it('flattens nested spans that do not change text style', () => {
11
+ const props = {
12
+ style: { fontSize: 18, color: '#221a14' },
13
+ children: [
14
+ 'Hello ',
15
+ React.createElement(Text, null, 'world'),
16
+ '!',
17
+ ],
18
+ };
19
+
20
+ expect(lowerTextPropsToParagraphSpec(props)?.spec.spans).toEqual([
21
+ {
22
+ type: 'text',
23
+ text: 'Hello world!',
24
+ style: {
25
+ color: '#221a14',
26
+ fontSize: 18,
27
+ },
28
+ },
29
+ ]);
30
+ });
31
+
32
+ it('resolves inherited inline text styles across nested Text spans', () => {
33
+ const props = {
34
+ style: { fontSize: 18, color: '#221a14' },
35
+ children: [
36
+ 'Hello ',
37
+ React.createElement(
38
+ Text,
39
+ { style: { fontWeight: '700' } },
40
+ 'bold ',
41
+ React.createElement(Text, { style: { color: '#b4542d' } }, 'accent'),
42
+ ),
43
+ ' again.',
44
+ ],
45
+ };
46
+
47
+ expect(lowerTextPropsToParagraphSpec(props)?.spec.spans).toEqual([
48
+ {
49
+ type: 'text',
50
+ text: 'Hello ',
51
+ style: {
52
+ color: '#221a14',
53
+ fontSize: 18,
54
+ },
55
+ },
56
+ {
57
+ type: 'text',
58
+ text: 'bold ',
59
+ style: {
60
+ color: '#221a14',
61
+ fontSize: 18,
62
+ fontWeight: '700',
63
+ },
64
+ },
65
+ {
66
+ type: 'text',
67
+ text: 'accent',
68
+ style: {
69
+ color: '#b4542d',
70
+ fontSize: 18,
71
+ fontWeight: '700',
72
+ },
73
+ },
74
+ {
75
+ type: 'text',
76
+ text: ' again.',
77
+ style: {
78
+ color: '#221a14',
79
+ fontSize: 18,
80
+ },
81
+ },
82
+ ]);
83
+ });
84
+ });
85
+
86
+ describe('text paragraph lowering integration', () => {
87
+ it('stores the lowered paragraph on text instances', () => {
88
+ const props = {
89
+ style: { fontSize: 16, color: '#112233' },
90
+ children: [
91
+ 'Release ',
92
+ React.createElement(Text, { style: { fontWeight: '700' } }, 'helper'),
93
+ ],
94
+ };
95
+
96
+ const instance = createInstance('Text', props);
97
+ const lowered = lowerTextPropsToParagraphSpec(props);
98
+
99
+ expect(instance.paragraphKey).toBe(lowered?.key ?? null);
100
+ expect(instance.paragraphSpec).toEqual(lowered?.spec ?? null);
101
+ expect(instance.props.textContent).toBe('Release helper');
102
+ });
103
+
104
+ it('treats nested span style changes as a parent text update', () => {
105
+ const oldProps = {
106
+ style: { fontSize: 16, color: '#112233' },
107
+ children: [
108
+ 'Release ',
109
+ React.createElement(Text, { style: { fontWeight: '700' } }, 'helper'),
110
+ ],
111
+ };
112
+ const newProps = {
113
+ style: { fontSize: 16, color: '#112233' },
114
+ children: [
115
+ 'Release ',
116
+ React.createElement(Text, { style: { fontWeight: '700', color: '#b4542d' } }, 'helper'),
117
+ ],
118
+ };
119
+ const instance = createInstance('Text', oldProps);
120
+
121
+ // react-reconciler 0.33: commitUpdate(instance, type, oldProps, newProps, fiber)
122
+ hostConfig.commitUpdate(instance, 'Text', oldProps, newProps, {} as never);
123
+
124
+ expect(instance.paragraphSpec?.spans).toEqual([
125
+ {
126
+ type: 'text',
127
+ text: 'Release ',
128
+ style: {
129
+ color: '#112233',
130
+ fontSize: 16,
131
+ },
132
+ },
133
+ {
134
+ type: 'text',
135
+ text: 'helper',
136
+ style: {
137
+ color: '#b4542d',
138
+ fontSize: 16,
139
+ fontWeight: '700',
140
+ },
141
+ },
142
+ ]);
143
+ });
144
+ });
@@ -0,0 +1,518 @@
1
+ /**
2
+ * Prop Normalization Tests
3
+ *
4
+ * Tests for the prop normalization utilities.
5
+ */
6
+
7
+ import React from 'react';
8
+ import { describe, it, expect } from 'vitest';
9
+ import { asset } from '../../../exact-core/src/assets-fonts-state.js';
10
+ import {
11
+ extractTextContent,
12
+ extractWebImageSource,
13
+ extractRNImageSource,
14
+ extractResizeMode,
15
+ normalizeProps,
16
+ propsEqual,
17
+ } from '../props/normalize.js';
18
+
19
+ describe('extractTextContent', () => {
20
+ it('should extract string content', () => {
21
+ expect(extractTextContent('Hello')).toBe('Hello');
22
+ });
23
+
24
+ it('should convert numbers to strings', () => {
25
+ expect(extractTextContent(42)).toBe('42');
26
+ expect(extractTextContent(3.14)).toBe('3.14');
27
+ });
28
+
29
+ it('should return undefined for booleans', () => {
30
+ expect(extractTextContent(true)).toBeUndefined();
31
+ expect(extractTextContent(false)).toBeUndefined();
32
+ });
33
+
34
+ it('should return undefined for null/undefined', () => {
35
+ expect(extractTextContent(null)).toBeUndefined();
36
+ expect(extractTextContent(undefined)).toBeUndefined();
37
+ });
38
+
39
+ it('should concatenate array children', () => {
40
+ expect(extractTextContent(['Hello', ' ', 'World'])).toBe('Hello World');
41
+ expect(extractTextContent(['Count: ', 42])).toBe('Count: 42');
42
+ });
43
+
44
+ it('should filter out non-text array children', () => {
45
+ expect(extractTextContent(['Hello', true, 'World'])).toBe('HelloWorld');
46
+ });
47
+
48
+ it('should return undefined for empty array', () => {
49
+ expect(extractTextContent([])).toBeUndefined();
50
+ });
51
+
52
+ it('should recurse through nested element children', () => {
53
+ expect(
54
+ extractTextContent([
55
+ 'Hello ',
56
+ React.createElement('span', null, 'world'),
57
+ '!',
58
+ ])
59
+ ).toBe('Hello world!');
60
+ });
61
+ });
62
+
63
+ describe('extractWebImageSource', () => {
64
+ it('should extract src prop', () => {
65
+ const props = { src: 'https://example.com/image.png' };
66
+ expect(extractWebImageSource(props)).toBe('https://example.com/image.png');
67
+ });
68
+
69
+ it('should return undefined for missing src', () => {
70
+ expect(extractWebImageSource({})).toBeUndefined();
71
+ });
72
+
73
+ it('should return undefined for empty src', () => {
74
+ expect(extractWebImageSource({ src: '' })).toBeUndefined();
75
+ });
76
+
77
+ it('should return undefined for non-string src', () => {
78
+ expect(extractWebImageSource({ src: 123 })).toBeUndefined();
79
+ });
80
+
81
+ it('should extract asset refs for native rendering paths', () => {
82
+ expect(extractWebImageSource({ src: asset('./images/logo.png') })).toBe('./images/logo.png');
83
+ });
84
+
85
+ it('should resolve theme-aware src props for web-style image nodes', () => {
86
+ expect(extractWebImageSource({
87
+ src: '/images/graph.png',
88
+ lightSrc: '/images/graph.light.png',
89
+ darkSrc: '/images/graph.dark.png',
90
+ })).toBe('/images/graph.light.png');
91
+ });
92
+ });
93
+
94
+ describe('extractRNImageSource', () => {
95
+ it('should extract string shorthand sources', () => {
96
+ const props = { source: 'https://example.com/image.png' };
97
+ expect(extractRNImageSource(props)).toBe('https://example.com/image.png');
98
+ });
99
+
100
+ it('should extract uri from source object', () => {
101
+ const props = { source: { uri: 'https://example.com/image.png' } };
102
+ expect(extractRNImageSource(props)).toBe('https://example.com/image.png');
103
+ });
104
+
105
+ it('should extract asset refs', () => {
106
+ const props = { source: asset('./images/logo.png') };
107
+ expect(extractRNImageSource(props)).toBe('./images/logo.png');
108
+ });
109
+
110
+ it('should return undefined for missing source', () => {
111
+ expect(extractRNImageSource({})).toBeUndefined();
112
+ });
113
+
114
+ it('should return undefined for empty uri', () => {
115
+ expect(extractRNImageSource({ source: { uri: '' } })).toBeUndefined();
116
+ });
117
+
118
+ it('should return undefined for numeric source (local asset)', () => {
119
+ // Local assets are not supported yet
120
+ expect(extractRNImageSource({ source: 42 })).toBeUndefined();
121
+ });
122
+ });
123
+
124
+ describe('extractResizeMode', () => {
125
+ describe('RN style', () => {
126
+ it('should extract resizeMode', () => {
127
+ expect(extractResizeMode({ resizeMode: 'cover' }, true)).toBe('cover');
128
+ expect(extractResizeMode({ resizeMode: 'contain' }, true)).toBe('contain');
129
+ expect(extractResizeMode({ resizeMode: 'stretch' }, true)).toBe('stretch');
130
+ expect(extractResizeMode({ resizeMode: 'center' }, true)).toBe('center');
131
+ });
132
+
133
+ it('should return undefined for invalid resizeMode', () => {
134
+ expect(extractResizeMode({ resizeMode: 'invalid' }, true)).toBeUndefined();
135
+ });
136
+
137
+ it('should accept objectFit on the Image component API', () => {
138
+ expect(extractResizeMode({ objectFit: 'contain' }, true)).toBe('contain');
139
+ });
140
+ });
141
+
142
+ describe('web style', () => {
143
+ it('should map objectFit to resizeMode', () => {
144
+ expect(extractResizeMode({ objectFit: 'cover' }, false)).toBe('cover');
145
+ expect(extractResizeMode({ objectFit: 'contain' }, false)).toBe('contain');
146
+ expect(extractResizeMode({ objectFit: 'fill' }, false)).toBe('stretch');
147
+ expect(extractResizeMode({ objectFit: 'none' }, false)).toBe('center');
148
+ expect(extractResizeMode({ objectFit: 'scale-down' }, false)).toBe('contain');
149
+ });
150
+ });
151
+ });
152
+
153
+ describe('normalizeProps', () => {
154
+ describe('text elements', () => {
155
+ it('should extract text content from children', () => {
156
+ const result = normalizeProps('text', { children: 'Hello' }, false);
157
+ expect(result.textContent).toBe('Hello');
158
+ });
159
+
160
+ it('should preserve explicit textContent when children are absent', () => {
161
+ const result = normalizeProps('text', { textContent: 'Hello' }, false);
162
+ expect(result.textContent).toBe('Hello');
163
+ });
164
+
165
+ it('should concatenate array children', () => {
166
+ const result = normalizeProps('text', { children: ['Hello', ' ', 'World'] }, false);
167
+ expect(result.textContent).toBe('Hello World');
168
+ });
169
+
170
+ it('should include nested element text in text content', () => {
171
+ const result = normalizeProps(
172
+ 'text',
173
+ {
174
+ children: [
175
+ 'Hello ',
176
+ React.createElement('span', null, 'world'),
177
+ '!',
178
+ ],
179
+ },
180
+ false,
181
+ );
182
+ expect(result.textContent).toBe('Hello world!');
183
+ });
184
+
185
+ it('should preserve selectable modes', () => {
186
+ expect(normalizeProps('text', { selectable: 'all' }, false).selectable).toBe('all');
187
+ expect(normalizeProps('text', { selectable: 'contain' }, false).selectable).toBe('contain');
188
+ expect(normalizeProps('text', { selectable: false }, false).selectable).toBe(false);
189
+ });
190
+ });
191
+
192
+ describe('shared selectable props', () => {
193
+ it('should normalize selectable on container elements', () => {
194
+ expect(normalizeProps('view', { selectable: false }, false).selectable).toBe(false);
195
+ });
196
+
197
+ it('normalizes non-empty lang props', () => {
198
+ expect(normalizeProps('view', { lang: ' ar-EG ' }, false).lang).toBe('ar-EG');
199
+ expect(normalizeProps('view', { lang: ' ' }, false).lang).toBeUndefined();
200
+ });
201
+
202
+ it('should preserve selectionCopyText including empty strings', () => {
203
+ expect(normalizeProps('view', { selectionCopyText: '' }, false).selectionCopyText).toBe('');
204
+ expect(normalizeProps('view', { selectionCopyText: '[Chart]' }, false).selectionCopyText).toBe('[Chart]');
205
+ });
206
+
207
+ it('normalizes controlled TextInput selection ranges', () => {
208
+ expect(
209
+ normalizeProps('input', {
210
+ selection: { start: 2, end: 5 },
211
+ }, false),
212
+ ).toMatchObject({
213
+ selectionStart: 2,
214
+ selectionEnd: 5,
215
+ });
216
+
217
+ expect(
218
+ normalizeProps('input', {
219
+ selection: { start: -1, end: 5 },
220
+ }, false).selectionStart,
221
+ ).toBeUndefined();
222
+ });
223
+ });
224
+
225
+ describe('image elements', () => {
226
+ it('should extract web image source', () => {
227
+ const result = normalizeProps('image', { src: 'https://example.com/img.png' }, false);
228
+ expect(result.imageSource).toBe('https://example.com/img.png');
229
+ });
230
+
231
+ it('should extract RN image source', () => {
232
+ const result = normalizeProps('image', { source: { uri: 'https://example.com/img.png' } }, true);
233
+ expect(result.imageSource).toBe('https://example.com/img.png');
234
+ });
235
+
236
+ it('should extract string shorthand Image sources', () => {
237
+ const result = normalizeProps('image', { source: 'https://example.com/img.png' }, true);
238
+ expect(result.imageSource).toBe('https://example.com/img.png');
239
+ });
240
+
241
+ it('should resolve an inline svgSource glyph to a data URI for native', () => {
242
+ const markup = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/></svg>';
243
+ const result = normalizeProps('image', { svgSource: markup, tintColor: '#5b6cf5' }, false);
244
+ expect(result.imageSource).toBe(`data:image/svg+xml,${encodeURIComponent(markup)}`);
245
+ expect(result.tintColor).toBe('#5b6cf5');
246
+ });
247
+
248
+ it('should let a raster src win over an inline svgSource', () => {
249
+ const result = normalizeProps(
250
+ 'image',
251
+ { src: 'https://example.com/img.png', svgSource: '<svg/>' },
252
+ false,
253
+ );
254
+ expect(result.imageSource).toBe('https://example.com/img.png');
255
+ });
256
+
257
+ it('should extract resize mode', () => {
258
+ const webResult = normalizeProps('image', { objectFit: 'cover' }, false);
259
+ expect(webResult.resizeMode).toBe('cover');
260
+
261
+ const rnResult = normalizeProps('image', { resizeMode: 'contain' }, true);
262
+ expect(rnResult.resizeMode).toBe('contain');
263
+ });
264
+
265
+ it('should map objectFit on the Image component to resizeMode', () => {
266
+ const result = normalizeProps('image', { objectFit: 'scale-down' }, true);
267
+ expect(result.resizeMode).toBe('contain');
268
+ });
269
+
270
+ it('should normalize color placeholders for native image props', () => {
271
+ const result = normalizeProps('image', {
272
+ source: 'https://example.com/img.png',
273
+ placeholder: { color: '#123456' },
274
+ }, true);
275
+
276
+ expect(result.placeholder).toBe('color:#123456');
277
+ });
278
+
279
+ it('should normalize ThumbHash placeholders for native image props', () => {
280
+ const result = normalizeProps('image', {
281
+ source: 'https://example.com/img.png',
282
+ placeholder: { thumbhash: '3OcRJYB4d3h/iIeHeEh3eIhw+j3A' },
283
+ }, true);
284
+
285
+ expect(result.placeholder).toMatch(/^data:image\/png;base64,/);
286
+ });
287
+
288
+ it('should normalize image-source placeholders for native image props', () => {
289
+ const result = normalizeProps('image', {
290
+ source: 'https://example.com/img.png',
291
+ placeholder: asset('./images/preview.png'),
292
+ }, true);
293
+
294
+ expect(result.placeholder).toBe('./images/preview.png');
295
+ });
296
+
297
+ it('should treat decorative images as intentionally unlabeled', () => {
298
+ const result = normalizeProps('image', { decorative: true, alt: 'ignored' }, true);
299
+ expect(result.accessibilityLabel).toBe('');
300
+ });
301
+ });
302
+
303
+ describe('svg elements', () => {
304
+ it('should extract SVG-specific props', () => {
305
+ const result = normalizeProps('svg', {
306
+ source: asset('./icons/logo.svg'),
307
+ objectFit: 'contain',
308
+ objectPosition: 'left top',
309
+ tintColor: '#ff3b30',
310
+ pixelDensity: 2,
311
+ colors: {
312
+ '--primary': '#007aff',
313
+ fill: '#000000',
314
+ },
315
+ alt: 'Exact logo',
316
+ }, true);
317
+
318
+ expect(result.svgSource).toBe('./icons/logo.svg');
319
+ expect(result.resizeMode).toBe('contain');
320
+ expect(result.svgObjectPosition).toBe('left top');
321
+ expect(result.tintColor).toBe('#ff3b30');
322
+ expect(result.svgPixelDensity).toBe('2');
323
+ expect(result.svgColors).toBe(JSON.stringify({ '--primary': '#007aff' }));
324
+ expect(result.accessibilityLabel).toBe('Exact logo');
325
+ });
326
+
327
+ it('should omit invalid SVG color overrides', () => {
328
+ const result = normalizeProps('svg', {
329
+ source: '<svg viewBox="0 0 24 24"></svg>',
330
+ colors: {
331
+ fill: '#111111',
332
+ },
333
+ }, true);
334
+
335
+ expect(result.svgColors).toBeUndefined();
336
+ });
337
+ });
338
+
339
+ describe('input elements', () => {
340
+ it('should extract value', () => {
341
+ const result = normalizeProps('input', { value: 'hello' }, false);
342
+ expect(result.value).toBe('hello');
343
+ });
344
+
345
+ it('should extract defaultValue when value is not present', () => {
346
+ const result = normalizeProps('input', { defaultValue: 'default' }, false);
347
+ expect(result.value).toBe('default');
348
+ });
349
+
350
+ it('should prefer value over defaultValue', () => {
351
+ const result = normalizeProps('input', { value: 'value', defaultValue: 'default' }, false);
352
+ expect(result.value).toBe('value');
353
+ });
354
+
355
+ it('should extract placeholder', () => {
356
+ const result = normalizeProps('input', { placeholder: 'Enter text...' }, false);
357
+ expect(result.placeholder).toBe('Enter text...');
358
+ });
359
+
360
+ it('should extract input-specific props', () => {
361
+ const result = normalizeProps('input', {
362
+ editable: false,
363
+ multiline: true,
364
+ maxLength: 100,
365
+ secureTextEntry: true,
366
+ }, false);
367
+
368
+ expect(result.editable).toBe(false);
369
+ expect(result.multiline).toBe(true);
370
+ expect(result.maxLength).toBe(100);
371
+ expect(result.secureTextEntry).toBe(true);
372
+ });
373
+ });
374
+
375
+ describe('scroll elements', () => {
376
+ it('should extract horizontal', () => {
377
+ const result = normalizeProps('scroll', { horizontal: true }, false);
378
+ expect(result.horizontal).toBe(true);
379
+ });
380
+
381
+ it('should extract scroll indicator visibility', () => {
382
+ const result = normalizeProps('scroll', {
383
+ showsVerticalScrollIndicator: false,
384
+ showsHorizontalScrollIndicator: false,
385
+ }, false);
386
+ expect(result.showsScrollIndicator).toBe(false);
387
+ });
388
+ });
389
+
390
+ describe('common props', () => {
391
+ it('should extract accessibilityLabel', () => {
392
+ const result = normalizeProps('view', { accessibilityLabel: 'Close button' }, false);
393
+ expect(result.accessibilityLabel).toBe('Close button');
394
+ });
395
+
396
+ it('should use alt as accessibilityLabel for images', () => {
397
+ const result = normalizeProps('image', { alt: 'A cat' }, false);
398
+ expect(result.accessibilityLabel).toBe('A cat');
399
+ });
400
+
401
+ it('should preserve Exact interaction metadata for inspector serialization', () => {
402
+ const result = normalizeProps('button', { __exactInteractionState: 'idle' }, false);
403
+ expect(result.__exactInteractionState).toBe('idle');
404
+ });
405
+
406
+ it('preserves Contract overlay metadata for inspector serialization', () => {
407
+ const result = normalizeProps(
408
+ 'view',
409
+ {
410
+ __exactPresencePhase: 'entered',
411
+ __exactDismissableLayer: true,
412
+ __exactDismissAction: 'close',
413
+ __exactFocusRestore: true,
414
+ __exactAnchorTarget: 'trigger',
415
+ __exactAnchorPlacement: 'bottom-start',
416
+ __exactAnchorStrategy: 'fixed',
417
+ __exactAnchorOffset: 8,
418
+ },
419
+ false,
420
+ );
421
+
422
+ expect(result.__exactPresencePhase).toBe('entered');
423
+ expect(result.__exactDismissableLayer).toBe(true);
424
+ expect(result.__exactDismissAction).toBe('close');
425
+ expect(result.__exactFocusRestore).toBe(true);
426
+ expect(result.__exactAnchorTarget).toBe('trigger');
427
+ expect(result.__exactAnchorPlacement).toBe('bottom-start');
428
+ expect(result.__exactAnchorStrategy).toBe('fixed');
429
+ expect(result.__exactAnchorOffset).toBe(8);
430
+ });
431
+
432
+ it('should serialize renderer-local agent semantics metadata', () => {
433
+ const result = normalizeProps(
434
+ 'view',
435
+ {
436
+ agentSemantics: {
437
+ kind: 'navigation',
438
+ label: 'Profile',
439
+ to: '/profile',
440
+ disabledReason: 'Sign in first',
441
+ },
442
+ },
443
+ false,
444
+ );
445
+ expect(result.agentSemantics).toBe(
446
+ JSON.stringify({
447
+ kind: 'navigation',
448
+ label: 'Profile',
449
+ to: '/profile',
450
+ disabledReason: 'Sign in first',
451
+ }),
452
+ );
453
+ });
454
+
455
+ it('should normalize test ids, accessibility actions, and text scaling props', () => {
456
+ const result = normalizeProps(
457
+ 'text',
458
+ {
459
+ testID: 'hero-title',
460
+ accessibilityActions: [
461
+ { name: 'archive', label: 'Archive item' },
462
+ { name: 'share' },
463
+ ],
464
+ accessibilityOrder: ['price', 'title'],
465
+ tabIndex: 2,
466
+ allowFontScaling: true,
467
+ maxFontSizeMultiplier: 1.8,
468
+ minimumFontSize: 14,
469
+ accessibilityHeadingLevel: 2,
470
+ },
471
+ false,
472
+ );
473
+
474
+ expect(result.testId).toBe('hero-title');
475
+ expect(result.accessibilityActions).toBe(
476
+ JSON.stringify([
477
+ { name: 'archive', label: 'Archive item' },
478
+ { name: 'share', label: undefined },
479
+ ]),
480
+ );
481
+ expect(result.accessibilityOrder).toBe(JSON.stringify(['price', 'title']));
482
+ expect(result.tabIndex).toBe(2);
483
+ expect(result.allowFontScaling).toBe(true);
484
+ expect(result.maxFontSizeMultiplier).toBe(1.8);
485
+ expect(result.minimumFontSize).toBe(14);
486
+ expect(result.accessibilityHeadingLevel).toBe(2);
487
+ });
488
+ });
489
+ });
490
+
491
+ describe('propsEqual', () => {
492
+ it('should return true for identical objects', () => {
493
+ const props = { textContent: 'Hello' };
494
+ expect(propsEqual(props, props)).toBe(true);
495
+ });
496
+
497
+ it('should return true for equal props', () => {
498
+ const a = { textContent: 'Hello', imageSource: 'test.png' };
499
+ const b = { textContent: 'Hello', imageSource: 'test.png' };
500
+ expect(propsEqual(a, b)).toBe(true);
501
+ });
502
+
503
+ it('should return false for different props', () => {
504
+ const a = { textContent: 'Hello' };
505
+ const b = { textContent: 'World' };
506
+ expect(propsEqual(a, b)).toBe(false);
507
+ });
508
+
509
+ it('should return false for different key counts', () => {
510
+ const a = { textContent: 'Hello' };
511
+ const b = { textContent: 'Hello', imageSource: 'test.png' };
512
+ expect(propsEqual(a, b)).toBe(false);
513
+ });
514
+
515
+ it('should return true for empty props', () => {
516
+ expect(propsEqual({}, {})).toBe(true);
517
+ });
518
+ });