@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.
- package/AGENTS.md +23 -0
- package/README.md +23 -1
- package/dist/components/forms/AddressInput.svelte +155 -42
- package/dist/components/forms/AddressInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/CheckboxInput.svelte +40 -13
- package/dist/components/forms/CheckboxInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/DateRangeInput.svelte +132 -16
- package/dist/components/forms/DateRangeInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/DateTimeInput.svelte +48 -14
- package/dist/components/forms/DateTimeInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/Form.svelte +599 -70
- package/dist/components/forms/Form.svelte.d.ts +6 -2
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/MeasurementInput.svelte +137 -31
- package/dist/components/forms/MeasurementInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/MoneyInput.svelte +42 -23
- package/dist/components/forms/MoneyInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/NumberInput.svelte +59 -27
- package/dist/components/forms/NumberInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/PhoneInput.svelte +51 -22
- package/dist/components/forms/PhoneInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/SelectInput.svelte +38 -22
- package/dist/components/forms/SelectInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/TextInput.svelte +32 -18
- package/dist/components/forms/TextInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/TextareaInput.svelte +25 -18
- package/dist/components/forms/TextareaInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/AddressInput.behavior.test.js +94 -0
- package/dist/components/forms/__tests__/Form.behavior.test.js +1 -1
- package/dist/components/forms/__tests__/Form.extraction.test.js +68 -0
- package/dist/components/forms/__tests__/Form.test.js +28 -0
- package/dist/components/forms/__tests__/Form.webmcp.test.js +2123 -114
- package/dist/components/forms/__tests__/FormMicButton.test.js +0 -1
- package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte +30 -0
- package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts +7 -0
- package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/async-validation-form.fixture.svelte +19 -0
- package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte +21 -0
- package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte +27 -0
- package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte +48 -0
- package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts +10 -0
- package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte +26 -0
- package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts +13 -0
- package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte +52 -0
- package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts +16 -0
- package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte +108 -5
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts +34 -0
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte +24 -0
- package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts +11 -0
- package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte +69 -4
- package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts +32 -0
- package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte +44 -0
- package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/policy-field.fixture.svelte +35 -0
- package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/prepare-field-value.d.ts +5 -0
- package/dist/components/forms/prepare-field-value.d.ts.map +1 -0
- package/dist/components/forms/prepare-field-value.js +15 -0
- package/dist/state/form-context.d.ts +28 -6
- package/dist/state/form-context.d.ts.map +1 -1
- package/dist/state/form-context.js +75 -7
- package/dist/web/__tests__/webmcp-composed.child.fixture.svelte +62 -0
- package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts +8 -0
- package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/webmcp-composed.fixture.svelte +52 -0
- package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts +12 -0
- package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/webmcp-composed.integration.svelte.test.js +247 -0
- package/dist/web/__tests__/webmcp-ssr.fixture.svelte +13 -0
- package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts +19 -0
- package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/webmcp-ui.test.js +209 -33
- package/dist/web/__tests__/webmcp.test.js +20 -0
- package/dist/web/webmcp-ui.d.ts.map +1 -1
- package/dist/web/webmcp-ui.js +50 -31
- package/dist/web/webmcp.svelte.d.ts.map +1 -1
- package/dist/web/webmcp.svelte.js +6 -1
- package/package.json +8 -6
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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
|
-
|
|
27
|
-
|
|
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('
|
|
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('
|
|
49
|
-
expect(onsubmit).
|
|
50
|
-
|
|
51
|
-
|
|
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('
|
|
221
|
+
it('stages and applies a canonical null for an empty optional number', async () => {
|
|
55
222
|
const registered = [];
|
|
56
223
|
document.modelContext = {
|
|
57
|
-
|
|
224
|
+
registerTool(tool) {
|
|
58
225
|
registered.push(tool);
|
|
59
226
|
},
|
|
60
227
|
};
|
|
61
|
-
const
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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).
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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('
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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('
|
|
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
|
-
|
|
363
|
+
registerTool(tool) {
|
|
98
364
|
registered.push(tool);
|
|
99
365
|
},
|
|
100
366
|
};
|
|
101
|
-
const
|
|
102
|
-
|
|
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).
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
397
|
+
], event))).toMatchObject({ ok: true });
|
|
398
|
+
await view.rerender({
|
|
399
|
+
webmcp: true,
|
|
400
|
+
interactionRegistry: registry,
|
|
401
|
+
showMoney: false,
|
|
115
402
|
});
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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('
|
|
568
|
+
it('rejects invalid scalar date and phone proposals', async () => {
|
|
155
569
|
const registered = [];
|
|
156
570
|
document.modelContext = {
|
|
157
|
-
|
|
571
|
+
registerTool(tool) {
|
|
158
572
|
registered.push(tool);
|
|
159
573
|
},
|
|
160
574
|
};
|
|
161
|
-
const
|
|
162
|
-
|
|
575
|
+
const registry = createControlInteractionRegistry({
|
|
576
|
+
isLocalGesture: () => true,
|
|
577
|
+
});
|
|
578
|
+
render(FormWithFields, {
|
|
163
579
|
props: {
|
|
164
580
|
webmcp: true,
|
|
165
|
-
|
|
166
|
-
|
|
581
|
+
showAge: false,
|
|
582
|
+
showScalarFields: true,
|
|
583
|
+
interactionRegistry: registry,
|
|
167
584
|
},
|
|
168
585
|
});
|
|
169
586
|
await tick();
|
|
170
587
|
await tick();
|
|
171
|
-
expect(registered
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
612
|
+
};
|
|
613
|
+
const registry = createControlInteractionRegistry({
|
|
614
|
+
isLocalGesture: () => true,
|
|
180
615
|
});
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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('
|
|
676
|
+
it('canonically applies a reverse-order measurement proposal from empty', async () => {
|
|
191
677
|
const registered = [];
|
|
192
678
|
document.modelContext = {
|
|
193
|
-
|
|
679
|
+
registerTool(tool) {
|
|
194
680
|
registered.push(tool);
|
|
195
681
|
},
|
|
196
682
|
};
|
|
197
|
-
const
|
|
683
|
+
const measurementChanged = vi.fn();
|
|
684
|
+
const registry = createControlInteractionRegistry({
|
|
685
|
+
isLocalGesture: () => true,
|
|
686
|
+
});
|
|
198
687
|
render(FormWithStructuredFields, {
|
|
199
|
-
props: {
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
});
|