@easyops-cn/a2ui-react 0.0.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 (161) hide show
  1. package/.claude/commands/speckit.analyze.md +184 -0
  2. package/.claude/commands/speckit.checklist.md +294 -0
  3. package/.claude/commands/speckit.clarify.md +181 -0
  4. package/.claude/commands/speckit.constitution.md +82 -0
  5. package/.claude/commands/speckit.implement.md +135 -0
  6. package/.claude/commands/speckit.plan.md +89 -0
  7. package/.claude/commands/speckit.specify.md +256 -0
  8. package/.claude/commands/speckit.tasks.md +137 -0
  9. package/.claude/commands/speckit.taskstoissues.md +30 -0
  10. package/.github/workflows/deploy.yml +69 -0
  11. package/.husky/pre-commit +1 -0
  12. package/.prettierignore +4 -0
  13. package/.prettierrc +7 -0
  14. package/.specify/memory/constitution.md +73 -0
  15. package/.specify/scripts/bash/check-prerequisites.sh +166 -0
  16. package/.specify/scripts/bash/common.sh +156 -0
  17. package/.specify/scripts/bash/create-new-feature.sh +297 -0
  18. package/.specify/scripts/bash/setup-plan.sh +61 -0
  19. package/.specify/scripts/bash/update-agent-context.sh +799 -0
  20. package/.specify/templates/agent-file-template.md +28 -0
  21. package/.specify/templates/checklist-template.md +40 -0
  22. package/.specify/templates/plan-template.md +105 -0
  23. package/.specify/templates/spec-template.md +115 -0
  24. package/.specify/templates/tasks-template.md +250 -0
  25. package/CLAUDE.md +105 -0
  26. package/CONTRIBUTING.md +97 -0
  27. package/README.md +126 -0
  28. package/components.json +21 -0
  29. package/eslint.config.js +25 -0
  30. package/netlify.toml +50 -0
  31. package/package.json +94 -0
  32. package/playground/README.md +75 -0
  33. package/playground/index.html +22 -0
  34. package/playground/package.json +32 -0
  35. package/playground/public/favicon.svg +8 -0
  36. package/playground/src/App.css +256 -0
  37. package/playground/src/App.tsx +115 -0
  38. package/playground/src/assets/react.svg +1 -0
  39. package/playground/src/components/ErrorDisplay.tsx +13 -0
  40. package/playground/src/components/ExampleSelector.tsx +64 -0
  41. package/playground/src/components/Header.tsx +47 -0
  42. package/playground/src/components/JsonEditor.tsx +32 -0
  43. package/playground/src/components/Preview.tsx +78 -0
  44. package/playground/src/components/ThemeToggle.tsx +19 -0
  45. package/playground/src/data/examples.ts +1571 -0
  46. package/playground/src/hooks/useTheme.ts +55 -0
  47. package/playground/src/index.css +220 -0
  48. package/playground/src/main.tsx +10 -0
  49. package/playground/tsconfig.app.json +34 -0
  50. package/playground/tsconfig.json +13 -0
  51. package/playground/tsconfig.node.json +26 -0
  52. package/playground/vite.config.ts +31 -0
  53. package/specs/001-a2ui-renderer/checklists/requirements.md +41 -0
  54. package/specs/001-a2ui-renderer/data-model.md +140 -0
  55. package/specs/001-a2ui-renderer/plan.md +123 -0
  56. package/specs/001-a2ui-renderer/quickstart.md +141 -0
  57. package/specs/001-a2ui-renderer/research.md +140 -0
  58. package/specs/001-a2ui-renderer/spec.md +165 -0
  59. package/specs/001-a2ui-renderer/tasks.md +310 -0
  60. package/specs/002-playground/checklists/requirements.md +37 -0
  61. package/specs/002-playground/contracts/components.md +120 -0
  62. package/specs/002-playground/data-model.md +149 -0
  63. package/specs/002-playground/plan.md +73 -0
  64. package/specs/002-playground/quickstart.md +158 -0
  65. package/specs/002-playground/research.md +117 -0
  66. package/specs/002-playground/spec.md +109 -0
  67. package/specs/002-playground/tasks.md +224 -0
  68. package/src/0.8/A2UIRender.test.tsx +793 -0
  69. package/src/0.8/A2UIRender.tsx +142 -0
  70. package/src/0.8/components/ComponentRenderer.test.tsx +373 -0
  71. package/src/0.8/components/ComponentRenderer.tsx +163 -0
  72. package/src/0.8/components/UnknownComponent.tsx +49 -0
  73. package/src/0.8/components/display/AudioPlayerComponent.tsx +37 -0
  74. package/src/0.8/components/display/DividerComponent.tsx +23 -0
  75. package/src/0.8/components/display/IconComponent.tsx +137 -0
  76. package/src/0.8/components/display/ImageComponent.tsx +57 -0
  77. package/src/0.8/components/display/TextComponent.tsx +56 -0
  78. package/src/0.8/components/display/VideoComponent.tsx +31 -0
  79. package/src/0.8/components/display/display.test.tsx +660 -0
  80. package/src/0.8/components/display/index.ts +10 -0
  81. package/src/0.8/components/index.ts +14 -0
  82. package/src/0.8/components/interactive/ButtonComponent.tsx +44 -0
  83. package/src/0.8/components/interactive/CheckBoxComponent.tsx +45 -0
  84. package/src/0.8/components/interactive/DateTimeInputComponent.tsx +176 -0
  85. package/src/0.8/components/interactive/MultipleChoiceComponent.tsx +157 -0
  86. package/src/0.8/components/interactive/SliderComponent.tsx +53 -0
  87. package/src/0.8/components/interactive/TextFieldComponent.tsx +65 -0
  88. package/src/0.8/components/interactive/index.ts +10 -0
  89. package/src/0.8/components/interactive/interactive.test.tsx +618 -0
  90. package/src/0.8/components/layout/CardComponent.tsx +30 -0
  91. package/src/0.8/components/layout/ColumnComponent.tsx +93 -0
  92. package/src/0.8/components/layout/ListComponent.tsx +81 -0
  93. package/src/0.8/components/layout/ModalComponent.tsx +41 -0
  94. package/src/0.8/components/layout/RowComponent.tsx +94 -0
  95. package/src/0.8/components/layout/TabsComponent.tsx +59 -0
  96. package/src/0.8/components/layout/index.ts +10 -0
  97. package/src/0.8/components/layout/layout.test.tsx +558 -0
  98. package/src/0.8/contexts/A2UIProvider.test.tsx +226 -0
  99. package/src/0.8/contexts/A2UIProvider.tsx +54 -0
  100. package/src/0.8/contexts/ActionContext.test.tsx +242 -0
  101. package/src/0.8/contexts/ActionContext.tsx +105 -0
  102. package/src/0.8/contexts/ComponentsMapContext.tsx +125 -0
  103. package/src/0.8/contexts/DataModelContext.test.tsx +335 -0
  104. package/src/0.8/contexts/DataModelContext.tsx +184 -0
  105. package/src/0.8/contexts/SurfaceContext.test.tsx +339 -0
  106. package/src/0.8/contexts/SurfaceContext.tsx +197 -0
  107. package/src/0.8/hooks/useA2UIMessageHandler.test.tsx +399 -0
  108. package/src/0.8/hooks/useA2UIMessageHandler.ts +123 -0
  109. package/src/0.8/hooks/useComponent.test.tsx +148 -0
  110. package/src/0.8/hooks/useComponent.ts +39 -0
  111. package/src/0.8/hooks/useDataBinding.test.tsx +334 -0
  112. package/src/0.8/hooks/useDataBinding.ts +99 -0
  113. package/src/0.8/hooks/useDispatchAction.test.tsx +83 -0
  114. package/src/0.8/hooks/useDispatchAction.ts +35 -0
  115. package/src/0.8/hooks/useSurface.test.tsx +114 -0
  116. package/src/0.8/hooks/useSurface.ts +34 -0
  117. package/src/0.8/index.ts +38 -0
  118. package/src/0.8/schemas/client_to_server.json +50 -0
  119. package/src/0.8/schemas/server_to_client.json +148 -0
  120. package/src/0.8/schemas/standard_catalog_definition.json +661 -0
  121. package/src/0.8/types/index.ts +448 -0
  122. package/src/0.8/utils/dataBinding.test.ts +443 -0
  123. package/src/0.8/utils/dataBinding.ts +212 -0
  124. package/src/0.8/utils/pathUtils.test.ts +353 -0
  125. package/src/0.8/utils/pathUtils.ts +200 -0
  126. package/src/components/ui/button.tsx +62 -0
  127. package/src/components/ui/calendar.tsx +220 -0
  128. package/src/components/ui/card.tsx +92 -0
  129. package/src/components/ui/checkbox.tsx +30 -0
  130. package/src/components/ui/dialog.tsx +141 -0
  131. package/src/components/ui/input.tsx +21 -0
  132. package/src/components/ui/label.tsx +22 -0
  133. package/src/components/ui/native-select.tsx +53 -0
  134. package/src/components/ui/popover.tsx +46 -0
  135. package/src/components/ui/select.tsx +188 -0
  136. package/src/components/ui/separator.tsx +26 -0
  137. package/src/components/ui/slider.tsx +61 -0
  138. package/src/components/ui/tabs.tsx +64 -0
  139. package/src/components/ui/textarea.tsx +18 -0
  140. package/src/index.ts +1 -0
  141. package/src/lib/utils.ts +6 -0
  142. package/tsconfig.json +28 -0
  143. package/vite.config.ts +29 -0
  144. package/vitest.config.ts +22 -0
  145. package/vitest.setup.ts +8 -0
  146. package/website/README.md +4 -0
  147. package/website/assets/favicon.svg +8 -0
  148. package/website/content/.gitkeep +0 -0
  149. package/website/content/index.md +122 -0
  150. package/website/global.d.ts +9 -0
  151. package/website/package.json +17 -0
  152. package/website/plain.config.js +28 -0
  153. package/website/serve.json +6 -0
  154. package/website/src/client/color-mode-switch.css +47 -0
  155. package/website/src/client/index.js +61 -0
  156. package/website/src/client/moon.svg +1 -0
  157. package/website/src/client/sun.svg +1 -0
  158. package/website/src/components/Footer.jsx +9 -0
  159. package/website/src/components/Header.jsx +44 -0
  160. package/website/src/components/Page.jsx +28 -0
  161. package/website/src/global.css +423 -0
@@ -0,0 +1,793 @@
1
+ /**
2
+ * A2UIRender Tests
3
+ *
4
+ * Tests for the main A2UIRender component.
5
+ */
6
+
7
+ import { describe, it, expect, vi } from 'vitest'
8
+ import { render, screen } from '@testing-library/react'
9
+ import userEvent from '@testing-library/user-event'
10
+ import { A2UIRender, type ComponentsMap } from './A2UIRender'
11
+ import type { A2UIMessage, BaseComponentProps, Action } from './types'
12
+ import { useDispatchAction } from './hooks/useDispatchAction'
13
+ import { useDataBinding, useFormBinding } from './hooks/useDataBinding'
14
+ import { ComponentRenderer } from './components/ComponentRenderer'
15
+
16
+ // Helper to create test messages
17
+ function createTestMessages(options: {
18
+ surfaceId?: string
19
+ root?: string
20
+ components?: Array<{
21
+ id: string
22
+ type: string
23
+ props?: Record<string, unknown>
24
+ }>
25
+ dataModel?: Record<string, unknown>
26
+ }): A2UIMessage[] {
27
+ const {
28
+ surfaceId = 'test-surface',
29
+ root = 'root-component',
30
+ components = [],
31
+ dataModel,
32
+ } = options
33
+
34
+ const messages: A2UIMessage[] = []
35
+
36
+ // Begin rendering message
37
+ messages.push({
38
+ beginRendering: {
39
+ surfaceId,
40
+ root,
41
+ },
42
+ })
43
+
44
+ // Surface update with components
45
+ if (components.length > 0) {
46
+ messages.push({
47
+ surfaceUpdate: {
48
+ surfaceId,
49
+ components: components.map((c) => ({
50
+ id: c.id,
51
+ component: { [c.type]: c.props ?? {} },
52
+ })),
53
+ },
54
+ })
55
+ }
56
+
57
+ // Data model update
58
+ if (dataModel) {
59
+ messages.push({
60
+ dataModelUpdate: {
61
+ surfaceId,
62
+ path: '/',
63
+ contents: Object.entries(dataModel).map(([key, value]) => {
64
+ if (typeof value === 'string') {
65
+ return { key, valueString: value }
66
+ }
67
+ if (typeof value === 'number') {
68
+ return { key, valueNumber: value }
69
+ }
70
+ if (typeof value === 'boolean') {
71
+ return { key, valueBoolean: value }
72
+ }
73
+ return { key, valueString: String(value) }
74
+ }),
75
+ },
76
+ })
77
+ }
78
+
79
+ return messages
80
+ }
81
+
82
+ describe('A2UIRender', () => {
83
+ describe('Phase 3: User Story 1 - Basic Message Rendering', () => {
84
+ it('T014: renders nothing for empty messages array', () => {
85
+ const { container } = render(<A2UIRender messages={[]} />)
86
+ expect(container.firstChild).toBeNull()
87
+ })
88
+
89
+ it('T014b: renders nothing for null/undefined messages', () => {
90
+ // @ts-expect-error - testing null handling
91
+ const { container: c1 } = render(<A2UIRender messages={null} />)
92
+ expect(c1.firstChild).toBeNull()
93
+
94
+ // @ts-expect-error - testing undefined handling
95
+ const { container: c2 } = render(<A2UIRender messages={undefined} />)
96
+ expect(c2.firstChild).toBeNull()
97
+ })
98
+
99
+ it('T015: renders components from valid A2UIMessage objects', () => {
100
+ const messages = createTestMessages({
101
+ surfaceId: 'surface-1',
102
+ root: 'text-1',
103
+ components: [
104
+ {
105
+ id: 'text-1',
106
+ type: 'Text',
107
+ props: { text: { literalString: 'Hello World' } },
108
+ },
109
+ ],
110
+ })
111
+
112
+ render(<A2UIRender messages={messages} />)
113
+
114
+ expect(screen.getByText('Hello World')).toBeInTheDocument()
115
+ })
116
+
117
+ it('T016: renders nested components correctly', () => {
118
+ const messages = createTestMessages({
119
+ surfaceId: 'surface-1',
120
+ root: 'column-1',
121
+ components: [
122
+ {
123
+ id: 'column-1',
124
+ type: 'Column',
125
+ props: { children: { explicitList: ['text-1', 'text-2'] } },
126
+ },
127
+ {
128
+ id: 'text-1',
129
+ type: 'Text',
130
+ props: { text: { literalString: 'First' } },
131
+ },
132
+ {
133
+ id: 'text-2',
134
+ type: 'Text',
135
+ props: { text: { literalString: 'Second' } },
136
+ },
137
+ ],
138
+ })
139
+
140
+ render(<A2UIRender messages={messages} />)
141
+
142
+ expect(screen.getByText('First')).toBeInTheDocument()
143
+ expect(screen.getByText('Second')).toBeInTheDocument()
144
+ })
145
+
146
+ it('renders multiple surfaces', () => {
147
+ const messages: A2UIMessage[] = [
148
+ // Surface 1
149
+ { beginRendering: { surfaceId: 'surface-1', root: 'text-1' } },
150
+ {
151
+ surfaceUpdate: {
152
+ surfaceId: 'surface-1',
153
+ components: [
154
+ {
155
+ id: 'text-1',
156
+ component: { Text: { text: { literalString: 'Surface 1' } } },
157
+ },
158
+ ],
159
+ },
160
+ },
161
+ // Surface 2
162
+ { beginRendering: { surfaceId: 'surface-2', root: 'text-2' } },
163
+ {
164
+ surfaceUpdate: {
165
+ surfaceId: 'surface-2',
166
+ components: [
167
+ {
168
+ id: 'text-2',
169
+ component: { Text: { text: { literalString: 'Surface 2' } } },
170
+ },
171
+ ],
172
+ },
173
+ },
174
+ ]
175
+
176
+ render(<A2UIRender messages={messages} />)
177
+
178
+ expect(screen.getByText('Surface 1')).toBeInTheDocument()
179
+ expect(screen.getByText('Surface 2')).toBeInTheDocument()
180
+ })
181
+ })
182
+
183
+ describe('Phase 4: User Story 2 - Action Handling', () => {
184
+ it('T022: onAction callback invoked when button clicked', async () => {
185
+ const user = userEvent.setup()
186
+ const onAction = vi.fn()
187
+
188
+ const messages = createTestMessages({
189
+ surfaceId: 'surface-1',
190
+ root: 'button-1',
191
+ components: [
192
+ {
193
+ id: 'button-1',
194
+ type: 'Button',
195
+ props: {
196
+ child: 'text-1',
197
+ action: { name: 'submit' },
198
+ },
199
+ },
200
+ {
201
+ id: 'text-1',
202
+ type: 'Text',
203
+ props: { text: { literalString: 'Click Me' } },
204
+ },
205
+ ],
206
+ })
207
+
208
+ render(<A2UIRender messages={messages} onAction={onAction} />)
209
+
210
+ const button = screen.getByRole('button')
211
+ await user.click(button)
212
+
213
+ expect(onAction).toHaveBeenCalled()
214
+ })
215
+
216
+ it('T023: action payload contains surfaceId, componentId, and context', async () => {
217
+ const user = userEvent.setup()
218
+ const onAction = vi.fn()
219
+
220
+ const messages = createTestMessages({
221
+ surfaceId: 'my-surface',
222
+ root: 'button-1',
223
+ components: [
224
+ {
225
+ id: 'button-1',
226
+ type: 'Button',
227
+ props: {
228
+ child: 'text-1',
229
+ action: {
230
+ name: 'submit',
231
+ context: [
232
+ { key: 'value', value: { literalString: 'test-value' } },
233
+ ],
234
+ },
235
+ },
236
+ },
237
+ {
238
+ id: 'text-1',
239
+ type: 'Text',
240
+ props: { text: { literalString: 'Submit' } },
241
+ },
242
+ ],
243
+ })
244
+
245
+ render(<A2UIRender messages={messages} onAction={onAction} />)
246
+
247
+ const button = screen.getByRole('button')
248
+ await user.click(button)
249
+
250
+ expect(onAction).toHaveBeenCalledWith(
251
+ expect.objectContaining({
252
+ surfaceId: 'my-surface',
253
+ sourceComponentId: 'button-1',
254
+ name: 'submit',
255
+ context: { value: 'test-value' },
256
+ })
257
+ )
258
+ })
259
+
260
+ it('T024: multiple components dispatch unique actions', async () => {
261
+ const user = userEvent.setup()
262
+ const onAction = vi.fn()
263
+
264
+ const messages = createTestMessages({
265
+ surfaceId: 'surface-1',
266
+ root: 'row-1',
267
+ components: [
268
+ {
269
+ id: 'row-1',
270
+ type: 'Row',
271
+ props: { children: { explicitList: ['button-1', 'button-2'] } },
272
+ },
273
+ {
274
+ id: 'button-1',
275
+ type: 'Button',
276
+ props: {
277
+ child: 'text-1',
278
+ action: { name: 'action-1' },
279
+ },
280
+ },
281
+ {
282
+ id: 'button-2',
283
+ type: 'Button',
284
+ props: {
285
+ child: 'text-2',
286
+ action: { name: 'action-2' },
287
+ },
288
+ },
289
+ {
290
+ id: 'text-1',
291
+ type: 'Text',
292
+ props: { text: { literalString: 'Button 1' } },
293
+ },
294
+ {
295
+ id: 'text-2',
296
+ type: 'Text',
297
+ props: { text: { literalString: 'Button 2' } },
298
+ },
299
+ ],
300
+ })
301
+
302
+ render(<A2UIRender messages={messages} onAction={onAction} />)
303
+
304
+ const buttons = screen.getAllByRole('button')
305
+ await user.click(buttons[0])
306
+ await user.click(buttons[1])
307
+
308
+ expect(onAction).toHaveBeenCalledTimes(2)
309
+ expect(onAction).toHaveBeenNthCalledWith(
310
+ 1,
311
+ expect.objectContaining({
312
+ name: 'action-1',
313
+ sourceComponentId: 'button-1',
314
+ })
315
+ )
316
+ expect(onAction).toHaveBeenNthCalledWith(
317
+ 2,
318
+ expect.objectContaining({
319
+ name: 'action-2',
320
+ sourceComponentId: 'button-2',
321
+ })
322
+ )
323
+ })
324
+
325
+ it('T027: no error when action dispatched without onAction callback', async () => {
326
+ const user = userEvent.setup()
327
+ const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
328
+
329
+ const messages = createTestMessages({
330
+ surfaceId: 'surface-1',
331
+ root: 'button-1',
332
+ components: [
333
+ {
334
+ id: 'button-1',
335
+ type: 'Button',
336
+ props: {
337
+ child: 'text-1',
338
+ action: { name: 'submit' },
339
+ },
340
+ },
341
+ {
342
+ id: 'text-1',
343
+ type: 'Text',
344
+ props: { text: { literalString: 'Click Me' } },
345
+ },
346
+ ],
347
+ })
348
+
349
+ // No onAction prop
350
+ render(<A2UIRender messages={messages} />)
351
+
352
+ const button = screen.getByRole('button')
353
+
354
+ // Should not throw
355
+ await expect(user.click(button)).resolves.not.toThrow()
356
+
357
+ consoleWarn.mockRestore()
358
+ })
359
+ })
360
+
361
+ describe('Phase 5: User Story 3 - Custom Component Override', () => {
362
+ it('T028: custom Button component renders instead of default', () => {
363
+ function CustomButton({
364
+ surfaceId,
365
+ child,
366
+ }: BaseComponentProps & { child?: string }) {
367
+ return (
368
+ <button data-testid="custom-button" data-surface={surfaceId}>
369
+ Custom:{' '}
370
+ {child && (
371
+ <ComponentRenderer surfaceId={surfaceId} componentId={child} />
372
+ )}
373
+ </button>
374
+ )
375
+ }
376
+
377
+ const customComponents: ComponentsMap = new Map([
378
+ ['Button', CustomButton],
379
+ ])
380
+
381
+ const messages = createTestMessages({
382
+ surfaceId: 'surface-1',
383
+ root: 'button-1',
384
+ components: [
385
+ {
386
+ id: 'button-1',
387
+ type: 'Button',
388
+ props: { child: 'text-1' },
389
+ },
390
+ {
391
+ id: 'text-1',
392
+ type: 'Text',
393
+ props: { text: { literalString: 'Hello' } },
394
+ },
395
+ ],
396
+ })
397
+
398
+ render(<A2UIRender messages={messages} components={customComponents} />)
399
+
400
+ expect(screen.getByTestId('custom-button')).toBeInTheDocument()
401
+ expect(screen.getByText('Hello')).toBeInTheDocument()
402
+ })
403
+
404
+ it('T029: multiple custom components render correctly', () => {
405
+ function CustomButton() {
406
+ return <button data-testid="custom-button">Custom Button</button>
407
+ }
408
+
409
+ function CustomText({
410
+ text,
411
+ }: BaseComponentProps & { text?: { literalString?: string } }) {
412
+ return (
413
+ <span data-testid="custom-text">Custom: {text?.literalString}</span>
414
+ )
415
+ }
416
+
417
+ const customComponents: ComponentsMap = new Map([
418
+ ['Button', CustomButton],
419
+ ['Text', CustomText],
420
+ ])
421
+
422
+ const messages = createTestMessages({
423
+ surfaceId: 'surface-1',
424
+ root: 'row-1',
425
+ components: [
426
+ {
427
+ id: 'row-1',
428
+ type: 'Row',
429
+ props: { children: { explicitList: ['button-1', 'text-1'] } },
430
+ },
431
+ { id: 'button-1', type: 'Button', props: {} },
432
+ {
433
+ id: 'text-1',
434
+ type: 'Text',
435
+ props: { text: { literalString: 'Hello' } },
436
+ },
437
+ ],
438
+ })
439
+
440
+ render(<A2UIRender messages={messages} components={customComponents} />)
441
+
442
+ expect(screen.getByTestId('custom-button')).toBeInTheDocument()
443
+ expect(screen.getByTestId('custom-text')).toBeInTheDocument()
444
+ expect(screen.getByText('Custom: Hello')).toBeInTheDocument()
445
+ })
446
+
447
+ it('T030: non-overridden components use defaults', () => {
448
+ function CustomButton() {
449
+ return <button data-testid="custom-button">Custom Button</button>
450
+ }
451
+
452
+ const customComponents: ComponentsMap = new Map([
453
+ ['Button', CustomButton],
454
+ ])
455
+
456
+ const messages = createTestMessages({
457
+ surfaceId: 'surface-1',
458
+ root: 'row-1',
459
+ components: [
460
+ {
461
+ id: 'row-1',
462
+ type: 'Row',
463
+ props: { children: { explicitList: ['button-1', 'text-1'] } },
464
+ },
465
+ { id: 'button-1', type: 'Button', props: {} },
466
+ {
467
+ id: 'text-1',
468
+ type: 'Text',
469
+ props: { text: { literalString: 'Default Text' } },
470
+ },
471
+ ],
472
+ })
473
+
474
+ render(<A2UIRender messages={messages} components={customComponents} />)
475
+
476
+ // Custom button
477
+ expect(screen.getByTestId('custom-button')).toBeInTheDocument()
478
+ // Default text component
479
+ expect(screen.getByText('Default Text')).toBeInTheDocument()
480
+ })
481
+ })
482
+
483
+ describe('Phase 6: User Story 4 - Custom Component Creation', () => {
484
+ it('T034: new component type "Switch" renders from ComponentsMap', () => {
485
+ function CustomSwitch({
486
+ label,
487
+ }: BaseComponentProps & { label?: string }) {
488
+ return (
489
+ <label data-testid="custom-switch">
490
+ <input type="checkbox" />
491
+ {label}
492
+ </label>
493
+ )
494
+ }
495
+
496
+ const customComponents: ComponentsMap = new Map([
497
+ ['Switch', CustomSwitch],
498
+ ])
499
+
500
+ const messages = createTestMessages({
501
+ surfaceId: 'surface-1',
502
+ root: 'switch-1',
503
+ components: [
504
+ {
505
+ id: 'switch-1',
506
+ type: 'Switch',
507
+ props: { label: 'Enable feature' },
508
+ },
509
+ ],
510
+ })
511
+
512
+ render(<A2UIRender messages={messages} components={customComponents} />)
513
+
514
+ expect(screen.getByTestId('custom-switch')).toBeInTheDocument()
515
+ expect(screen.getByText('Enable feature')).toBeInTheDocument()
516
+ })
517
+
518
+ it('T035: custom component can use useDispatchAction hook', async () => {
519
+ const user = userEvent.setup()
520
+ const onAction = vi.fn()
521
+
522
+ function CustomSwitch({
523
+ surfaceId,
524
+ componentId,
525
+ action,
526
+ }: BaseComponentProps & { action?: Action }) {
527
+ const dispatchAction = useDispatchAction()
528
+
529
+ const handleChange = () => {
530
+ if (action) {
531
+ dispatchAction(surfaceId, componentId, action)
532
+ }
533
+ }
534
+
535
+ return (
536
+ <label data-testid="custom-switch">
537
+ <input type="checkbox" onChange={handleChange} />
538
+ Toggle
539
+ </label>
540
+ )
541
+ }
542
+
543
+ const customComponents: ComponentsMap = new Map([
544
+ ['Switch', CustomSwitch],
545
+ ])
546
+
547
+ const messages = createTestMessages({
548
+ surfaceId: 'surface-1',
549
+ root: 'switch-1',
550
+ components: [
551
+ {
552
+ id: 'switch-1',
553
+ type: 'Switch',
554
+ props: { action: { name: 'toggle' } },
555
+ },
556
+ ],
557
+ })
558
+
559
+ render(
560
+ <A2UIRender
561
+ messages={messages}
562
+ onAction={onAction}
563
+ components={customComponents}
564
+ />
565
+ )
566
+
567
+ const checkbox = screen.getByRole('checkbox')
568
+ await user.click(checkbox)
569
+
570
+ expect(onAction).toHaveBeenCalledWith(
571
+ expect.objectContaining({
572
+ surfaceId: 'surface-1',
573
+ sourceComponentId: 'switch-1',
574
+ name: 'toggle',
575
+ })
576
+ )
577
+ })
578
+ })
579
+
580
+ describe('Phase 7: User Story 5 - Data Binding in Custom Components', () => {
581
+ it('T038: custom component with useDataBinding displays bound value', () => {
582
+ function CustomDisplay({
583
+ surfaceId,
584
+ text,
585
+ }: BaseComponentProps & { text?: { path: string } }) {
586
+ const textValue = useDataBinding<string>(surfaceId, text, 'default')
587
+ return <span data-testid="custom-display">{textValue}</span>
588
+ }
589
+
590
+ const customComponents: ComponentsMap = new Map([
591
+ ['CustomDisplay', CustomDisplay],
592
+ ])
593
+
594
+ const messages: A2UIMessage[] = [
595
+ { beginRendering: { surfaceId: 'surface-1', root: 'display-1' } },
596
+ {
597
+ surfaceUpdate: {
598
+ surfaceId: 'surface-1',
599
+ components: [
600
+ {
601
+ id: 'display-1',
602
+ component: { CustomDisplay: { text: { path: '/message' } } },
603
+ },
604
+ ],
605
+ },
606
+ },
607
+ {
608
+ dataModelUpdate: {
609
+ surfaceId: 'surface-1',
610
+ path: '/',
611
+ contents: [
612
+ { key: 'message', valueString: 'Hello from data model' },
613
+ ],
614
+ },
615
+ },
616
+ ]
617
+
618
+ render(<A2UIRender messages={messages} components={customComponents} />)
619
+
620
+ expect(screen.getByTestId('custom-display')).toHaveTextContent(
621
+ 'Hello from data model'
622
+ )
623
+ })
624
+
625
+ it('T039: useDataBinding returns default when path not found', () => {
626
+ function CustomDisplay({
627
+ surfaceId,
628
+ text,
629
+ }: BaseComponentProps & { text?: { path: string } }) {
630
+ const textValue = useDataBinding<string>(
631
+ surfaceId,
632
+ text,
633
+ 'fallback value'
634
+ )
635
+ return <span data-testid="custom-display">{textValue}</span>
636
+ }
637
+
638
+ const customComponents: ComponentsMap = new Map([
639
+ ['CustomDisplay', CustomDisplay],
640
+ ])
641
+
642
+ const messages: A2UIMessage[] = [
643
+ { beginRendering: { surfaceId: 'surface-1', root: 'display-1' } },
644
+ {
645
+ surfaceUpdate: {
646
+ surfaceId: 'surface-1',
647
+ components: [
648
+ {
649
+ id: 'display-1',
650
+ component: {
651
+ CustomDisplay: { text: { path: '/nonexistent' } },
652
+ },
653
+ },
654
+ ],
655
+ },
656
+ },
657
+ ]
658
+
659
+ render(<A2UIRender messages={messages} components={customComponents} />)
660
+
661
+ expect(screen.getByTestId('custom-display')).toHaveTextContent(
662
+ 'fallback value'
663
+ )
664
+ })
665
+ })
666
+
667
+ describe('Phase 8: User Story 6 - Form Binding in Custom Components', () => {
668
+ it('T042: custom component with useFormBinding displays current value', () => {
669
+ function CustomInput({
670
+ surfaceId,
671
+ value,
672
+ }: BaseComponentProps & { value?: { path: string } }) {
673
+ const [inputValue] = useFormBinding<string>(surfaceId, value, '')
674
+ return <input data-testid="custom-input" value={inputValue} readOnly />
675
+ }
676
+
677
+ const customComponents: ComponentsMap = new Map([
678
+ ['CustomInput', CustomInput],
679
+ ])
680
+
681
+ const messages: A2UIMessage[] = [
682
+ { beginRendering: { surfaceId: 'surface-1', root: 'input-1' } },
683
+ {
684
+ surfaceUpdate: {
685
+ surfaceId: 'surface-1',
686
+ components: [
687
+ {
688
+ id: 'input-1',
689
+ component: { CustomInput: { value: { path: '/username' } } },
690
+ },
691
+ ],
692
+ },
693
+ },
694
+ {
695
+ dataModelUpdate: {
696
+ surfaceId: 'surface-1',
697
+ path: '/',
698
+ contents: [{ key: 'username', valueString: 'john_doe' }],
699
+ },
700
+ },
701
+ ]
702
+
703
+ render(<A2UIRender messages={messages} components={customComponents} />)
704
+
705
+ expect(screen.getByTestId('custom-input')).toHaveValue('john_doe')
706
+ })
707
+
708
+ it('T043: useFormBinding setValue updates the bound value', async () => {
709
+ const user = userEvent.setup()
710
+
711
+ function CustomInput({
712
+ surfaceId,
713
+ value,
714
+ }: BaseComponentProps & { value?: { path: string } }) {
715
+ const [inputValue, setInputValue] = useFormBinding<string>(
716
+ surfaceId,
717
+ value,
718
+ ''
719
+ )
720
+ return (
721
+ <input
722
+ data-testid="custom-input"
723
+ value={inputValue}
724
+ onChange={(e) => setInputValue(e.target.value)}
725
+ />
726
+ )
727
+ }
728
+
729
+ const customComponents: ComponentsMap = new Map([
730
+ ['CustomInput', CustomInput],
731
+ ])
732
+
733
+ const messages: A2UIMessage[] = [
734
+ { beginRendering: { surfaceId: 'surface-1', root: 'input-1' } },
735
+ {
736
+ surfaceUpdate: {
737
+ surfaceId: 'surface-1',
738
+ components: [
739
+ {
740
+ id: 'input-1',
741
+ component: { CustomInput: { value: { path: '/username' } } },
742
+ },
743
+ ],
744
+ },
745
+ },
746
+ ]
747
+
748
+ render(<A2UIRender messages={messages} components={customComponents} />)
749
+
750
+ const input = screen.getByTestId('custom-input')
751
+ await user.type(input, 'new_value')
752
+
753
+ expect(input).toHaveValue('new_value')
754
+ })
755
+
756
+ it('T044: useFormBinding returns default when path not found', () => {
757
+ function CustomInput({
758
+ surfaceId,
759
+ value,
760
+ }: BaseComponentProps & { value?: { path: string } }) {
761
+ const [inputValue] = useFormBinding<string>(
762
+ surfaceId,
763
+ value,
764
+ 'default_value'
765
+ )
766
+ return <input data-testid="custom-input" value={inputValue} readOnly />
767
+ }
768
+
769
+ const customComponents: ComponentsMap = new Map([
770
+ ['CustomInput', CustomInput],
771
+ ])
772
+
773
+ const messages: A2UIMessage[] = [
774
+ { beginRendering: { surfaceId: 'surface-1', root: 'input-1' } },
775
+ {
776
+ surfaceUpdate: {
777
+ surfaceId: 'surface-1',
778
+ components: [
779
+ {
780
+ id: 'input-1',
781
+ component: { CustomInput: { value: { path: '/nonexistent' } } },
782
+ },
783
+ ],
784
+ },
785
+ },
786
+ ]
787
+
788
+ render(<A2UIRender messages={messages} components={customComponents} />)
789
+
790
+ expect(screen.getByTestId('custom-input')).toHaveValue('default_value')
791
+ })
792
+ })
793
+ })