@happyvertical/smrt-svelte 0.43.4 → 0.43.6

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 (92) hide show
  1. package/AGENTS.md +23 -0
  2. package/README.md +23 -1
  3. package/dist/components/forms/AddressInput.svelte +155 -42
  4. package/dist/components/forms/AddressInput.svelte.d.ts.map +1 -1
  5. package/dist/components/forms/CheckboxInput.svelte +40 -13
  6. package/dist/components/forms/CheckboxInput.svelte.d.ts.map +1 -1
  7. package/dist/components/forms/DateRangeInput.svelte +132 -16
  8. package/dist/components/forms/DateRangeInput.svelte.d.ts.map +1 -1
  9. package/dist/components/forms/DateTimeInput.svelte +48 -14
  10. package/dist/components/forms/DateTimeInput.svelte.d.ts.map +1 -1
  11. package/dist/components/forms/Form.svelte +599 -70
  12. package/dist/components/forms/Form.svelte.d.ts +6 -2
  13. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  14. package/dist/components/forms/MeasurementInput.svelte +137 -31
  15. package/dist/components/forms/MeasurementInput.svelte.d.ts.map +1 -1
  16. package/dist/components/forms/MoneyInput.svelte +42 -23
  17. package/dist/components/forms/MoneyInput.svelte.d.ts.map +1 -1
  18. package/dist/components/forms/NumberInput.svelte +59 -27
  19. package/dist/components/forms/NumberInput.svelte.d.ts.map +1 -1
  20. package/dist/components/forms/PhoneInput.svelte +51 -22
  21. package/dist/components/forms/PhoneInput.svelte.d.ts.map +1 -1
  22. package/dist/components/forms/SelectInput.svelte +38 -22
  23. package/dist/components/forms/SelectInput.svelte.d.ts.map +1 -1
  24. package/dist/components/forms/TextInput.svelte +32 -18
  25. package/dist/components/forms/TextInput.svelte.d.ts.map +1 -1
  26. package/dist/components/forms/TextareaInput.svelte +25 -18
  27. package/dist/components/forms/TextareaInput.svelte.d.ts.map +1 -1
  28. package/dist/components/forms/__tests__/AddressInput.behavior.test.js +94 -0
  29. package/dist/components/forms/__tests__/Form.behavior.test.js +1 -1
  30. package/dist/components/forms/__tests__/Form.extraction.test.js +68 -0
  31. package/dist/components/forms/__tests__/Form.test.js +28 -0
  32. package/dist/components/forms/__tests__/Form.webmcp.test.js +2123 -114
  33. package/dist/components/forms/__tests__/FormMicButton.test.js +0 -1
  34. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte +30 -0
  35. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts +7 -0
  36. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts.map +1 -0
  37. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte +19 -0
  38. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts +9 -0
  39. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts.map +1 -0
  40. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte +21 -0
  41. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts +8 -0
  42. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts.map +1 -0
  43. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte +27 -0
  44. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts +9 -0
  45. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts.map +1 -0
  46. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte +48 -0
  47. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts +10 -0
  48. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts.map +1 -0
  49. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte +26 -0
  50. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts +13 -0
  51. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts.map +1 -0
  52. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte +52 -0
  53. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts +16 -0
  54. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts.map +1 -0
  55. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte +108 -5
  56. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts +34 -0
  57. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts.map +1 -1
  58. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte +24 -0
  59. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts +11 -0
  60. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts.map +1 -0
  61. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte +69 -4
  62. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts +32 -0
  63. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts.map +1 -1
  64. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte +44 -0
  65. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts +9 -0
  66. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts.map +1 -0
  67. package/dist/components/forms/__tests__/policy-field.fixture.svelte +35 -0
  68. package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts +8 -0
  69. package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts.map +1 -0
  70. package/dist/components/forms/prepare-field-value.d.ts +5 -0
  71. package/dist/components/forms/prepare-field-value.d.ts.map +1 -0
  72. package/dist/components/forms/prepare-field-value.js +15 -0
  73. package/dist/state/form-context.d.ts +28 -6
  74. package/dist/state/form-context.d.ts.map +1 -1
  75. package/dist/state/form-context.js +75 -7
  76. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte +62 -0
  77. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts +8 -0
  78. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts.map +1 -0
  79. package/dist/web/__tests__/webmcp-composed.fixture.svelte +52 -0
  80. package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts +12 -0
  81. package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts.map +1 -0
  82. package/dist/web/__tests__/webmcp-composed.integration.svelte.test.js +247 -0
  83. package/dist/web/__tests__/webmcp-ssr.fixture.svelte +13 -0
  84. package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts +19 -0
  85. package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts.map +1 -0
  86. package/dist/web/__tests__/webmcp-ui.test.js +209 -33
  87. package/dist/web/__tests__/webmcp.test.js +20 -0
  88. package/dist/web/webmcp-ui.d.ts.map +1 -1
  89. package/dist/web/webmcp-ui.js +50 -31
  90. package/dist/web/webmcp.svelte.d.ts.map +1 -1
  91. package/dist/web/webmcp.svelte.js +6 -1
  92. package/package.json +8 -6
@@ -1,9 +1,11 @@
1
- import { render } from '@testing-library/svelte';
1
+ import { createControlInteractionRegistry, executeLocalControlBatch, executeLocalControlCommand, } from '@happyvertical/smrt-ui/forms';
2
+ import { render, screen, waitFor } from '@testing-library/svelte';
2
3
  import userEvent from '@testing-library/user-event';
3
4
  import { tick } from 'svelte';
4
5
  import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ const appState = vi.hoisted(() => ({ mode: 'default' }));
5
7
  vi.mock('../../../hooks/useAppState.svelte.js', () => ({
6
- useAppState: () => ({ state: { mode: 'default' }, setMode: vi.fn() }),
8
+ useAppState: () => ({ state: appState, setMode: vi.fn() }),
7
9
  }));
8
10
  vi.mock('../../../hooks/useSTT.svelte.js', () => ({
9
11
  useSTT: () => ({
@@ -18,13 +20,133 @@ vi.mock('../../../hooks/useSTT.svelte.js', () => ({
18
20
  stop: vi.fn(),
19
21
  }),
20
22
  }));
23
+ import AsyncValidationForm from './async-validation-form.fixture.svelte';
24
+ import AsyncValidationSnapshotForm from './async-validation-snapshot-form.fixture.svelte';
25
+ import FormRegistrationLifecycle from './form-registration-lifecycle.fixture.svelte';
21
26
  import FormWithFields from './form-with-fields.fixture.svelte';
27
+ import FormWithPolicyField from './form-with-policy-field.fixture.svelte';
22
28
  import FormWithStructuredFields from './form-with-structured-fields.fixture.svelte';
29
+ import LegacyFormContext from './legacy-form-context.fixture.svelte';
23
30
  afterEach(() => {
24
31
  delete document.modelContext;
32
+ appState.mode = 'default';
25
33
  });
26
- describe('Form WebMCP submit intent', () => {
27
- it('registers a field-derived tool and dispatches validation + submit', async () => {
34
+ function dispatchLocalGesture(execute) {
35
+ const target = new EventTarget();
36
+ let pending;
37
+ target.addEventListener('click', (event) => {
38
+ pending = execute(event);
39
+ }, { once: true });
40
+ target.dispatchEvent(new Event('click'));
41
+ if (!pending)
42
+ throw new Error('local gesture handler did not run');
43
+ return pending;
44
+ }
45
+ describe('Form WebMCP staged-edit intent', () => {
46
+ it('awaits an async false field validation before the WebMCP form submits', async () => {
47
+ const registered = [];
48
+ document.modelContext = {
49
+ async registerTool(tool) {
50
+ registered.push(tool);
51
+ },
52
+ };
53
+ let resolveValidation = () => { };
54
+ const validation = new Promise((resolve) => {
55
+ resolveValidation = resolve;
56
+ });
57
+ const validate = vi.fn(() => validation);
58
+ const onsubmit = vi.fn();
59
+ render(AsyncValidationForm, {
60
+ props: { onsubmit, validate, webmcp: true },
61
+ });
62
+ await tick();
63
+ await tick();
64
+ expect(registered).toHaveLength(1);
65
+ await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
66
+ await waitFor(() => expect(validate).toHaveBeenCalledTimes(1));
67
+ expect(onsubmit).not.toHaveBeenCalled();
68
+ resolveValidation(false);
69
+ await Promise.resolve();
70
+ await Promise.resolve();
71
+ expect(onsubmit).not.toHaveBeenCalled();
72
+ });
73
+ it('awaits a cross-realm async false validation before the WebMCP form submits', async () => {
74
+ const frame = document.createElement('iframe');
75
+ document.body.append(frame);
76
+ const ForeignPromise = frame.contentWindow?.Promise;
77
+ if (!ForeignPromise)
78
+ throw new Error('iframe Promise constructor unavailable');
79
+ expect(ForeignPromise).not.toBe(Promise);
80
+ const validation = ForeignPromise.resolve(false);
81
+ expect(validation).not.toBeInstanceOf(Promise);
82
+ const validate = vi.fn(() => validation);
83
+ const onsubmit = vi.fn();
84
+ render(AsyncValidationForm, {
85
+ props: { onsubmit, validate, webmcp: true },
86
+ });
87
+ await tick();
88
+ await tick();
89
+ await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
90
+ await waitFor(() => expect(validate).toHaveBeenCalledTimes(1));
91
+ await Promise.resolve();
92
+ await Promise.resolve();
93
+ expect(onsubmit).not.toHaveBeenCalled();
94
+ frame.remove();
95
+ });
96
+ it('submits after an async true field validation', async () => {
97
+ const validate = vi.fn(async () => true);
98
+ const onsubmit = vi.fn();
99
+ render(AsyncValidationForm, {
100
+ props: { onsubmit, validate, webmcp: true },
101
+ });
102
+ await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
103
+ await waitFor(() => expect(validate).toHaveBeenCalledTimes(1));
104
+ await waitFor(() => expect(onsubmit).toHaveBeenCalledWith({ 'async-invalid': 'proposal' }));
105
+ });
106
+ it('blocks submission when an async field validation rejects', async () => {
107
+ const validate = vi.fn(() => Promise.reject(new Error('validation_failed')));
108
+ const onsubmit = vi.fn();
109
+ render(AsyncValidationForm, {
110
+ props: { onsubmit, validate, webmcp: true },
111
+ });
112
+ await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
113
+ await waitFor(() => expect(validate).toHaveBeenCalledTimes(1));
114
+ await Promise.resolve();
115
+ await Promise.resolve();
116
+ expect(onsubmit).not.toHaveBeenCalled();
117
+ });
118
+ it('submits a coherent field snapshot when validation allows intervening edits', async () => {
119
+ let resolveValidation = () => { };
120
+ const validation = new Promise((resolve) => {
121
+ resolveValidation = resolve;
122
+ });
123
+ const validate = vi.fn(() => validation);
124
+ const onsubmit = vi.fn();
125
+ render(AsyncValidationSnapshotForm, { props: { onsubmit, validate } });
126
+ await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
127
+ await waitFor(() => expect(validate).toHaveBeenCalledTimes(1));
128
+ await userEvent.clear(screen.getByRole('textbox', { name: 'Later value' }));
129
+ await userEvent.type(screen.getByRole('textbox', { name: 'Later value' }), 'after');
130
+ resolveValidation(true);
131
+ await waitFor(() => expect(onsubmit).toHaveBeenCalledWith({
132
+ 'async-invalid': 'proposal',
133
+ later: 'before',
134
+ }));
135
+ });
136
+ it('calls onsubmit synchronously when a field validator is synchronous', async () => {
137
+ const validate = vi.fn(() => true);
138
+ const onsubmit = vi.fn();
139
+ const { container } = render(AsyncValidationForm, {
140
+ props: { onsubmit, validate, webmcp: true },
141
+ });
142
+ const form = container.querySelector('form');
143
+ if (!form)
144
+ throw new Error('Form not rendered');
145
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
146
+ expect(validate).toHaveBeenCalledTimes(1);
147
+ expect(onsubmit).toHaveBeenCalledWith({ 'async-invalid': 'proposal' });
148
+ });
149
+ it('registers a field-derived tool and stages without mutating or submitting', async () => {
28
150
  const registered = [];
29
151
  document.modelContext = {
30
152
  async registerTool(tool) {
@@ -40,177 +162,2064 @@ describe('Form WebMCP submit intent', () => {
40
162
  type: 'object',
41
163
  properties: { fullname: { type: 'string' }, age: { type: 'number' } },
42
164
  });
43
- expect(registered[0].name).toContain('submit');
165
+ expect(registered[0].name).toContain('stage_changes');
44
166
  const result = await registered[0].execute({
45
167
  fullname: 'Ada Lovelace',
46
168
  age: 36,
47
169
  });
48
- expect(result).toBe('Submitted successfully');
49
- expect(onsubmit).toHaveBeenCalledWith({
50
- fullname: 'Ada Lovelace',
51
- age: 36,
170
+ expect(result).toBe('Staged 2 changes for review');
171
+ expect(onsubmit).not.toHaveBeenCalled();
172
+ await waitFor(() => expect(screen.getByRole('region', { name: 'Review proposed changes' })).toBeInTheDocument());
173
+ expect(screen.getByRole('textbox', { name: 'Full name' })).toHaveValue('');
174
+ });
175
+ it('stages canonical rich values and rejects malformed text through WebMCP', async () => {
176
+ const registered = [];
177
+ document.modelContext = {
178
+ registerTool(tool) {
179
+ registered.push(tool);
180
+ },
181
+ };
182
+ const numberChanged = vi.fn();
183
+ const checkboxChanged = vi.fn();
184
+ const selectChanged = vi.fn();
185
+ const registry = createControlInteractionRegistry({
186
+ isLocalGesture: () => true,
187
+ });
188
+ render(FormWithFields, {
189
+ props: {
190
+ webmcp: true,
191
+ interactionRegistry: registry,
192
+ showClearFields: true,
193
+ onnumberchange: numberChanged,
194
+ oncheckboxchange: checkboxChanged,
195
+ onselectchange: selectChanged,
196
+ },
52
197
  });
198
+ await tick();
199
+ await tick();
200
+ const tool = registered.at(-1);
201
+ if (!tool)
202
+ throw new Error('WebMCP tool was not registered');
203
+ expect(await tool.execute({ fullname: { malformed: true } })).toBe('Staged 0 changes for review; 1 rejected');
204
+ expect(registry.list().find((item) => item.identity.controlId === 'fullname')
205
+ ?.state.staged).toBeUndefined();
206
+ expect(await tool.execute({ age: '42', enabled: 'false', choice: 'First' })).toBe('Staged 3 changes for review');
207
+ expect(registry.list().find((item) => item.identity.controlId === 'age')?.state
208
+ .staged?.value).toBe(42);
209
+ expect(registry.list().find((item) => item.identity.controlId === 'enabled')
210
+ ?.state.staged?.value).toBe(false);
211
+ expect(registry.list().find((item) => item.identity.controlId === 'choice')
212
+ ?.state.staged?.value).toBe('first');
213
+ await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
214
+ expect(screen.getByRole('spinbutton', { name: 'Age' })).toHaveValue(42);
215
+ expect(screen.getByRole('checkbox', { name: 'Enabled' })).not.toBeChecked();
216
+ expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveValue('first');
217
+ expect(numberChanged).toHaveBeenLastCalledWith(42);
218
+ expect(checkboxChanged).toHaveBeenLastCalledWith(false);
219
+ expect(selectChanged).toHaveBeenLastCalledWith('first');
53
220
  });
54
- it('publishes required and numeric constraints and validates them before submit', async () => {
221
+ it('stages and applies a canonical null for an empty optional number', async () => {
55
222
  const registered = [];
56
223
  document.modelContext = {
57
- async registerTool(tool) {
224
+ registerTool(tool) {
58
225
  registered.push(tool);
59
226
  },
60
227
  };
61
- const onsubmit = vi.fn();
228
+ const numberChanged = vi.fn();
229
+ const registry = createControlInteractionRegistry({
230
+ isLocalGesture: () => true,
231
+ });
62
232
  render(FormWithFields, {
63
233
  props: {
64
234
  webmcp: true,
65
- textRequired: true,
66
- ageRequired: true,
67
- ageMin: 18,
68
- ageMax: 65,
69
- onsubmit,
235
+ numberValue: 42,
236
+ onnumberchange: numberChanged,
237
+ interactionRegistry: registry,
70
238
  },
71
239
  });
72
240
  await tick();
73
241
  await tick();
74
- expect(registered).toHaveLength(1);
75
- const schema = registered[0].inputSchema;
76
- expect(schema.required).toEqual(['fullname', 'age']);
77
- expect(schema.properties.age).toMatchObject({
78
- type: 'number',
79
- minimum: 18,
80
- maximum: 65,
242
+ expect(await registered.at(-1)?.execute({ age: '' })).toBe('Staged 1 change for review');
243
+ const age = registry
244
+ .list()
245
+ .find((snapshot) => snapshot.identity.controlId === 'age');
246
+ expect(age?.state.staged).toMatchObject({ value: null, valid: true });
247
+ await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
248
+ expect(screen.getByRole('spinbutton', { name: 'Age' })).toHaveValue(null);
249
+ expect(numberChanged).toHaveBeenLastCalledWith(null);
250
+ });
251
+ it('returns focus to a rich field after applying its final proposal', async () => {
252
+ const registered = [];
253
+ document.modelContext = {
254
+ registerTool(tool) {
255
+ registered.push(tool);
256
+ },
257
+ };
258
+ const interactionRegistry = createControlInteractionRegistry({
259
+ isLocalGesture: () => true,
81
260
  });
82
- expect(await registered[0].execute({ age: 17 })).toBe('Validation failed');
83
- expect(await registered[0].execute({ fullname: 'Ada', age: 66 })).toBe('Validation failed');
84
- expect(onsubmit).not.toHaveBeenCalled();
85
- expect(await registered[0].execute({ fullname: 'Ada', age: 36 })).toBe('Submitted successfully');
86
- expect(onsubmit).toHaveBeenCalledWith({ fullname: 'Ada', age: 36 });
261
+ render(FormWithFields, {
262
+ props: { webmcp: true, showAge: false, interactionRegistry },
263
+ });
264
+ await tick();
265
+ await tick();
266
+ await registered.at(-1)?.execute({ fullname: 'Ada Lovelace' });
267
+ await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
268
+ await waitFor(() => expect(screen.getByRole('textbox', { name: 'Full name' })).toHaveFocus());
87
269
  });
88
- it('keeps the browser path a no-op without WebMCP', async () => {
89
- const onsubmit = vi.fn();
90
- const view = render(FormWithFields, { props: { onsubmit } });
91
- await userEvent.click(view.getByRole('button', { name: 'Submit' }));
92
- expect(onsubmit).toHaveBeenCalledTimes(1);
270
+ it('publishes rich control identity and staged state on the native field', async () => {
271
+ const registry = createControlInteractionRegistry({
272
+ isLocalGesture: () => true,
273
+ });
274
+ render(FormWithFields, {
275
+ props: {
276
+ showAge: false,
277
+ interactionRegistry: registry,
278
+ formSubject: { type: 'person', id: 'person-1' },
279
+ },
280
+ });
281
+ await tick();
282
+ const fullname = registry
283
+ .list()
284
+ .find((snapshot) => snapshot.identity.controlId === 'fullname');
285
+ if (!fullname)
286
+ throw new Error('fullname was not registered');
287
+ const input = screen.getByRole('textbox', { name: 'Full name' });
288
+ expect(input).toHaveAttribute('data-smrt-control', 'fullname');
289
+ expect(input).toHaveAttribute('data-smrt-form', fullname.identity.formId);
290
+ expect(input).toHaveAttribute('data-smrt-subject-type', 'person');
291
+ expect(input).toHaveAttribute('data-smrt-subject-id', 'person-1');
292
+ await registry.execute({ action: 'stage', identity: fullname.identity, value: 'Ada' }, { source: 'agent' });
293
+ await waitFor(() => expect(input).toHaveAttribute('data-smrt-staged', 'true'));
294
+ await userEvent.click(screen.getByRole('button', { name: 'Discard valid changes' }));
295
+ await waitFor(() => expect(input).not.toHaveAttribute('data-smrt-staged'));
296
+ expect(input).toHaveFocus();
93
297
  });
94
- it('publishes and accepts structured measurement, date range, and address fields', async () => {
298
+ it('marks empty required rich scalar proposals invalid before application', async () => {
299
+ const registry = createControlInteractionRegistry();
300
+ const textChanged = vi.fn();
301
+ const checkboxChanged = vi.fn();
302
+ const selectChanged = vi.fn();
303
+ const notesChanged = vi.fn();
304
+ render(FormWithFields, {
305
+ props: {
306
+ showAge: false,
307
+ showClearFields: true,
308
+ showScalarFields: true,
309
+ textValue: 'Ada',
310
+ notesValue: 'Existing',
311
+ textRequired: true,
312
+ checkboxRequired: true,
313
+ selectRequired: true,
314
+ notesRequired: true,
315
+ interactionRegistry: registry,
316
+ ontextchange: textChanged,
317
+ oncheckboxchange: checkboxChanged,
318
+ onselectchange: selectChanged,
319
+ onnoteschange: notesChanged,
320
+ },
321
+ });
322
+ await tick();
323
+ const candidates = new Map([
324
+ ['fullname', ' '],
325
+ ['enabled', false],
326
+ ['choice', ''],
327
+ ['notes', ' '],
328
+ ]);
329
+ const commands = [];
330
+ for (const [controlId, value] of candidates) {
331
+ const snapshot = registry
332
+ .list()
333
+ .find((item) => item.identity.controlId === controlId);
334
+ if (!snapshot)
335
+ throw new Error(`${controlId} was not registered`);
336
+ await registry.execute({ action: 'stage', identity: snapshot.identity, value }, { source: 'agent' });
337
+ expect(registry.get(snapshot.identity)?.state.staged).toMatchObject({
338
+ valid: false,
339
+ });
340
+ commands.push({
341
+ action: 'apply',
342
+ identity: snapshot.identity,
343
+ revision: registry.get(snapshot.identity)?.state.staged?.revision,
344
+ });
345
+ }
346
+ const applied = await dispatchLocalGesture((event) => executeLocalControlBatch(registry, commands, event));
347
+ expect(applied.results).toHaveLength(4);
348
+ expect(applied.results.every((result) => !result.ok)).toBe(true);
349
+ await userEvent.click(screen.getByRole('button', { name: 'Apply valid changes' }));
350
+ expect(screen.getByRole('textbox', { name: 'Full name*' })).toHaveValue('Ada');
351
+ expect(screen.getByRole('checkbox', { name: 'Enabled*' })).toBeChecked();
352
+ expect(screen.getByRole('combobox', { name: 'Choice*' })).toHaveValue('second');
353
+ expect(screen.getByRole('textbox', { name: 'Notes*' })).toHaveValue('Existing');
354
+ expect(textChanged).not.toHaveBeenCalled();
355
+ expect(checkboxChanged).not.toHaveBeenCalled();
356
+ expect(selectChanged).not.toHaveBeenCalled();
357
+ expect(notesChanged).not.toHaveBeenCalled();
358
+ expect(commands.every((command) => registry.get(command.identity)?.state.staged !== undefined)).toBe(true);
359
+ });
360
+ it('preserves staged state when an unrelated sibling field mounts', async () => {
95
361
  const registered = [];
96
362
  document.modelContext = {
97
- async registerTool(tool) {
363
+ registerTool(tool) {
98
364
  registered.push(tool);
99
365
  },
100
366
  };
101
- const onsubmit = vi.fn();
102
- render(FormWithStructuredFields, { props: { webmcp: true, onsubmit } });
367
+ const registry = createControlInteractionRegistry({
368
+ isLocalGesture: () => true,
369
+ });
370
+ const view = render(FormWithFields, {
371
+ props: { webmcp: true, interactionRegistry: registry },
372
+ });
103
373
  await tick();
104
374
  await tick();
105
- expect(registered).toHaveLength(1);
106
- const schema = registered[0].inputSchema;
107
- expect(schema.required).toEqual(['measurement', 'dates', 'address']);
108
- expect(schema.properties.measurement).toMatchObject({
109
- type: 'object',
110
- properties: {
111
- value: { type: 'number' },
112
- unit: { type: 'string' },
375
+ expect(await registered.at(-1)?.execute({ fullname: 'Ada' })).toBe('Staged 1 change for review');
376
+ await view.rerender({
377
+ webmcp: true,
378
+ interactionRegistry: registry,
379
+ showMoney: true,
380
+ });
381
+ await tick();
382
+ expect(registry
383
+ .list()
384
+ .find((snapshot) => snapshot.identity.controlId === 'fullname')?.state
385
+ .staged?.value).toBe('Ada');
386
+ const fullname = registry
387
+ .list()
388
+ .find((snapshot) => snapshot.identity.controlId === 'fullname');
389
+ if (!fullname)
390
+ throw new Error('fullname was not registered');
391
+ expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
392
+ {
393
+ action: 'apply',
394
+ identity: fullname.identity,
395
+ revision: fullname.state.staged?.revision,
113
396
  },
114
- required: ['value', 'unit'],
397
+ ], event))).toMatchObject({ ok: true });
398
+ await view.rerender({
399
+ webmcp: true,
400
+ interactionRegistry: registry,
401
+ showMoney: false,
115
402
  });
116
- expect(schema.properties.dates).toMatchObject({
117
- type: 'object',
118
- properties: {
119
- startDate: { type: 'string' },
120
- endDate: { type: 'string' },
403
+ await tick();
404
+ expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [{ action: 'undo', identity: fullname.identity }], event))).toMatchObject({ ok: true });
405
+ expect(registry.get(fullname.identity)?.state.value).toBe('');
406
+ });
407
+ it('preserves staged and undo state across live field label changes', async () => {
408
+ const registry = createControlInteractionRegistry({
409
+ isLocalGesture: () => true,
410
+ });
411
+ const view = render(FormWithFields, {
412
+ props: {
413
+ interactionRegistry: registry,
414
+ ageLabel: 'Minimum age',
121
415
  },
122
- required: ['startDate', 'endDate'],
123
416
  });
124
- expect(schema.properties.address).toMatchObject({
125
- type: 'object',
126
- properties: {
127
- street: { type: 'string' },
128
- city: { type: 'string' },
129
- province: { type: 'string' },
130
- postalCode: { type: 'string' },
131
- country: { type: 'string' },
417
+ await tick();
418
+ const age = registry
419
+ .list()
420
+ .find((snapshot) => snapshot.identity.controlId === 'age');
421
+ if (!age)
422
+ throw new Error('age was not registered');
423
+ await registry.execute({ action: 'stage', identity: age.identity, value: 42 }, { source: 'agent' });
424
+ const revision = registry.get(age.identity)?.state.staged?.revision;
425
+ await view.rerender({
426
+ interactionRegistry: registry,
427
+ ageLabel: 'Maximum age',
428
+ });
429
+ await waitFor(() => expect(registry.get(age.identity)?.metadata.label).toBe('Maximum age'));
430
+ expect(registry.get(age.identity)?.state.staged).toMatchObject({
431
+ value: 42,
432
+ revision,
433
+ });
434
+ expect(await screen.findByRole('textbox', {
435
+ name: 'Edit proposed value for Maximum age',
436
+ })).toHaveValue('42');
437
+ expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'apply', identity: age.identity, revision }, event))).toMatchObject({ ok: true });
438
+ expect(registry.get(age.identity)?.state.value).toBe(42);
439
+ await view.rerender({
440
+ interactionRegistry: registry,
441
+ ageLabel: 'Preferred age',
442
+ });
443
+ expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'undo', identity: age.identity }, event))).toMatchObject({ ok: true });
444
+ expect(registry.get(age.identity)?.state.value).toBeNull();
445
+ });
446
+ it('preserves state for subject-label replacements but not subject moves', async () => {
447
+ const registry = createControlInteractionRegistry({
448
+ isLocalGesture: () => true,
449
+ });
450
+ const view = render(FormWithFields, {
451
+ props: {
452
+ interactionRegistry: registry,
453
+ showAge: false,
454
+ formSubject: { type: 'person', id: 'person-1', label: 'Old label' },
132
455
  },
133
- required: ['street', 'city', 'province', 'postalCode', 'country'],
134
456
  });
135
- const values = {
136
- measurement: { value: 1.5, unit: 'm' },
137
- dates: { startDate: '2026-01-01', endDate: '2026-01-02' },
138
- address: {
139
- street: '1 Main St',
140
- city: 'Edmonton',
141
- province: 'AB',
142
- postalCode: 'T1A 1A1',
143
- country: 'CA',
457
+ await tick();
458
+ const original = registry
459
+ .list()
460
+ .find((snapshot) => snapshot.identity.controlId === 'fullname');
461
+ if (!original)
462
+ throw new Error('fullname was not registered');
463
+ await registry.execute({ action: 'stage', identity: original.identity, value: 'Grace' }, { source: 'agent' });
464
+ const revision = registry.get(original.identity)?.state.staged?.revision;
465
+ await view.rerender({
466
+ interactionRegistry: registry,
467
+ showAge: false,
468
+ formSubject: { type: 'person', id: 'person-1', label: 'New label' },
469
+ });
470
+ await waitFor(() => expect(registry.get(original.identity)?.identity.subject?.label).toBe('New label'));
471
+ const replacement = registry.get(original.identity);
472
+ expect(replacement?.state.staged).toMatchObject({
473
+ value: 'Grace',
474
+ revision,
475
+ });
476
+ const fullname = screen.getByRole('textbox', { name: 'Full name' });
477
+ expect(fullname).toHaveAttribute('data-smrt-subject-type', 'person');
478
+ expect(fullname).toHaveAttribute('data-smrt-subject-id', 'person-1');
479
+ expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, {
480
+ action: 'apply',
481
+ identity: replacement?.identity ?? original.identity,
482
+ revision,
483
+ }, event))).toMatchObject({ ok: true });
484
+ expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, {
485
+ action: 'undo',
486
+ identity: replacement?.identity ?? original.identity,
487
+ }, event))).toMatchObject({ ok: true });
488
+ expect(registry.get(original.identity)?.state.value).toBe('');
489
+ await view.rerender({
490
+ interactionRegistry: registry,
491
+ showAge: false,
492
+ formSubject: { type: 'person', id: 'person-2', label: 'Moved' },
493
+ });
494
+ await waitFor(() => expect(registry.get(original.identity)).toBeUndefined());
495
+ expect(registry.get({
496
+ formId: original.identity.formId,
497
+ controlId: 'fullname',
498
+ subject: { type: 'person', id: 'person-2' },
499
+ })?.state.staged).toBeUndefined();
500
+ });
501
+ it('rejects invalid rich numeric proposals before mutation callbacks run', async () => {
502
+ const numberChanged = vi.fn();
503
+ const measurementChanged = vi.fn();
504
+ const registry = createControlInteractionRegistry({
505
+ isLocalGesture: () => true,
506
+ });
507
+ render(FormWithFields, {
508
+ props: {
509
+ interactionRegistry: registry,
510
+ onnumberchange: numberChanged,
511
+ ageStep: 2,
512
+ },
513
+ });
514
+ render(FormWithStructuredFields, {
515
+ props: {
516
+ interactionRegistry: registry,
517
+ onmeasurementchange: measurementChanged,
518
+ measurementStep: 2,
144
519
  },
520
+ });
521
+ await tick();
522
+ const ageIdentity = registry
523
+ .list()
524
+ .find((snapshot) => snapshot.identity.controlId === 'age')?.identity;
525
+ if (!ageIdentity)
526
+ throw new Error('age control was not registered');
527
+ const measurementIdentity = {
528
+ formId: 'structured-fields',
529
+ controlId: 'measurement',
145
530
  };
146
- expect(await registered[0].execute({
147
- ...values,
148
- address: {},
149
- })).toBe('Validation failed');
150
- expect(onsubmit).not.toHaveBeenCalled();
151
- expect(await registered[0].execute(values)).toBe('Submitted successfully');
152
- expect(onsubmit).toHaveBeenCalledWith(values);
531
+ const applyStaged = () => dispatchLocalGesture((event) => executeLocalControlBatch(registry, registry
532
+ .list()
533
+ .filter((snapshot) => snapshot.state.staged)
534
+ .map((snapshot) => ({
535
+ action: 'apply',
536
+ identity: snapshot.identity,
537
+ revision: snapshot.state.staged?.revision,
538
+ })), event));
539
+ await registry.execute({
540
+ action: 'stage',
541
+ identity: ageIdentity,
542
+ value: Number.POSITIVE_INFINITY,
543
+ }, { source: 'agent' });
544
+ await registry.execute({
545
+ action: 'stage',
546
+ identity: measurementIdentity,
547
+ value: { value: Number.POSITIVE_INFINITY, unit: 'ft' },
548
+ }, { source: 'agent' });
549
+ const results = await applyStaged();
550
+ expect(results.results).toHaveLength(2);
551
+ expect(results.results.every((result) => !result.ok)).toBe(true);
552
+ await registry.execute({ action: 'stage', identity: ageIdentity, value: 3 }, { source: 'agent' });
553
+ await registry.execute({
554
+ action: 'stage',
555
+ identity: measurementIdentity,
556
+ value: { value: 3, unit: 'ft' },
557
+ }, { source: 'agent' });
558
+ expect((await applyStaged()).results.every((result) => !result.ok)).toBe(true);
559
+ await registry.execute({
560
+ action: 'stage',
561
+ identity: measurementIdentity,
562
+ value: { value: 2, unit: 'constructor' },
563
+ }, { source: 'agent' });
564
+ expect((await applyStaged()).results.every((result) => !result.ok)).toBe(true);
565
+ expect(numberChanged).not.toHaveBeenCalled();
566
+ expect(measurementChanged).not.toHaveBeenCalled();
153
567
  });
154
- it('limits the address schema and payload to configured fields', async () => {
568
+ it('rejects invalid scalar date and phone proposals', async () => {
155
569
  const registered = [];
156
570
  document.modelContext = {
157
- async registerTool(tool) {
571
+ registerTool(tool) {
158
572
  registered.push(tool);
159
573
  },
160
574
  };
161
- const onsubmit = vi.fn();
162
- render(FormWithStructuredFields, {
575
+ const registry = createControlInteractionRegistry({
576
+ isLocalGesture: () => true,
577
+ });
578
+ render(FormWithFields, {
163
579
  props: {
164
580
  webmcp: true,
165
- addressFields: ['city', 'country'],
166
- onsubmit,
581
+ showAge: false,
582
+ showScalarFields: true,
583
+ interactionRegistry: registry,
167
584
  },
168
585
  });
169
586
  await tick();
170
587
  await tick();
171
- expect(registered).toHaveLength(1);
172
- const schema = registered[0].inputSchema;
173
- expect(schema.properties.address).toMatchObject({
174
- type: 'object',
175
- properties: {
176
- city: { type: 'string' },
177
- country: { type: 'string' },
588
+ expect(await registered.at(-1)?.execute({
589
+ appointment: '2026-02-30',
590
+ phone: '21234567890',
591
+ })).toBe('Staged 2 changes for review');
592
+ for (const controlId of ['appointment', 'phone']) {
593
+ const snapshot = registry
594
+ .list()
595
+ .find((item) => item.identity.controlId === controlId);
596
+ expect(snapshot?.state.staged?.valid).toBe(false);
597
+ expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
598
+ {
599
+ action: 'apply',
600
+ identity: snapshot?.identity ?? { formId: '', controlId: '' },
601
+ revision: snapshot?.state.staged?.revision,
602
+ },
603
+ ], event))).toMatchObject({ ok: false });
604
+ }
605
+ });
606
+ it('applies an untouched canonical append-mode value from the mounted review', async () => {
607
+ const registered = [];
608
+ document.modelContext = {
609
+ registerTool(tool) {
610
+ registered.push(tool);
178
611
  },
179
- required: ['city', 'country'],
612
+ };
613
+ const registry = createControlInteractionRegistry({
614
+ isLocalGesture: () => true,
180
615
  });
181
- expect(schema.properties.address.properties).not.toHaveProperty('street');
182
- const values = {
183
- measurement: { value: 1.5, unit: 'm' },
184
- dates: { startDate: '2026-01-01', endDate: '2026-01-02' },
185
- address: { city: 'Edmonton', country: 'CA' },
616
+ render(FormWithFields, {
617
+ props: {
618
+ webmcp: true,
619
+ showAge: false,
620
+ showScalarFields: true,
621
+ notesValue: 'Existing',
622
+ notesAppendMode: true,
623
+ interactionRegistry: registry,
624
+ },
625
+ });
626
+ await tick();
627
+ await tick();
628
+ expect(await registered.at(-1)?.execute({ notes: 'Proposed' })).toBe('Staged 1 change for review');
629
+ const notes = registry
630
+ .list()
631
+ .find((item) => item.identity.controlId === 'notes');
632
+ expect(notes?.state.staged?.value).toBe('Existing\nProposed');
633
+ expect(screen.getByRole('textbox', { name: 'Edit proposed value for Notes' })).toHaveValue('Existing\nProposed');
634
+ await userEvent.click(screen.getByRole('button', { name: 'Apply Notes' }));
635
+ expect(registry.get(notes?.identity ?? { formId: '', controlId: '' })?.state
636
+ .value).toBe('Existing\nProposed');
637
+ });
638
+ it('applies an edited canonical append-mode value from the mounted review', async () => {
639
+ const registered = [];
640
+ document.modelContext = {
641
+ registerTool(tool) {
642
+ registered.push(tool);
643
+ },
186
644
  };
187
- expect(await registered[0].execute(values)).toBe('Submitted successfully');
188
- expect(onsubmit).toHaveBeenCalledWith(values);
645
+ const registry = createControlInteractionRegistry({
646
+ isLocalGesture: () => true,
647
+ });
648
+ render(FormWithFields, {
649
+ props: {
650
+ webmcp: true,
651
+ showAge: false,
652
+ showScalarFields: true,
653
+ notesValue: 'Existing',
654
+ notesAppendMode: true,
655
+ interactionRegistry: registry,
656
+ },
657
+ });
658
+ await tick();
659
+ await tick();
660
+ expect(await registered.at(-1)?.execute({ notes: 'Proposed' })).toBe('Staged 1 change for review');
661
+ const notes = registry
662
+ .list()
663
+ .find((item) => item.identity.controlId === 'notes');
664
+ expect(notes?.state.staged?.value).toBe('Existing\nProposed');
665
+ const reviewEditor = screen.getByRole('textbox', {
666
+ name: 'Edit proposed value for Notes',
667
+ });
668
+ expect(reviewEditor).toHaveValue('Existing\nProposed');
669
+ await userEvent.clear(reviewEditor);
670
+ await userEvent.type(reviewEditor, 'Existing{enter}Edited');
671
+ expect(reviewEditor).toHaveValue('Existing\nEdited');
672
+ await userEvent.click(screen.getByRole('button', { name: 'Apply Notes' }));
673
+ expect(registry.get(notes?.identity ?? { formId: '', controlId: '' })?.state
674
+ .value).toBe('Existing\nEdited');
189
675
  });
190
- it('allows partial payloads for optional structured fields', async () => {
676
+ it('canonically applies a reverse-order measurement proposal from empty', async () => {
191
677
  const registered = [];
192
678
  document.modelContext = {
193
- async registerTool(tool) {
679
+ registerTool(tool) {
194
680
  registered.push(tool);
195
681
  },
196
682
  };
197
- const onsubmit = vi.fn();
683
+ const measurementChanged = vi.fn();
684
+ const registry = createControlInteractionRegistry({
685
+ isLocalGesture: () => true,
686
+ });
198
687
  render(FormWithStructuredFields, {
199
- props: { webmcp: true, structuredRequired: false, onsubmit },
688
+ props: {
689
+ webmcp: true,
690
+ structuredRequired: false,
691
+ interactionRegistry: registry,
692
+ onmeasurementchange: measurementChanged,
693
+ },
200
694
  });
201
695
  await tick();
202
696
  await tick();
203
- expect(registered).toHaveLength(1);
204
- const schema = registered[0].inputSchema;
205
- expect(schema.properties.measurement).not.toHaveProperty('required');
206
- expect(schema.properties.dates).not.toHaveProperty('required');
207
- expect(schema.properties.address).not.toHaveProperty('required');
208
- const partialValues = {
209
- measurement: { value: 1.5 },
210
- dates: { startDate: '2026-01-01' },
211
- address: { city: 'Edmonton' },
697
+ expect(await registered.at(-1)?.execute({
698
+ measurement: { unit: 'ft', value: 12 },
699
+ })).toBe('Staged 1 change for review');
700
+ const staged = registry.get({
701
+ formId: 'structured-fields',
702
+ controlId: 'measurement',
703
+ })?.state.staged?.value;
704
+ expect(JSON.stringify(staged)).toBe('{"value":12,"unit":"ft"}');
705
+ await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
706
+ await waitFor(() => expect(measurementChanged).toHaveBeenLastCalledWith({
707
+ value: 12,
708
+ unit: 'ft',
709
+ }));
710
+ expect(registry.get({
711
+ formId: 'structured-fields',
712
+ controlId: 'measurement',
713
+ })?.state.staged).toBeUndefined();
714
+ });
715
+ it('canonically applies a spoken phone proposal', async () => {
716
+ const registered = [];
717
+ document.modelContext = {
718
+ registerTool(tool) {
719
+ registered.push(tool);
720
+ },
212
721
  };
213
- expect(await registered[0].execute(partialValues)).toBe('Submitted successfully');
214
- expect(onsubmit).toHaveBeenCalledTimes(1);
722
+ const registry = createControlInteractionRegistry({
723
+ isLocalGesture: () => true,
724
+ });
725
+ render(FormWithFields, {
726
+ props: {
727
+ webmcp: true,
728
+ showAge: false,
729
+ showScalarFields: true,
730
+ interactionRegistry: registry,
731
+ },
732
+ });
733
+ await tick();
734
+ await tick();
735
+ expect(await registered.at(-1)?.execute({
736
+ phone: 'five five five one two three four five six seven',
737
+ })).toBe('Staged 1 change for review');
738
+ const phone = registry
739
+ .list()
740
+ .find((item) => item.identity.controlId === 'phone');
741
+ if (!phone)
742
+ throw new Error('Phone control was not registered');
743
+ expect(registry.get(phone.identity)?.state.staged?.value).toBe('(555) 123-4567');
744
+ await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
745
+ await waitFor(() => expect(screen.getByRole('textbox', { name: 'Phone' })).toHaveValue('(555) 123-4567'));
746
+ expect(registry.get(phone.identity)?.state.staged).toBeUndefined();
747
+ });
748
+ it('prepares appended textarea proposals after an async policy wait', async () => {
749
+ const registered = [];
750
+ document.modelContext = {
751
+ registerTool(tool) {
752
+ registered.push(tool);
753
+ },
754
+ };
755
+ let releasePolicy;
756
+ let policyStarted;
757
+ const policyBlocked = new Promise((resolve) => {
758
+ releasePolicy = resolve;
759
+ });
760
+ const policyStartedPromise = new Promise((resolve) => {
761
+ policyStarted = resolve;
762
+ });
763
+ const policyValues = [];
764
+ const registry = createControlInteractionRegistry({
765
+ isLocalGesture: () => true,
766
+ policy: async (command) => {
767
+ if (command.action === 'stage') {
768
+ policyValues.push(command.value);
769
+ if (policyValues.length === 1) {
770
+ policyStarted?.();
771
+ await policyBlocked;
772
+ }
773
+ if (String(command.value).includes('Forbidden')) {
774
+ return { allowed: false, reason: 'forbidden_content' };
775
+ }
776
+ }
777
+ return { allowed: true };
778
+ },
779
+ });
780
+ render(FormWithFields, {
781
+ props: {
782
+ webmcp: true,
783
+ showAge: false,
784
+ showScalarFields: true,
785
+ notesValue: 'Existing',
786
+ notesAppendMode: true,
787
+ interactionRegistry: registry,
788
+ },
789
+ });
790
+ await tick();
791
+ await tick();
792
+ const staging = registered.at(-1)?.execute({ notes: 'Proposed' });
793
+ await policyStartedPromise;
794
+ await userEvent.type(screen.getByRole('textbox', { name: 'Notes' }), ' human');
795
+ releasePolicy?.();
796
+ expect(await staging).toBe('Staged 1 change for review');
797
+ const notes = registry
798
+ .list()
799
+ .find((item) => item.identity.controlId === 'notes');
800
+ expect(notes?.state.staged).toMatchObject({
801
+ value: 'Existing human\nProposed',
802
+ stale: false,
803
+ });
804
+ expect(policyValues).toEqual([
805
+ 'Existing\nProposed',
806
+ 'Existing human\nProposed',
807
+ ]);
808
+ expect(await registered.at(-1)?.execute({ notes: 'Forbidden' })).toBe('Staged 0 changes for review; 1 rejected');
809
+ expect(policyValues.at(-1)).toBe('Existing human\nForbidden');
810
+ expect(registry.list().find((item) => item.identity.controlId === 'notes')?.state
811
+ .staged?.value).toBe('Existing human\nProposed');
812
+ });
813
+ it('retries partial structured merges against an intervening human edit', async () => {
814
+ const registered = [];
815
+ document.modelContext = {
816
+ registerTool(tool) {
817
+ registered.push(tool);
818
+ },
819
+ };
820
+ let releasePolicy;
821
+ let policyStarted;
822
+ const policyBlocked = new Promise((resolve) => {
823
+ releasePolicy = resolve;
824
+ });
825
+ const policyStartedPromise = new Promise((resolve) => {
826
+ policyStarted = resolve;
827
+ });
828
+ const policyValues = [];
829
+ const registry = createControlInteractionRegistry({
830
+ policy: async (command) => {
831
+ if (command.action === 'stage') {
832
+ policyValues.push(command.value);
833
+ if (policyValues.length === 1) {
834
+ policyStarted?.();
835
+ await policyBlocked;
836
+ }
837
+ }
838
+ return { allowed: true };
839
+ },
840
+ });
841
+ render(FormWithStructuredFields, {
842
+ props: {
843
+ webmcp: true,
844
+ structuredRequired: false,
845
+ interactionRegistry: registry,
846
+ },
847
+ });
848
+ await tick();
849
+ await tick();
850
+ const staging = registered.at(-1)?.execute({
851
+ address: { street: '123 Main Street' },
852
+ });
853
+ await policyStartedPromise;
854
+ await userEvent.type(screen.getByRole('textbox', { name: 'City' }), 'Calgary');
855
+ releasePolicy?.();
856
+ expect(await staging).toBe('Staged 1 change for review');
857
+ const stagedAddress = registry.get({
858
+ formId: 'structured-fields',
859
+ controlId: 'address',
860
+ })?.state.staged?.value;
861
+ expect(stagedAddress).toMatchObject({
862
+ street: '123 Main Street',
863
+ city: 'Calgary',
864
+ });
865
+ expect(policyValues).toHaveLength(2);
866
+ expect(policyValues[0]).toMatchObject({ city: '' });
867
+ expect(policyValues[1]).toMatchObject({ city: 'Calgary' });
868
+ });
869
+ it('rejects a non-object date-range proposal without delayed mutation', async () => {
870
+ const datesChanged = vi.fn();
871
+ const registry = createControlInteractionRegistry({
872
+ isLocalGesture: () => true,
873
+ });
874
+ render(FormWithStructuredFields, {
875
+ props: {
876
+ interactionRegistry: registry,
877
+ ondateschange: datesChanged,
878
+ },
879
+ });
880
+ await tick();
881
+ const identity = { formId: 'structured-fields', controlId: 'dates' };
882
+ await registry.execute({ action: 'stage', identity, value: 'next week' }, { source: 'agent' });
883
+ const revision = registry.get(identity)?.state.staged?.revision;
884
+ expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [{ action: 'apply', identity, revision }], event))).toMatchObject({ ok: false });
885
+ await Promise.resolve();
886
+ expect(datesChanged).not.toHaveBeenCalled();
887
+ });
888
+ it('affirms idempotent clears for empty composite fields', async () => {
889
+ const registry = createControlInteractionRegistry({
890
+ isLocalGesture: () => true,
891
+ });
892
+ render(FormWithStructuredFields, {
893
+ props: {
894
+ interactionRegistry: registry,
895
+ structuredRequired: false,
896
+ },
897
+ });
898
+ await tick();
899
+ expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
900
+ {
901
+ action: 'clear',
902
+ identity: { formId: 'structured-fields', controlId: 'dates' },
903
+ },
904
+ {
905
+ action: 'clear',
906
+ identity: {
907
+ formId: 'structured-fields',
908
+ controlId: 'measurement',
909
+ },
910
+ },
911
+ ], event))).toMatchObject({
912
+ ok: true,
913
+ results: [{ ok: true }, { ok: true }],
914
+ });
915
+ });
916
+ it('clears rich checkbox and select fields to their explicit empty values', async () => {
917
+ const registry = createControlInteractionRegistry({
918
+ isLocalGesture: () => true,
919
+ });
920
+ render(FormWithFields, {
921
+ props: {
922
+ interactionRegistry: registry,
923
+ showAge: false,
924
+ showClearFields: true,
925
+ checkboxValue: true,
926
+ selectValue: 'second',
927
+ },
928
+ });
929
+ await tick();
930
+ const enabled = registry
931
+ .list()
932
+ .find((snapshot) => snapshot.identity.controlId === 'enabled');
933
+ const choice = registry
934
+ .list()
935
+ .find((snapshot) => snapshot.identity.controlId === 'choice');
936
+ if (!enabled || !choice)
937
+ throw new Error('clear fields were not registered');
938
+ expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
939
+ {
940
+ action: 'clear',
941
+ identity: enabled.identity,
942
+ },
943
+ {
944
+ action: 'clear',
945
+ identity: choice.identity,
946
+ },
947
+ ], event))).toMatchObject({
948
+ ok: true,
949
+ results: [{ ok: true }, { ok: true }],
950
+ });
951
+ expect(registry.get(enabled.identity)?.state.value).toBe(false);
952
+ expect(registry.get(choice.identity)?.state.value).toBe('');
953
+ });
954
+ it('rejects invalid structured proposals before mutation callbacks run', async () => {
955
+ const addressChanged = vi.fn();
956
+ const datesChanged = vi.fn();
957
+ const measurementChanged = vi.fn();
958
+ const registry = createControlInteractionRegistry({
959
+ isLocalGesture: () => true,
960
+ });
961
+ render(FormWithStructuredFields, {
962
+ props: {
963
+ interactionRegistry: registry,
964
+ structuredRequired: false,
965
+ addressFields: ['city'],
966
+ onaddresschange: addressChanged,
967
+ ondateschange: datesChanged,
968
+ onmeasurementchange: measurementChanged,
969
+ measurementUnits: ['m'],
970
+ minDate: '2026-08-01',
971
+ maxDate: '2026-08-31',
972
+ },
973
+ });
974
+ await tick();
975
+ const apply = async (controlId, value) => {
976
+ const identity = { formId: 'structured-fields', controlId };
977
+ await registry.execute({ action: 'stage', identity, value }, { source: 'agent' });
978
+ return dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
979
+ {
980
+ action: 'apply',
981
+ identity,
982
+ revision: registry.get(identity)?.state.staged?.revision,
983
+ },
984
+ ], event));
985
+ };
986
+ expect((await apply('address', { city: { nested: true } })).results[0].ok).toBe(false);
987
+ expect((await apply('address', { city: 'Edmonton', constructor: 'payload' }))
988
+ .results[0].ok).toBe(false);
989
+ expect((await apply('dates', {
990
+ startDate: '2026-08-30',
991
+ endDate: '2026-08-20',
992
+ })).results[0].ok).toBe(false);
993
+ expect((await apply('dates', {
994
+ startDate: '2026-02-30',
995
+ endDate: '2026-08-20',
996
+ })).results[0].ok).toBe(false);
997
+ expect((await apply('dates', {
998
+ startDate: '2026-07-31',
999
+ endDate: '2026-08-20',
1000
+ })).results[0].ok).toBe(false);
1001
+ expect((await apply('dates', {
1002
+ startDate: '2026-08-10',
1003
+ endDate: '2026-08-20',
1004
+ constructor: 'payload',
1005
+ })).results[0].ok).toBe(false);
1006
+ expect((await apply('measurement', { value: 2, unit: 'ft' })).results[0].ok).toBe(false);
1007
+ expect((await apply('measurement', {
1008
+ value: 2,
1009
+ unit: 'm',
1010
+ constructor: 'payload',
1011
+ })).results[0].ok).toBe(false);
1012
+ expect(addressChanged).not.toHaveBeenCalled();
1013
+ expect(datesChanged).not.toHaveBeenCalled();
1014
+ expect(measurementChanged).not.toHaveBeenCalled();
1015
+ });
1016
+ it('returns focus to smrt-mode DateRangeInput after final discard', async () => {
1017
+ appState.mode = 'smrt';
1018
+ const registry = createControlInteractionRegistry({
1019
+ isLocalGesture: () => true,
1020
+ });
1021
+ render(FormWithStructuredFields, {
1022
+ props: { interactionRegistry: registry },
1023
+ });
1024
+ await tick();
1025
+ await registry.execute({
1026
+ action: 'stage',
1027
+ identity: { formId: 'structured-fields', controlId: 'dates' },
1028
+ value: { startDate: '2026-08-26', endDate: '2026-08-27' },
1029
+ }, { source: 'agent' });
1030
+ await userEvent.click(await screen.findByRole('button', { name: 'Discard valid changes' }));
1031
+ await waitFor(() => expect(screen.getByRole('button', { name: /date range/i })).toHaveFocus());
1032
+ });
1033
+ it('falls back to an enabled form control after discarding a disabled field', async () => {
1034
+ appState.mode = 'smrt';
1035
+ const registry = createControlInteractionRegistry({
1036
+ isLocalGesture: () => true,
1037
+ });
1038
+ const view = render(FormWithStructuredFields, {
1039
+ props: { interactionRegistry: registry },
1040
+ });
1041
+ await tick();
1042
+ await registry.execute({
1043
+ action: 'stage',
1044
+ identity: { formId: 'structured-fields', controlId: 'dates' },
1045
+ value: { startDate: '2026-08-26', endDate: '2026-08-27' },
1046
+ }, { source: 'agent' });
1047
+ await view.rerender({
1048
+ interactionRegistry: registry,
1049
+ dateDisabled: true,
1050
+ });
1051
+ await tick();
1052
+ await userEvent.click(await screen.findByRole('button', { name: 'Discard valid changes' }));
1053
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Submit' })).toHaveFocus());
1054
+ });
1055
+ it('publishes constraints and leaves validation outcomes in the review workflow', async () => {
1056
+ const registered = [];
1057
+ document.modelContext = {
1058
+ async registerTool(tool) {
1059
+ registered.push(tool);
1060
+ },
1061
+ };
1062
+ const onsubmit = vi.fn();
1063
+ render(FormWithFields, {
1064
+ props: {
1065
+ webmcp: true,
1066
+ textRequired: true,
1067
+ ageRequired: true,
1068
+ ageMin: 18,
1069
+ ageMax: 65,
1070
+ onsubmit,
1071
+ },
1072
+ });
1073
+ await tick();
1074
+ await tick();
1075
+ expect(registered).toHaveLength(1);
1076
+ const schema = registered[0].inputSchema;
1077
+ expect(schema.required).toEqual(['fullname', 'age']);
1078
+ expect(schema.properties.age).toMatchObject({
1079
+ type: 'number',
1080
+ minimum: 18,
1081
+ maximum: 65,
1082
+ });
1083
+ expect(await registered[0].execute({ age: 17 })).toBe('Staged 1 change for review');
1084
+ expect(onsubmit).not.toHaveBeenCalled();
1085
+ expect(await registered[0].execute({ fullname: 'Ada', age: 36 })).toBe('Staged 2 changes for review');
1086
+ expect(onsubmit).not.toHaveBeenCalled();
1087
+ });
1088
+ it('does not expose or stage disabled rich fields', async () => {
1089
+ const registered = [];
1090
+ document.modelContext = {
1091
+ registerTool(tool) {
1092
+ registered.push(tool);
1093
+ },
1094
+ };
1095
+ const view = render(FormWithFields, {
1096
+ props: { webmcp: true, textDisabled: false, showAge: false },
1097
+ });
1098
+ await tick();
1099
+ await tick();
1100
+ expect(registered.at(-1)?.inputSchema).toMatchObject({
1101
+ properties: { fullname: { type: 'string' } },
1102
+ });
1103
+ await view.rerender({
1104
+ webmcp: true,
1105
+ textDisabled: true,
1106
+ showAge: false,
1107
+ });
1108
+ await tick();
1109
+ await tick();
1110
+ const tool = registered.at(-1);
1111
+ expect(tool?.inputSchema).toMatchObject({ properties: {} });
1112
+ expect(await tool?.execute({ fullname: 'Ada Lovelace' })).toBe('No reviewable changes provided');
1113
+ expect(screen.getByRole('textbox', { name: 'Full name' })).toBeDisabled();
1114
+ });
1115
+ it('refreshes the WebMCP schema after imperative editability changes', async () => {
1116
+ const registered = [];
1117
+ document.modelContext = {
1118
+ registerTool(tool) {
1119
+ registered.push(tool);
1120
+ },
1121
+ };
1122
+ render(FormWithFields, {
1123
+ props: { webmcp: true, showAge: false },
1124
+ });
1125
+ await tick();
1126
+ await tick();
1127
+ const input = screen.getByRole('textbox', { name: 'Full name' });
1128
+ expect(registered.at(-1)?.inputSchema).toMatchObject({
1129
+ properties: { fullname: expect.any(Object) },
1130
+ });
1131
+ let registrationCount = registered.length;
1132
+ input.setAttribute('disabled', '');
1133
+ await waitFor(() => expect(registered.length).toBeGreaterThan(registrationCount));
1134
+ expect(registered.at(-1)?.inputSchema).toMatchObject({ properties: {} });
1135
+ registrationCount = registered.length;
1136
+ input.removeAttribute('disabled');
1137
+ await waitFor(() => expect(registered.length).toBeGreaterThan(registrationCount));
1138
+ expect(registered.at(-1)?.inputSchema).toMatchObject({
1139
+ properties: { fullname: expect.any(Object) },
1140
+ });
1141
+ registrationCount = registered.length;
1142
+ input.setAttribute('readonly', '');
1143
+ await waitFor(() => expect(registered.length).toBeGreaterThan(registrationCount));
1144
+ expect(registered.at(-1)?.inputSchema).toMatchObject({ properties: {} });
1145
+ });
1146
+ it('does not expose fields disabled by an ancestor fieldset', async () => {
1147
+ const registered = [];
1148
+ document.modelContext = {
1149
+ registerTool(tool) {
1150
+ registered.push(tool);
1151
+ },
1152
+ };
1153
+ render(FormWithFields, {
1154
+ props: {
1155
+ webmcp: true,
1156
+ fieldsetDisabled: true,
1157
+ showAge: false,
1158
+ },
1159
+ });
1160
+ await tick();
1161
+ await tick();
1162
+ const tool = registered.at(-1);
1163
+ expect(tool?.inputSchema).toMatchObject({ properties: {} });
1164
+ expect(await tool?.execute({ fullname: 'Ada Lovelace' })).toBe('No reviewable changes provided');
1165
+ expect(screen.getByRole('textbox', { name: 'Full name' })).toBeDisabled();
1166
+ });
1167
+ it('prevents apply and restores it when DOM ancestry disables the field', async () => {
1168
+ const interactionRegistry = createControlInteractionRegistry();
1169
+ const view = render(FormWithFields, {
1170
+ props: {
1171
+ interactionRegistry,
1172
+ showAge: false,
1173
+ },
1174
+ });
1175
+ await tick();
1176
+ await tick();
1177
+ const fullname = interactionRegistry.list().at(0);
1178
+ if (!fullname)
1179
+ throw new Error('Expected registered full-name control');
1180
+ await interactionRegistry.execute({
1181
+ action: 'stage',
1182
+ identity: fullname.identity,
1183
+ value: 'Ada Lovelace',
1184
+ }, { source: 'agent' });
1185
+ await waitFor(() => expect(screen.getByRole('region', { name: 'Review proposed changes' })).toBeInTheDocument());
1186
+ const fieldset = view.container.querySelector('fieldset');
1187
+ if (!(fieldset instanceof HTMLFieldSetElement)) {
1188
+ throw new Error('Expected fieldset');
1189
+ }
1190
+ fieldset.disabled = true;
1191
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Apply Full name' })).toBeDisabled());
1192
+ expect(screen.getByRole('button', { name: 'Discard Full name' })).not.toBeDisabled();
1193
+ fieldset.disabled = false;
1194
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Apply Full name' })).not.toBeDisabled());
1195
+ });
1196
+ it('uses the form subject for rich-field registration and staging', async () => {
1197
+ const registered = [];
1198
+ document.modelContext = {
1199
+ registerTool(tool) {
1200
+ registered.push(tool);
1201
+ },
1202
+ };
1203
+ const view = render(FormWithFields, {
1204
+ props: {
1205
+ webmcp: true,
1206
+ showAge: false,
1207
+ formSubject: { type: 'person', id: 'person-1' },
1208
+ },
1209
+ });
1210
+ await tick();
1211
+ await tick();
1212
+ expect(await registered.at(-1)?.execute({ fullname: 'Ada' })).toBe('Staged 1 change for review');
1213
+ const fullname = screen.getByRole('textbox', { name: 'Full name' });
1214
+ expect(fullname).toHaveAttribute('data-smrt-subject-type', 'person');
1215
+ expect(fullname).toHaveAttribute('data-smrt-subject-id', 'person-1');
1216
+ expect(await screen.findByText(/person:person-1/)).toBeInTheDocument();
1217
+ await view.rerender({
1218
+ webmcp: true,
1219
+ showAge: false,
1220
+ formSubject: { type: 'person', id: 'person-2' },
1221
+ });
1222
+ await tick();
1223
+ await tick();
1224
+ expect(await registered.at(-1)?.execute({ fullname: 'Grace' })).toBe('Staged 1 change for review');
1225
+ expect(fullname).toHaveAttribute('data-smrt-subject-id', 'person-2');
1226
+ expect(await screen.findByText(/person:person-2/)).toBeInTheDocument();
1227
+ await userEvent.click(screen.getByRole('button', { name: 'Mutate subject' }));
1228
+ await tick();
1229
+ expect(await registered.at(-1)?.execute({ fullname: 'Katherine' })).toBe('Staged 1 change for review');
1230
+ expect(fullname).toHaveAttribute('data-smrt-subject-id', 'person-mutated');
1231
+ expect(await screen.findByText(/person:person-mutated/)).toBeInTheDocument();
1232
+ });
1233
+ it('publishes money in integer minor units and applies cents without rescaling', async () => {
1234
+ const registered = [];
1235
+ document.modelContext = {
1236
+ registerTool(tool) {
1237
+ registered.push(tool);
1238
+ },
1239
+ };
1240
+ const registry = createControlInteractionRegistry({
1241
+ isLocalGesture: () => true,
1242
+ });
1243
+ render(FormWithFields, {
1244
+ props: {
1245
+ webmcp: true,
1246
+ showAge: false,
1247
+ showMoney: true,
1248
+ moneyMin: 100,
1249
+ moneyMax: 5000,
1250
+ interactionRegistry: registry,
1251
+ },
1252
+ });
1253
+ await tick();
1254
+ await tick();
1255
+ expect(registered.at(-1)?.inputSchema).toMatchObject({
1256
+ properties: {
1257
+ budget: {
1258
+ type: 'integer',
1259
+ description: 'A monetary amount in dollars (between $1.00 and $50.00). WebMCP values use integer minor units (cents).',
1260
+ minimum: 100,
1261
+ maximum: 5000,
1262
+ },
1263
+ },
1264
+ });
1265
+ expect(await registered.at(-1)?.execute({ budget: 123.5 })).toBe('Staged 1 change for review');
1266
+ expect(registry
1267
+ .list()
1268
+ .find((snapshot) => snapshot.identity.controlId === 'budget')?.state
1269
+ .staged).toMatchObject({ value: 123.5, valid: false });
1270
+ expect(await registered.at(-1)?.execute({ budget: 1234 })).toBe('Staged 1 change for review');
1271
+ const budget = registry
1272
+ .list()
1273
+ .find((snapshot) => snapshot.identity.controlId === 'budget');
1274
+ expect(budget?.state.staged?.value).toBe(1234);
1275
+ expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
1276
+ {
1277
+ action: 'apply',
1278
+ identity: budget?.identity ?? { formId: '', controlId: '' },
1279
+ revision: budget?.state.staged?.revision,
1280
+ },
1281
+ ], event))).toMatchObject({ ok: true });
1282
+ expect(registry
1283
+ .list()
1284
+ .find((snapshot) => snapshot.identity.controlId === 'budget')?.state
1285
+ .value).toBe(1234);
1286
+ });
1287
+ it('keeps the browser path a no-op without WebMCP', async () => {
1288
+ const onsubmit = vi.fn();
1289
+ const view = render(FormWithFields, { props: { onsubmit } });
1290
+ await userEvent.click(view.getByRole('button', { name: 'Submit' }));
1291
+ expect(onsubmit).toHaveBeenCalledTimes(1);
1292
+ });
1293
+ it('publishes and accepts structured measurement, date range, and address fields', async () => {
1294
+ const registered = [];
1295
+ document.modelContext = {
1296
+ async registerTool(tool) {
1297
+ registered.push(tool);
1298
+ },
1299
+ };
1300
+ const onsubmit = vi.fn();
1301
+ render(FormWithStructuredFields, { props: { webmcp: true, onsubmit } });
1302
+ await tick();
1303
+ await tick();
1304
+ expect(registered).toHaveLength(1);
1305
+ const schema = registered[0].inputSchema;
1306
+ expect(schema.required).toEqual(['measurement', 'dates', 'address']);
1307
+ expect(schema.properties.measurement).toMatchObject({
1308
+ type: 'object',
1309
+ properties: {
1310
+ value: { type: 'number' },
1311
+ unit: { type: 'string' },
1312
+ },
1313
+ required: ['value', 'unit'],
1314
+ });
1315
+ expect(schema.properties.dates).toMatchObject({
1316
+ type: 'object',
1317
+ properties: {
1318
+ startDate: { type: 'string' },
1319
+ endDate: { type: 'string' },
1320
+ },
1321
+ required: ['startDate', 'endDate'],
1322
+ });
1323
+ expect(schema.properties.address).toMatchObject({
1324
+ type: 'object',
1325
+ additionalProperties: false,
1326
+ properties: {
1327
+ street: { type: 'string' },
1328
+ city: { type: 'string' },
1329
+ province: { type: 'string' },
1330
+ postalCode: { type: 'string' },
1331
+ country: { type: 'string' },
1332
+ },
1333
+ required: ['street', 'city', 'province', 'postalCode', 'country'],
1334
+ });
1335
+ expect(schema.properties.address.properties.country.enum).toEqual(['CA', 'US']);
1336
+ expect(schema.properties.address.properties.province.enum).toEqual(expect.arrayContaining(['AB', 'BC', 'ON', 'QC', 'US-CA', 'US-NY']));
1337
+ const values = {
1338
+ measurement: { value: 1.5, unit: 'm' },
1339
+ dates: { startDate: '2026-01-01', endDate: '2026-01-02' },
1340
+ address: {
1341
+ street: '1 Main St',
1342
+ city: 'Edmonton',
1343
+ province: 'AB',
1344
+ postalCode: 'T1A 1A1',
1345
+ country: 'CA',
1346
+ },
1347
+ };
1348
+ expect(await registered[0].execute(values)).toBe('Staged 3 changes for review');
1349
+ expect(onsubmit).not.toHaveBeenCalled();
1350
+ });
1351
+ it('keeps address option schemas and validation live across rerenders', async () => {
1352
+ const registered = [];
1353
+ document.modelContext = {
1354
+ registerTool(tool) {
1355
+ registered.push(tool);
1356
+ },
1357
+ };
1358
+ const addressChanged = vi.fn();
1359
+ const registry = createControlInteractionRegistry({
1360
+ isLocalGesture: () => true,
1361
+ });
1362
+ const view = render(FormWithStructuredFields, {
1363
+ props: {
1364
+ webmcp: true,
1365
+ structuredRequired: false,
1366
+ addressFields: ['province', 'country'],
1367
+ addressCountries: [{ value: 'CA', label: 'Canada' }],
1368
+ addressProvinces: [{ value: 'AB', label: 'Alberta' }],
1369
+ interactionRegistry: registry,
1370
+ onaddresschange: addressChanged,
1371
+ },
1372
+ });
1373
+ await tick();
1374
+ await tick();
1375
+ const addressProperties = () => {
1376
+ const tool = registered.at(-1);
1377
+ if (!tool)
1378
+ throw new Error('WebMCP tool was not registered');
1379
+ return tool.inputSchema.properties.address.properties;
1380
+ };
1381
+ expect(addressProperties()).toEqual({
1382
+ province: { type: 'string', enum: ['', 'AB'] },
1383
+ country: { type: 'string', enum: ['CA'] },
1384
+ });
1385
+ await registered.at(-1)?.execute({
1386
+ address: { province: 'AB', country: 'CA' },
1387
+ });
1388
+ const identity = { formId: 'structured-fields', controlId: 'address' };
1389
+ expect(registry.get(identity)?.state.staged).toMatchObject({ valid: true });
1390
+ await view.rerender({
1391
+ webmcp: true,
1392
+ structuredRequired: false,
1393
+ addressFields: ['province', 'country'],
1394
+ addressCountries: [{ value: 'FR', label: 'France' }],
1395
+ addressProvinces: [{ value: 'IDF', label: 'Île-de-France' }],
1396
+ interactionRegistry: registry,
1397
+ onaddresschange: addressChanged,
1398
+ });
1399
+ await tick();
1400
+ await tick();
1401
+ expect(addressProperties()).toEqual({
1402
+ province: { type: 'string', enum: ['', 'IDF'] },
1403
+ country: { type: 'string', enum: ['FR'] },
1404
+ });
1405
+ expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, {
1406
+ action: 'apply',
1407
+ identity,
1408
+ revision: registry.get(identity)?.state.staged?.revision,
1409
+ }, event))).toMatchObject({ ok: false });
1410
+ expect(addressChanged).not.toHaveBeenCalled();
1411
+ await registered.at(-1)?.execute({
1412
+ address: { province: 'IDF', country: 'FR' },
1413
+ });
1414
+ expect(registry.get(identity)?.state.staged).toMatchObject({ valid: true });
1415
+ expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, {
1416
+ action: 'apply',
1417
+ identity,
1418
+ revision: registry.get(identity)?.state.staged?.revision,
1419
+ }, event))).toMatchObject({ ok: true });
1420
+ expect(addressChanged).toHaveBeenLastCalledWith({
1421
+ province: 'IDF',
1422
+ country: 'FR',
1423
+ });
1424
+ await registered.at(-1)?.execute({
1425
+ address: { province: 'XX', country: 'FR' },
1426
+ });
1427
+ expect(registry.get(identity)?.state.staged).toMatchObject({
1428
+ valid: false,
1429
+ });
1430
+ });
1431
+ it('omits zero-based multipleOf when measurement steps use a minimum offset', async () => {
1432
+ const registered = [];
1433
+ const registry = createControlInteractionRegistry();
1434
+ document.modelContext = {
1435
+ registerTool(tool) {
1436
+ registered.push(tool);
1437
+ },
1438
+ };
1439
+ const view = render(FormWithStructuredFields, {
1440
+ props: {
1441
+ webmcp: true,
1442
+ structuredRequired: false,
1443
+ measurementMin: 1,
1444
+ measurementStep: 2,
1445
+ interactionRegistry: registry,
1446
+ },
1447
+ });
1448
+ await tick();
1449
+ await tick();
1450
+ const valueSchema = () => {
1451
+ const tool = registered.at(-1);
1452
+ if (!tool)
1453
+ throw new Error('WebMCP tool was not registered');
1454
+ return tool.inputSchema.properties.measurement.properties.value;
1455
+ };
1456
+ expect(valueSchema()).toMatchObject({ type: 'number', minimum: 1 });
1457
+ expect(valueSchema()).not.toHaveProperty('multipleOf');
1458
+ expect(await registered.at(-1)?.execute({
1459
+ measurement: { value: 3, unit: 'ft' },
1460
+ })).toBe('Staged 1 change for review');
1461
+ const measurement = registry.get({
1462
+ formId: 'structured-fields',
1463
+ controlId: 'measurement',
1464
+ });
1465
+ expect(measurement?.state.staged).toMatchObject({ valid: true });
1466
+ await registered.at(-1)?.execute({
1467
+ measurement: { value: 2, unit: 'ft' },
1468
+ });
1469
+ expect(registry.get({
1470
+ formId: 'structured-fields',
1471
+ controlId: 'measurement',
1472
+ })?.state.staged).toMatchObject({ valid: false });
1473
+ await view.rerender({
1474
+ webmcp: true,
1475
+ structuredRequired: false,
1476
+ measurementMin: 2,
1477
+ measurementStep: 2,
1478
+ interactionRegistry: registry,
1479
+ });
1480
+ await tick();
1481
+ await tick();
1482
+ expect(valueSchema()).toMatchObject({
1483
+ type: 'number',
1484
+ minimum: 2,
1485
+ multipleOf: 2,
1486
+ });
1487
+ });
1488
+ it('stages through an additive legacy registry without executeBatch', async () => {
1489
+ const registered = [];
1490
+ document.modelContext = {
1491
+ registerTool(tool) {
1492
+ registered.push(tool);
1493
+ },
1494
+ };
1495
+ const baseRegistry = createControlInteractionRegistry({
1496
+ isLocalGesture: () => true,
1497
+ });
1498
+ const { executeBatch: _executeBatch, ...legacyRegistry } = baseRegistry;
1499
+ render(FormWithFields, {
1500
+ props: {
1501
+ webmcp: true,
1502
+ showAge: false,
1503
+ interactionRegistry: legacyRegistry,
1504
+ },
1505
+ });
1506
+ await tick();
1507
+ await tick();
1508
+ expect(await registered.at(-1)?.execute({ fullname: 'Ada' })).toBe('Staged 1 change for review');
1509
+ expect(legacyRegistry.list()[0]?.state.staged?.value).toBe('Ada');
1510
+ await userEvent.click(screen.getByRole('button', { name: 'Apply Full name' }));
1511
+ await waitFor(() => expect(screen.getByRole('textbox', { name: 'Full name' })).toHaveValue('Ada'));
1512
+ expect(legacyRegistry.list()[0]?.state.staged).toBeUndefined();
1513
+ });
1514
+ it('limits the address schema and payload to configured fields', async () => {
1515
+ const registered = [];
1516
+ document.modelContext = {
1517
+ async registerTool(tool) {
1518
+ registered.push(tool);
1519
+ },
1520
+ };
1521
+ const onsubmit = vi.fn();
1522
+ const registry = createControlInteractionRegistry();
1523
+ render(FormWithStructuredFields, {
1524
+ props: {
1525
+ webmcp: true,
1526
+ addressFields: ['city', 'country'],
1527
+ onsubmit,
1528
+ interactionRegistry: registry,
1529
+ },
1530
+ });
1531
+ await tick();
1532
+ await tick();
1533
+ expect(registered).toHaveLength(1);
1534
+ const schema = registered[0].inputSchema;
1535
+ expect(schema.properties.address).toMatchObject({
1536
+ type: 'object',
1537
+ properties: {
1538
+ city: { type: 'string' },
1539
+ country: { type: 'string' },
1540
+ },
1541
+ required: ['city', 'country'],
1542
+ });
1543
+ expect(schema.properties.address.properties).not.toHaveProperty('street');
1544
+ expect(schema.properties.address.properties.country.enum).toEqual(['CA', 'US']);
1545
+ const values = {
1546
+ measurement: { value: 1.5, unit: 'm' },
1547
+ dates: { startDate: '2026-01-01', endDate: '2026-01-02' },
1548
+ address: { city: 'Edmonton', country: 'CA', street: 'hidden' },
1549
+ };
1550
+ expect(await registered[0].execute(values)).toBe('Staged 3 changes for review');
1551
+ expect(onsubmit).not.toHaveBeenCalled();
1552
+ expect(registry
1553
+ .list('structured-fields')
1554
+ .find((snapshot) => snapshot.identity.controlId === 'address')?.state
1555
+ .staged?.value).toEqual({ city: 'Edmonton', country: 'CA' });
1556
+ });
1557
+ it('updates configured rich-field schemas after prop changes', async () => {
1558
+ const registered = [];
1559
+ const registry = createControlInteractionRegistry();
1560
+ document.modelContext = {
1561
+ registerTool(tool) {
1562
+ registered.push(tool);
1563
+ },
1564
+ };
1565
+ const view = render(FormWithStructuredFields, {
1566
+ props: {
1567
+ webmcp: true,
1568
+ addressFields: ['city'],
1569
+ measurementUnits: ['m'],
1570
+ measurementLabel: 'Height',
1571
+ measurementMin: 0,
1572
+ measurementMax: 10,
1573
+ interactionRegistry: registry,
1574
+ },
1575
+ });
1576
+ await tick();
1577
+ await tick();
1578
+ await view.rerender({
1579
+ webmcp: true,
1580
+ addressFields: ['country'],
1581
+ measurementUnits: ['ft'],
1582
+ measurementLabel: 'Weight',
1583
+ measurementMin: 100,
1584
+ measurementMax: 200,
1585
+ interactionRegistry: registry,
1586
+ });
1587
+ await tick();
1588
+ await tick();
1589
+ const schema = registered.at(-1)?.inputSchema;
1590
+ expect(schema.properties.address.properties).toEqual({
1591
+ country: { type: 'string', enum: ['CA', 'US'] },
1592
+ });
1593
+ expect(schema.properties.measurement.properties.unit.enum).toEqual(['ft']);
1594
+ expect(schema.properties.measurement).toMatchObject({
1595
+ title: 'Weight',
1596
+ description: expect.stringContaining('between 100 and 200'),
1597
+ });
1598
+ expect(schema.properties.measurement.properties.value).toMatchObject({
1599
+ minimum: 100,
1600
+ maximum: 200,
1601
+ });
1602
+ expect(registry
1603
+ .list('structured-fields')
1604
+ .find((snapshot) => snapshot.identity.controlId === 'measurement')
1605
+ ?.metadata).toMatchObject({
1606
+ label: 'Weight',
1607
+ description: expect.stringContaining('between 100 and 200'),
1608
+ });
1609
+ });
1610
+ it('updates ordinary rich-field schemas after prop changes', async () => {
1611
+ const registered = [];
1612
+ document.modelContext = {
1613
+ registerTool(tool) {
1614
+ registered.push(tool);
1615
+ },
1616
+ };
1617
+ const view = render(FormWithFields, {
1618
+ props: {
1619
+ webmcp: true,
1620
+ ageLabel: 'Minimum age',
1621
+ ageMax: 65,
1622
+ textRequired: false,
1623
+ },
1624
+ });
1625
+ await tick();
1626
+ await tick();
1627
+ await view.rerender({
1628
+ webmcp: true,
1629
+ ageLabel: 'Maximum age',
1630
+ ageMax: 100,
1631
+ textRequired: true,
1632
+ });
1633
+ await tick();
1634
+ await tick();
1635
+ const schema = registered.at(-1)?.inputSchema;
1636
+ expect(schema.required).toContain('fullname');
1637
+ expect(schema.properties.age).toMatchObject({
1638
+ title: 'Maximum age',
1639
+ maximum: 100,
1640
+ description: expect.stringContaining('maximum 100'),
1641
+ });
1642
+ });
1643
+ it('tightens live field policy without re-registering the field', async () => {
1644
+ const registered = [];
1645
+ document.modelContext = {
1646
+ registerTool(tool) {
1647
+ registered.push(tool);
1648
+ },
1649
+ };
1650
+ const registry = createControlInteractionRegistry({
1651
+ isLocalGesture: () => true,
1652
+ });
1653
+ const view = render(FormWithPolicyField, {
1654
+ props: { webmcp: true, interactionRegistry: registry },
1655
+ });
1656
+ await tick();
1657
+ await tick();
1658
+ expect(await registered.at(-1)?.execute({ policy: 'Grace' })).toBe('Staged 1 change for review');
1659
+ const revision = registry.get({
1660
+ formId: 'policy-form',
1661
+ controlId: 'policy',
1662
+ })?.state.staged?.revision;
1663
+ expect(revision).toBeDefined();
1664
+ await view.rerender({
1665
+ webmcp: true,
1666
+ interactionRegistry: registry,
1667
+ sensitivity: 'secret',
1668
+ writable: false,
1669
+ });
1670
+ await tick();
1671
+ await tick();
1672
+ expect(registered.at(-1)?.inputSchema).toMatchObject({ properties: {} });
1673
+ const snapshot = registry.get({
1674
+ formId: 'policy-form',
1675
+ controlId: 'policy',
1676
+ });
1677
+ expect(snapshot?.metadata).toMatchObject({
1678
+ sensitivity: 'secret',
1679
+ writable: false,
1680
+ });
1681
+ expect(snapshot?.state).toMatchObject({
1682
+ value: undefined,
1683
+ valueRedacted: true,
1684
+ });
1685
+ expect(snapshot?.state).not.toHaveProperty('staged');
1686
+ expect(snapshot?.state).not.toHaveProperty('stagedValue');
1687
+ expect(screen.queryByRole('region', { name: 'Review proposed changes' })).not.toBeInTheDocument();
1688
+ await expect(dispatchLocalGesture((event) => executeLocalControlCommand(registry, {
1689
+ action: 'discard',
1690
+ identity: { formId: 'policy-form', controlId: 'policy' },
1691
+ revision,
1692
+ }, event))).resolves.toMatchObject({ ok: true, action: 'discard' });
1693
+ await view.rerender({
1694
+ webmcp: true,
1695
+ interactionRegistry: registry,
1696
+ sensitivity: 'public',
1697
+ writable: true,
1698
+ });
1699
+ await tick();
1700
+ await tick();
1701
+ expect(registry.get({ formId: 'policy-form', controlId: 'policy' })?.state).not.toHaveProperty('staged');
1702
+ });
1703
+ it('allows partial payloads for optional structured fields', async () => {
1704
+ const registered = [];
1705
+ document.modelContext = {
1706
+ async registerTool(tool) {
1707
+ registered.push(tool);
1708
+ },
1709
+ };
1710
+ const onsubmit = vi.fn();
1711
+ const registry = createControlInteractionRegistry({
1712
+ isLocalGesture: () => true,
1713
+ });
1714
+ render(FormWithStructuredFields, {
1715
+ props: {
1716
+ webmcp: true,
1717
+ structuredRequired: false,
1718
+ onsubmit,
1719
+ interactionRegistry: registry,
1720
+ },
1721
+ });
1722
+ await tick();
1723
+ await tick();
1724
+ expect(registered).toHaveLength(1);
1725
+ const schema = registered[0].inputSchema;
1726
+ expect(schema.properties.measurement).not.toHaveProperty('required');
1727
+ expect(schema.properties.dates).not.toHaveProperty('required');
1728
+ expect(schema.properties.address).not.toHaveProperty('required');
1729
+ const partialValues = {
1730
+ measurement: { value: 1.5 },
1731
+ dates: { startDate: '2026-01-01' },
1732
+ address: { city: 'Edmonton' },
1733
+ };
1734
+ expect(await registered[0].execute(partialValues)).toBe('Staged 3 changes for review');
1735
+ expect(onsubmit).not.toHaveBeenCalled();
1736
+ const staged = registry
1737
+ .list('structured-fields')
1738
+ .filter((snapshot) => snapshot.state.staged)
1739
+ .map((snapshot) => ({
1740
+ action: 'apply',
1741
+ identity: snapshot.identity,
1742
+ revision: snapshot.state.staged?.revision,
1743
+ }));
1744
+ const applied = await dispatchLocalGesture((event) => executeLocalControlBatch(registry, staged, event));
1745
+ expect(applied.results).toEqual(staged.map((command) => expect.objectContaining({
1746
+ ok: true,
1747
+ identity: command.identity,
1748
+ })));
1749
+ expect(registry.list('structured-fields')).toEqual(expect.arrayContaining([
1750
+ expect.objectContaining({
1751
+ identity: expect.objectContaining({ controlId: 'dates' }),
1752
+ state: expect.objectContaining({
1753
+ value: { startDate: '2026-01-01', endDate: '' },
1754
+ }),
1755
+ }),
1756
+ expect.objectContaining({
1757
+ identity: expect.objectContaining({ controlId: 'address' }),
1758
+ state: expect.objectContaining({
1759
+ value: expect.objectContaining({
1760
+ city: 'Edmonton',
1761
+ country: 'CA',
1762
+ }),
1763
+ }),
1764
+ }),
1765
+ ]));
1766
+ });
1767
+ it('does not expose structured fields disabled by an ancestor fieldset', async () => {
1768
+ appState.mode = 'smrt';
1769
+ const registered = [];
1770
+ document.modelContext = {
1771
+ registerTool(tool) {
1772
+ registered.push(tool);
1773
+ },
1774
+ };
1775
+ render(FormWithStructuredFields, {
1776
+ props: { webmcp: true, fieldsetDisabled: true },
1777
+ });
1778
+ await tick();
1779
+ await tick();
1780
+ const tool = registered.at(-1);
1781
+ expect(tool?.inputSchema).toMatchObject({ properties: {} });
1782
+ expect(await tool?.execute({ address: { city: 'Edmonton' } })).toBe('No reviewable changes provided');
1783
+ });
1784
+ it('prevents applying a staged address when any matched child control becomes disabled', async () => {
1785
+ const interactionRegistry = createControlInteractionRegistry({
1786
+ isLocalGesture: () => true,
1787
+ });
1788
+ render(FormWithStructuredFields, {
1789
+ props: { interactionRegistry },
1790
+ });
1791
+ await tick();
1792
+ await tick();
1793
+ const address = interactionRegistry
1794
+ .list('structured-fields')
1795
+ .find((snapshot) => snapshot.identity.controlId === 'address');
1796
+ if (!address)
1797
+ throw new Error('Expected registered address control');
1798
+ await interactionRegistry.execute({
1799
+ action: 'stage',
1800
+ identity: address.identity,
1801
+ value: { city: 'Edmonton' },
1802
+ }, { source: 'agent' });
1803
+ await waitFor(() => expect(screen.getByRole('region', { name: 'Review proposed changes' })).toBeInTheDocument());
1804
+ const city = screen.getByRole('textbox', { name: 'City' });
1805
+ city.setAttribute('disabled', '');
1806
+ await waitFor(() => {
1807
+ expect(interactionRegistry.get(address.identity)?.state.disabled).toBe(true);
1808
+ expect(screen.getByRole('button', { name: 'Apply Address' })).toBeDisabled();
1809
+ });
1810
+ expect(screen.getByRole('button', { name: 'Discard Address' })).not.toBeDisabled();
1811
+ });
1812
+ it('does not let a colliding sibling name mask a disabled rich field', async () => {
1813
+ const registered = [];
1814
+ document.modelContext = {
1815
+ registerTool(tool) {
1816
+ registered.push(tool);
1817
+ },
1818
+ };
1819
+ render(FormWithStructuredFields, {
1820
+ props: {
1821
+ webmcp: true,
1822
+ fieldsetDisabled: true,
1823
+ measurementName: 'user',
1824
+ showCollidingSibling: true,
1825
+ },
1826
+ });
1827
+ await tick();
1828
+ await tick();
1829
+ expect(registered.at(-1)?.inputSchema).toMatchObject({ properties: {} });
1830
+ expect(await registered.at(-1)?.execute({ user: { value: 2, unit: 'm' } })).toBe('No reviewable changes provided');
1831
+ });
1832
+ it('gives exact registered names precedence over composite ownership', async () => {
1833
+ const registered = [];
1834
+ const registry = createControlInteractionRegistry();
1835
+ document.modelContext = {
1836
+ registerTool(tool) {
1837
+ registered.push(tool);
1838
+ },
1839
+ };
1840
+ render(FormWithStructuredFields, {
1841
+ props: {
1842
+ webmcp: true,
1843
+ structuredRequired: false,
1844
+ fieldsetDisabled: true,
1845
+ measurementName: 'weight',
1846
+ showExactNameCollisions: true,
1847
+ interactionRegistry: registry,
1848
+ },
1849
+ });
1850
+ await tick();
1851
+ await tick();
1852
+ const tool = registered.at(-1);
1853
+ if (!tool)
1854
+ throw new Error('WebMCP tool was not registered');
1855
+ const properties = tool.inputSchema.properties;
1856
+ expect(properties).toHaveProperty('address[city]');
1857
+ expect(properties).toHaveProperty('weight_unit');
1858
+ expect(properties).not.toHaveProperty('address');
1859
+ expect(properties).not.toHaveProperty('weight');
1860
+ const exactAddress = screen.getByRole('textbox', {
1861
+ name: 'Exact address city',
1862
+ });
1863
+ const exactUnit = screen.getByRole('textbox', {
1864
+ name: 'Exact measurement unit',
1865
+ });
1866
+ expect(exactAddress).toHaveAttribute('data-smrt-control', 'address[city]');
1867
+ expect(exactUnit).toHaveAttribute('data-smrt-control', 'weight_unit');
1868
+ expect(screen.getByRole('textbox', { name: 'City' })).toHaveAttribute('data-smrt-control', 'address');
1869
+ expect(registry.get({
1870
+ formId: 'structured-fields',
1871
+ controlId: 'address[city]',
1872
+ })?.state.disabled).toBe(false);
1873
+ expect(registry.get({
1874
+ formId: 'structured-fields',
1875
+ controlId: 'weight_unit',
1876
+ })?.state.disabled).toBe(false);
1877
+ });
1878
+ it('keeps a base control with a composite-looking name outside rich ownership', async () => {
1879
+ const registry = createControlInteractionRegistry();
1880
+ render(FormWithStructuredFields, {
1881
+ props: {
1882
+ interactionRegistry: registry,
1883
+ fieldsetDisabled: true,
1884
+ showBaseControlCollision: true,
1885
+ },
1886
+ });
1887
+ await tick();
1888
+ await tick();
1889
+ expect(registry.get({ formId: 'structured-fields', controlId: 'address' })?.state
1890
+ .disabled).toBe(true);
1891
+ expect(registry.get({
1892
+ formId: 'structured-fields',
1893
+ controlId: 'address[city]',
1894
+ })?.state.disabled).toBe(false);
1895
+ expect(screen.getByRole('textbox', { name: 'City' })).toHaveAttribute('data-smrt-control', 'address');
1896
+ expect(screen.getByTestId('base-address-city')).toHaveAttribute('data-smrt-control', 'address[city]');
1897
+ });
1898
+ it('moves a built-in registration when its name changes', async () => {
1899
+ const registry = createControlInteractionRegistry();
1900
+ const view = render(FormRegistrationLifecycle, {
1901
+ props: { interactionRegistry: registry, firstName: 'first' },
1902
+ });
1903
+ await tick();
1904
+ await tick();
1905
+ expect(registry.get({ formId: 'registration-lifecycle', controlId: 'first' })).toBeDefined();
1906
+ await view.rerender({
1907
+ interactionRegistry: registry,
1908
+ firstName: 'renamed',
1909
+ });
1910
+ await tick();
1911
+ await tick();
1912
+ expect(registry.get({ formId: 'registration-lifecycle', controlId: 'first' })).toBeUndefined();
1913
+ expect(registry.get({ formId: 'registration-lifecycle', controlId: 'renamed' })).toBeDefined();
1914
+ });
1915
+ it('does not let stale cleanup remove a same-name replacement', async () => {
1916
+ const registry = createControlInteractionRegistry();
1917
+ const view = render(FormRegistrationLifecycle, {
1918
+ props: {
1919
+ interactionRegistry: registry,
1920
+ firstName: 'shared',
1921
+ showFirst: true,
1922
+ showReplacement: false,
1923
+ },
1924
+ });
1925
+ await tick();
1926
+ await tick();
1927
+ await view.rerender({
1928
+ interactionRegistry: registry,
1929
+ firstName: 'shared',
1930
+ showFirst: true,
1931
+ showReplacement: true,
1932
+ });
1933
+ await tick();
1934
+ await tick();
1935
+ expect(registry.get({ formId: 'registration-lifecycle', controlId: 'shared' })
1936
+ ?.metadata.label).toBe('Replacement field');
1937
+ await view.rerender({
1938
+ interactionRegistry: registry,
1939
+ firstName: 'shared',
1940
+ showFirst: false,
1941
+ showReplacement: true,
1942
+ });
1943
+ await tick();
1944
+ await tick();
1945
+ expect(registry.get({ formId: 'registration-lifecycle', controlId: 'shared' })
1946
+ ?.metadata.label).toBe('Replacement field');
1947
+ });
1948
+ it('does not let stale custom cleanup remove its replacement', async () => {
1949
+ const registry = createControlInteractionRegistry();
1950
+ const view = render(FormRegistrationLifecycle, {
1951
+ props: {
1952
+ interactionRegistry: registry,
1953
+ showFirst: false,
1954
+ showCustomFirst: true,
1955
+ showCustomReplacement: false,
1956
+ },
1957
+ });
1958
+ await tick();
1959
+ await tick();
1960
+ await view.rerender({
1961
+ interactionRegistry: registry,
1962
+ showFirst: false,
1963
+ showCustomFirst: true,
1964
+ showCustomReplacement: true,
1965
+ });
1966
+ await tick();
1967
+ await tick();
1968
+ expect(registry.get({
1969
+ formId: 'registration-lifecycle',
1970
+ controlId: 'custom-shared',
1971
+ })?.metadata.label).toBe('Custom replacement');
1972
+ await view.rerender({
1973
+ interactionRegistry: registry,
1974
+ showFirst: false,
1975
+ showCustomFirst: false,
1976
+ showCustomReplacement: true,
1977
+ });
1978
+ await tick();
1979
+ await tick();
1980
+ expect(registry.get({
1981
+ formId: 'registration-lifecycle',
1982
+ controlId: 'custom-shared',
1983
+ })?.metadata.label).toBe('Custom replacement');
1984
+ });
1985
+ it('supports ordinary legacy register and unregister cleanup', async () => {
1986
+ const registry = createControlInteractionRegistry();
1987
+ const view = render(FormRegistrationLifecycle, {
1988
+ props: {
1989
+ interactionRegistry: registry,
1990
+ showFirst: false,
1991
+ showCustomFirst: true,
1992
+ legacyCustomCleanup: true,
1993
+ },
1994
+ });
1995
+ await tick();
1996
+ await tick();
1997
+ expect(registry.get({
1998
+ formId: 'registration-lifecycle',
1999
+ controlId: 'custom-shared',
2000
+ })).toBeDefined();
2001
+ await view.rerender({
2002
+ interactionRegistry: registry,
2003
+ showFirst: false,
2004
+ showCustomFirst: false,
2005
+ legacyCustomCleanup: true,
2006
+ });
2007
+ await tick();
2008
+ await tick();
2009
+ expect(registry.get({
2010
+ formId: 'registration-lifecycle',
2011
+ controlId: 'custom-shared',
2012
+ })).toBeUndefined();
2013
+ });
2014
+ it('does not let stale legacy unregister remove its replacement', async () => {
2015
+ const registry = createControlInteractionRegistry();
2016
+ const view = render(FormRegistrationLifecycle, {
2017
+ props: {
2018
+ interactionRegistry: registry,
2019
+ showFirst: false,
2020
+ showCustomFirst: true,
2021
+ showCustomReplacement: false,
2022
+ legacyCustomCleanup: true,
2023
+ },
2024
+ });
2025
+ await tick();
2026
+ await tick();
2027
+ await view.rerender({
2028
+ interactionRegistry: registry,
2029
+ showFirst: false,
2030
+ showCustomFirst: true,
2031
+ showCustomReplacement: true,
2032
+ legacyCustomCleanup: true,
2033
+ });
2034
+ await tick();
2035
+ await tick();
2036
+ await view.rerender({
2037
+ interactionRegistry: registry,
2038
+ showFirst: false,
2039
+ showCustomFirst: false,
2040
+ showCustomReplacement: true,
2041
+ legacyCustomCleanup: true,
2042
+ });
2043
+ await tick();
2044
+ await tick();
2045
+ expect(registry.get({
2046
+ formId: 'registration-lifecycle',
2047
+ controlId: 'custom-shared',
2048
+ })?.metadata.label).toBe('Custom replacement');
2049
+ await view.rerender({
2050
+ interactionRegistry: registry,
2051
+ showFirst: false,
2052
+ showCustomFirst: false,
2053
+ showCustomReplacement: false,
2054
+ legacyCustomCleanup: true,
2055
+ });
2056
+ await tick();
2057
+ await tick();
2058
+ expect(registry.get({
2059
+ formId: 'registration-lifecycle',
2060
+ controlId: 'custom-shared',
2061
+ })).toBeUndefined();
2062
+ });
2063
+ it('restores the older legacy registration when its replacement unmounts first', async () => {
2064
+ const registry = createControlInteractionRegistry();
2065
+ const view = render(FormRegistrationLifecycle, {
2066
+ props: {
2067
+ interactionRegistry: registry,
2068
+ showFirst: false,
2069
+ showCustomFirst: true,
2070
+ showCustomReplacement: true,
2071
+ legacyCustomCleanup: true,
2072
+ },
2073
+ });
2074
+ await tick();
2075
+ await tick();
2076
+ expect(registry.get({
2077
+ formId: 'registration-lifecycle',
2078
+ controlId: 'custom-shared',
2079
+ })?.metadata.label).toBe('Custom replacement');
2080
+ await view.rerender({
2081
+ interactionRegistry: registry,
2082
+ showFirst: false,
2083
+ showCustomFirst: true,
2084
+ showCustomReplacement: false,
2085
+ legacyCustomCleanup: true,
2086
+ });
2087
+ await tick();
2088
+ await tick();
2089
+ expect(registry.get({
2090
+ formId: 'registration-lifecycle',
2091
+ controlId: 'custom-shared',
2092
+ })?.metadata.label).toBe('Custom first');
2093
+ await view.rerender({
2094
+ interactionRegistry: registry,
2095
+ showFirst: false,
2096
+ showCustomFirst: false,
2097
+ showCustomReplacement: false,
2098
+ legacyCustomCleanup: true,
2099
+ });
2100
+ await tick();
2101
+ await tick();
2102
+ expect(registry.get({
2103
+ formId: 'registration-lifecycle',
2104
+ controlId: 'custom-shared',
2105
+ })).toBeUndefined();
2106
+ });
2107
+ it('binds repeated context accessor cleanup to the same legacy registration', async () => {
2108
+ const registry = createControlInteractionRegistry();
2109
+ const view = render(FormRegistrationLifecycle, {
2110
+ props: {
2111
+ interactionRegistry: registry,
2112
+ showFirst: false,
2113
+ showCustomFirst: true,
2114
+ legacyCustomCleanup: true,
2115
+ separateLegacyCleanupContext: true,
2116
+ },
2117
+ });
2118
+ await tick();
2119
+ await tick();
2120
+ expect(registry.get({
2121
+ formId: 'registration-lifecycle',
2122
+ controlId: 'custom-shared',
2123
+ })?.metadata.label).toBe('Custom first');
2124
+ await view.rerender({
2125
+ interactionRegistry: registry,
2126
+ showFirst: false,
2127
+ showCustomFirst: false,
2128
+ legacyCustomCleanup: true,
2129
+ separateLegacyCleanupContext: true,
2130
+ });
2131
+ await tick();
2132
+ await tick();
2133
+ expect(registry.get({
2134
+ formId: 'registration-lifecycle',
2135
+ controlId: 'custom-shared',
2136
+ })).toBeUndefined();
2137
+ });
2138
+ it('keeps descendant legacy cleanup isolated after a compound field reads context', async () => {
2139
+ const registry = createControlInteractionRegistry();
2140
+ const view = render(FormRegistrationLifecycle, {
2141
+ props: {
2142
+ interactionRegistry: registry,
2143
+ showFirst: false,
2144
+ showCustomFirst: true,
2145
+ showCustomReplacement: true,
2146
+ legacyCustomCleanup: true,
2147
+ compoundLegacyCustom: true,
2148
+ },
2149
+ });
2150
+ await tick();
2151
+ await tick();
2152
+ expect(registry.get({
2153
+ formId: 'registration-lifecycle',
2154
+ controlId: 'custom-shared',
2155
+ })?.metadata.label).toBe('Custom replacement');
2156
+ await view.rerender({
2157
+ interactionRegistry: registry,
2158
+ showFirst: false,
2159
+ showCustomFirst: true,
2160
+ showCustomReplacement: false,
2161
+ legacyCustomCleanup: true,
2162
+ compoundLegacyCustom: true,
2163
+ });
2164
+ await tick();
2165
+ await tick();
2166
+ expect(registry.get({
2167
+ formId: 'registration-lifecycle',
2168
+ controlId: 'custom-shared',
2169
+ })?.metadata.label).toBe('Custom first');
2170
+ await view.rerender({
2171
+ interactionRegistry: registry,
2172
+ showFirst: false,
2173
+ showCustomFirst: false,
2174
+ showCustomReplacement: false,
2175
+ legacyCustomCleanup: true,
2176
+ compoundLegacyCustom: true,
2177
+ });
2178
+ await tick();
2179
+ await tick();
2180
+ expect(registry.get({
2181
+ formId: 'registration-lifecycle',
2182
+ controlId: 'custom-shared',
2183
+ })).toBeUndefined();
2184
+ });
2185
+ it('cleans up built-ins against a legacy void-returning context', async () => {
2186
+ const registered = vi.fn();
2187
+ const unregistered = vi.fn();
2188
+ const view = render(LegacyFormContext, {
2189
+ props: { onregister: registered, onunregister: unregistered },
2190
+ });
2191
+ await tick();
2192
+ expect(registered).toHaveBeenCalledWith('legacy-field');
2193
+ await view.rerender({
2194
+ showField: false,
2195
+ onregister: registered,
2196
+ onunregister: unregistered,
2197
+ });
2198
+ await tick();
2199
+ expect(unregistered).toHaveBeenCalledWith('legacy-field');
2200
+ });
2201
+ it('removes a smrt-mode date range when its fieldset becomes disabled', async () => {
2202
+ appState.mode = 'smrt';
2203
+ const registered = [];
2204
+ document.modelContext = {
2205
+ registerTool(tool) {
2206
+ registered.push(tool);
2207
+ },
2208
+ };
2209
+ const view = render(FormWithStructuredFields, {
2210
+ props: { webmcp: true },
2211
+ });
2212
+ await tick();
2213
+ await tick();
2214
+ expect(registered.at(-1)?.inputSchema).toMatchObject({
2215
+ properties: { dates: expect.any(Object) },
2216
+ });
2217
+ await view.rerender({ webmcp: true, fieldsetDisabled: true });
2218
+ await tick();
2219
+ await tick();
2220
+ expect(registered.at(-1)?.inputSchema).toMatchObject({ properties: {} });
2221
+ expect(await registered.at(-1)?.execute({
2222
+ dates: { startDate: '2026-08-26', endDate: '2026-08-27' },
2223
+ })).toBe('No reviewable changes provided');
215
2224
  });
216
2225
  });