@happyvertical/smrt-ui 0.43.4 → 0.43.5
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 +33 -0
- package/README.md +50 -4
- package/dist/components/forms/Checkbox.svelte +15 -6
- package/dist/components/forms/Checkbox.svelte.d.ts.map +1 -1
- package/dist/components/forms/Combobox.svelte +45 -10
- package/dist/components/forms/Combobox.svelte.d.ts.map +1 -1
- package/dist/components/forms/FilePicker.svelte +5 -2
- package/dist/components/forms/Form.svelte +57 -0
- package/dist/components/forms/Form.svelte.d.ts +2 -0
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/Input.svelte +59 -13
- package/dist/components/forms/Input.svelte.d.ts.map +1 -1
- package/dist/components/forms/Listbox.svelte +33 -6
- package/dist/components/forms/Listbox.svelte.d.ts.map +1 -1
- package/dist/components/forms/MultiSelect.svelte +41 -19
- package/dist/components/forms/MultiSelect.svelte.d.ts.map +1 -1
- package/dist/components/forms/RadioGroup.svelte +14 -3
- package/dist/components/forms/RadioGroup.svelte.d.ts.map +1 -1
- package/dist/components/forms/RangeSlider.svelte +61 -6
- package/dist/components/forms/RangeSlider.svelte.d.ts.map +1 -1
- package/dist/components/forms/SegmentedControl.svelte +33 -6
- package/dist/components/forms/SegmentedControl.svelte.d.ts.map +1 -1
- package/dist/components/forms/Select.svelte +32 -9
- package/dist/components/forms/Select.svelte.d.ts.map +1 -1
- package/dist/components/forms/Slider.svelte +26 -6
- package/dist/components/forms/Slider.svelte.d.ts.map +1 -1
- package/dist/components/forms/StagedControlReview.svelte +637 -0
- package/dist/components/forms/StagedControlReview.svelte.d.ts +13 -0
- package/dist/components/forms/StagedControlReview.svelte.d.ts.map +1 -0
- package/dist/components/forms/Switch.svelte +16 -7
- package/dist/components/forms/Switch.svelte.d.ts.map +1 -1
- package/dist/components/forms/TagsInput.svelte +36 -8
- package/dist/components/forms/TagsInput.svelte.d.ts.map +1 -1
- package/dist/components/forms/Textarea.svelte +20 -8
- package/dist/components/forms/Textarea.svelte.d.ts.map +1 -1
- package/dist/components/forms/ToggleButton.svelte +20 -3
- package/dist/components/forms/ToggleButton.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/Form.test.js +106 -0
- package/dist/components/forms/__tests__/StagedControlReview.test.js +668 -0
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte +22 -4
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts +6 -2
- package/dist/components/forms/__tests__/composite-controls.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/composite-controls.test.js +96 -1
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte +41 -0
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts +13 -0
- package/dist/components/forms/__tests__/composite-user-edit.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/control-interaction.test.js +3323 -15
- package/dist/components/forms/__tests__/core-controls.fixture.svelte +16 -5
- package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts +1 -0
- package/dist/components/forms/__tests__/core-controls.fixture.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/core-controls.test.js +223 -2
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte +25 -0
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/decimal-sliders.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte +25 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/select-interaction.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte +21 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/staged-review-fieldset.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte +23 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts +8 -0
- package/dist/components/forms/__tests__/staged-review-outer-fieldset.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte +56 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts +12 -0
- package/dist/components/forms/__tests__/staged-review.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/control-interaction-context.d.ts +9 -1
- package/dist/components/forms/control-interaction-context.d.ts.map +1 -1
- package/dist/components/forms/control-interaction-context.js +16 -0
- package/dist/components/forms/control-interaction.d.ts +97 -7
- package/dist/components/forms/control-interaction.d.ts.map +1 -1
- package/dist/components/forms/control-interaction.js +1019 -92
- package/dist/components/forms/control-value-validation.d.ts +19 -0
- package/dist/components/forms/control-value-validation.d.ts.map +1 -0
- package/dist/components/forms/control-value-validation.js +152 -0
- package/dist/components/forms/index.d.ts +3 -1
- package/dist/components/forms/index.d.ts.map +1 -1
- package/dist/components/forms/index.js +2 -1
- package/dist/components/forms/staged-control-review.d.ts +21 -0
- package/dist/components/forms/staged-control-review.d.ts.map +1 -0
- package/dist/components/forms/staged-control-review.js +1 -0
- package/dist/components/forms/use-control-registration.svelte.d.ts.map +1 -1
- package/dist/components/forms/use-control-registration.svelte.js +23 -4
- package/dist/i18n/strings.ui.d.ts +18 -0
- package/dist/i18n/strings.ui.d.ts.map +1 -1
- package/dist/i18n/strings.ui.js +19 -0
- package/dist/svelte/playground/InteractiveControlsPreview.svelte +3 -4
- package/dist/svelte/playground/InteractiveControlsPreview.svelte.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import { render, screen, waitFor, within } from '@testing-library/svelte';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { expectNoA11yViolations } from '../../../test-support/a11y';
|
|
5
|
+
import { createControlInteractionRegistry, executeLocalControlBatch, executeLocalControlCommand, } from '../control-interaction.js';
|
|
6
|
+
import CompositeUserEditFixture from './composite-user-edit.fixture.svelte';
|
|
7
|
+
import SelectFixture from './select-interaction.fixture.svelte';
|
|
8
|
+
import Fixture from './staged-review.fixture.svelte';
|
|
9
|
+
import FieldsetFixture from './staged-review-fieldset.fixture.svelte';
|
|
10
|
+
import OuterFieldsetFixture from './staged-review-outer-fieldset.fixture.svelte';
|
|
11
|
+
const identity = { formId: 'profile', controlId: 'display-name' };
|
|
12
|
+
const createReviewRegistry = () => createControlInteractionRegistry({ isLocalGesture: () => true });
|
|
13
|
+
function dispatchLocalGesture(execute) {
|
|
14
|
+
const target = new EventTarget();
|
|
15
|
+
let pending;
|
|
16
|
+
target.addEventListener('click', (event) => {
|
|
17
|
+
pending = execute(event);
|
|
18
|
+
}, { once: true });
|
|
19
|
+
target.dispatchEvent(new Event('click'));
|
|
20
|
+
if (!pending)
|
|
21
|
+
throw new Error('local gesture handler did not run');
|
|
22
|
+
return pending;
|
|
23
|
+
}
|
|
24
|
+
describe('StagedControlReview', () => {
|
|
25
|
+
it('keeps a click-driven composite edit made during a failed async apply', async () => {
|
|
26
|
+
const registry = createReviewRegistry();
|
|
27
|
+
const onchange = vi.fn();
|
|
28
|
+
const modeState = {};
|
|
29
|
+
render(CompositeUserEditFixture, {
|
|
30
|
+
props: { registry, onchange, modeState },
|
|
31
|
+
});
|
|
32
|
+
const identity = registry
|
|
33
|
+
.list('profile')
|
|
34
|
+
.find((snapshot) => snapshot.identity.controlId === 'mode')?.identity;
|
|
35
|
+
if (!identity)
|
|
36
|
+
throw new Error('mode was not registered');
|
|
37
|
+
const value = () => modeState.get?.();
|
|
38
|
+
const setValue = (next) => modeState.set?.(String(next));
|
|
39
|
+
let releaseSetter;
|
|
40
|
+
let setterStarted;
|
|
41
|
+
const setterBlocked = new Promise((resolve) => {
|
|
42
|
+
releaseSetter = resolve;
|
|
43
|
+
});
|
|
44
|
+
const setterStartedPromise = new Promise((resolve) => {
|
|
45
|
+
setterStarted = resolve;
|
|
46
|
+
});
|
|
47
|
+
registry.register({
|
|
48
|
+
identity,
|
|
49
|
+
metadata: { kind: 'segmented-control' },
|
|
50
|
+
getValue: value,
|
|
51
|
+
setValue: async (next) => {
|
|
52
|
+
setValue(next);
|
|
53
|
+
setterStarted?.();
|
|
54
|
+
await setterBlocked;
|
|
55
|
+
throw new Error('setter_failed');
|
|
56
|
+
},
|
|
57
|
+
restoreValue: setValue,
|
|
58
|
+
});
|
|
59
|
+
await registry.execute({ action: 'stage', identity, value: 'review' }, { source: 'agent' });
|
|
60
|
+
const applying = dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'apply', identity, revision: 1 }, event));
|
|
61
|
+
await setterStartedPromise;
|
|
62
|
+
await userEvent.click(screen.getByRole('radio', { name: 'Published' }));
|
|
63
|
+
await waitFor(() => expect(onchange).toHaveBeenCalled());
|
|
64
|
+
releaseSetter?.();
|
|
65
|
+
expect(await applying).toMatchObject({
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: 'setter_failed',
|
|
68
|
+
});
|
|
69
|
+
expect(value()).toBe('published');
|
|
70
|
+
});
|
|
71
|
+
it('shows an adjacent indicator and applies an edited proposal only after a human click', async () => {
|
|
72
|
+
const registry = createReviewRegistry();
|
|
73
|
+
const { container } = render(Fixture, { props: { registry } });
|
|
74
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent', actorId: 'assistant' });
|
|
75
|
+
const field = screen.getByRole('textbox', { name: 'Display name' });
|
|
76
|
+
expect(field).toHaveValue('Ada');
|
|
77
|
+
await waitFor(() => expect(field).toHaveAttribute('data-smrt-staged', 'true'));
|
|
78
|
+
expect(screen.getByRole('region', { name: 'Review proposed changes' })).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByText(/Proposed by assistant/)).toBeInTheDocument();
|
|
80
|
+
expect(screen.getByText('profile/display-name')).toBeInTheDocument();
|
|
81
|
+
const proposal = screen.getByRole('textbox', {
|
|
82
|
+
name: 'Edit proposed value for Display name',
|
|
83
|
+
});
|
|
84
|
+
await userEvent.clear(proposal);
|
|
85
|
+
await userEvent.type(proposal, 'Grace Hopper');
|
|
86
|
+
expect(field).toHaveValue('Ada');
|
|
87
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply Display name' }));
|
|
88
|
+
expect(field).toHaveValue('Grace Hopper');
|
|
89
|
+
await waitFor(() => expect(screen.queryByRole('region', { name: 'Review proposed changes' })).not.toBeInTheDocument());
|
|
90
|
+
expect(screen.getByText('Applied proposed change.', {
|
|
91
|
+
selector: 'p[role="status"]',
|
|
92
|
+
})).toHaveTextContent('Applied proposed change.');
|
|
93
|
+
expect(field).toHaveFocus();
|
|
94
|
+
await expectNoA11yViolations(container);
|
|
95
|
+
});
|
|
96
|
+
it('keeps Enter in the proposal editor from submitting the form', async () => {
|
|
97
|
+
const registry = createReviewRegistry();
|
|
98
|
+
render(Fixture, { props: { registry } });
|
|
99
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
100
|
+
const proposal = await screen.findByRole('textbox', {
|
|
101
|
+
name: 'Edit proposed value for Display name',
|
|
102
|
+
});
|
|
103
|
+
const submitted = vi.fn((event) => event.preventDefault());
|
|
104
|
+
screen
|
|
105
|
+
.getByRole('form', { name: 'Profile form' })
|
|
106
|
+
.addEventListener('submit', submitted);
|
|
107
|
+
await userEvent.type(proposal, '{Enter}');
|
|
108
|
+
expect(screen.getByRole('textbox', { name: 'Display name' })).toHaveValue('Ada');
|
|
109
|
+
expect(registry.get(identity)?.state.staged?.value).toBe('Grace');
|
|
110
|
+
expect(submitted).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
it('disables apply and restores it when an ancestor fieldset changes state in a base Form', async () => {
|
|
113
|
+
const registry = createReviewRegistry();
|
|
114
|
+
const { container } = render(FieldsetFixture, { props: { registry } });
|
|
115
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
116
|
+
const fieldset = container.querySelector('fieldset');
|
|
117
|
+
if (!(fieldset instanceof HTMLFieldSetElement)) {
|
|
118
|
+
throw new Error('Expected form fieldset');
|
|
119
|
+
}
|
|
120
|
+
fieldset.disabled = true;
|
|
121
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Apply Display name' })).toBeDisabled());
|
|
122
|
+
expect(screen.getByRole('button', { name: 'Discard Display name' })).not.toBeDisabled();
|
|
123
|
+
fieldset.disabled = false;
|
|
124
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Apply Display name' })).not.toBeDisabled());
|
|
125
|
+
});
|
|
126
|
+
it('refreshes staged state when a fieldset wrapping the form changes state', async () => {
|
|
127
|
+
const registry = createReviewRegistry();
|
|
128
|
+
const { container } = render(OuterFieldsetFixture, {
|
|
129
|
+
props: { registry },
|
|
130
|
+
});
|
|
131
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
132
|
+
const fieldset = container.querySelector('fieldset');
|
|
133
|
+
if (!(fieldset instanceof HTMLFieldSetElement)) {
|
|
134
|
+
throw new Error('Expected outer form fieldset');
|
|
135
|
+
}
|
|
136
|
+
fieldset.disabled = true;
|
|
137
|
+
await waitFor(() => expect(registry.get(identity)?.state.disabled).toBe(true));
|
|
138
|
+
expect(screen.getByRole('button', { name: 'Apply Display name' })).toBeDisabled();
|
|
139
|
+
fieldset.disabled = false;
|
|
140
|
+
await waitFor(() => expect(registry.get(identity)?.state.disabled).toBe(false));
|
|
141
|
+
expect(screen.getByRole('button', { name: 'Apply Display name' })).not.toBeDisabled();
|
|
142
|
+
});
|
|
143
|
+
it('resets an edited draft when a replacement proposal has a new revision', async () => {
|
|
144
|
+
const registry = createReviewRegistry();
|
|
145
|
+
render(Fixture, { props: { registry } });
|
|
146
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
147
|
+
const proposal = await screen.findByRole('textbox', {
|
|
148
|
+
name: 'Edit proposed value for Display name',
|
|
149
|
+
});
|
|
150
|
+
await userEvent.clear(proposal);
|
|
151
|
+
await userEvent.type(proposal, 'Katherine');
|
|
152
|
+
await registry.execute({ action: 'stage', identity, value: 'Hopper' }, { source: 'agent' });
|
|
153
|
+
expect(proposal).toHaveValue('Hopper');
|
|
154
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply Display name' }));
|
|
155
|
+
expect(screen.getByRole('textbox', { name: 'Display name' })).toHaveValue('Hopper');
|
|
156
|
+
});
|
|
157
|
+
it('moves focus to the next proposal after applying one', async () => {
|
|
158
|
+
const registry = createReviewRegistry();
|
|
159
|
+
render(Fixture, { props: { registry } });
|
|
160
|
+
let secondValue = 'Ada';
|
|
161
|
+
const secondIdentity = { formId: 'profile', controlId: 'family-name' };
|
|
162
|
+
registry.register({
|
|
163
|
+
identity: secondIdentity,
|
|
164
|
+
metadata: { kind: 'text', label: 'Family name' },
|
|
165
|
+
getValue: () => secondValue,
|
|
166
|
+
setValue: (next) => {
|
|
167
|
+
secondValue = String(next);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
171
|
+
await registry.execute({ action: 'stage', identity: secondIdentity, value: 'Hopper' }, { source: 'agent' });
|
|
172
|
+
const applyButtons = await screen.findAllByRole('button', {
|
|
173
|
+
name: /^Apply (?!valid changes$)/,
|
|
174
|
+
});
|
|
175
|
+
await userEvent.click(applyButtons[0]);
|
|
176
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Apply Family name' })).toHaveFocus());
|
|
177
|
+
});
|
|
178
|
+
it('names every proposal action and reports batch failures on their affected fields', async () => {
|
|
179
|
+
const familyIdentity = { formId: 'profile', controlId: 'family-name' };
|
|
180
|
+
const preferencesIdentity = {
|
|
181
|
+
formId: 'profile',
|
|
182
|
+
controlId: 'preferences',
|
|
183
|
+
};
|
|
184
|
+
const registry = createControlInteractionRegistry({
|
|
185
|
+
isLocalGesture: () => true,
|
|
186
|
+
policy: (command) => command.action === 'apply' &&
|
|
187
|
+
command.identity.controlId === familyIdentity.controlId
|
|
188
|
+
? { allowed: false, reason: 'opaque_policy_code' }
|
|
189
|
+
: { allowed: true },
|
|
190
|
+
});
|
|
191
|
+
let familyName = 'Lovelace';
|
|
192
|
+
let preferences = { layout: 'comfortable' };
|
|
193
|
+
render(Fixture, { props: { registry } });
|
|
194
|
+
registry.register({
|
|
195
|
+
identity: familyIdentity,
|
|
196
|
+
metadata: { kind: 'text', label: 'Family name' },
|
|
197
|
+
getValue: () => familyName,
|
|
198
|
+
setValue: (next) => {
|
|
199
|
+
familyName = String(next);
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
registry.register({
|
|
203
|
+
identity: preferencesIdentity,
|
|
204
|
+
metadata: { kind: 'custom', label: 'Preferences' },
|
|
205
|
+
getValue: () => preferences,
|
|
206
|
+
setValue: (next) => {
|
|
207
|
+
preferences = next;
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
211
|
+
await registry.execute({ action: 'stage', identity: familyIdentity, value: 'Hopper' }, { source: 'agent' });
|
|
212
|
+
await registry.execute({
|
|
213
|
+
action: 'stage',
|
|
214
|
+
identity: preferencesIdentity,
|
|
215
|
+
value: { layout: 'compact' },
|
|
216
|
+
}, { source: 'agent' });
|
|
217
|
+
expect(screen.getByRole('button', { name: 'Apply Display name' })).toBeInTheDocument();
|
|
218
|
+
expect(screen.getByRole('button', { name: 'Discard Display name' })).toBeInTheDocument();
|
|
219
|
+
expect(screen.getByRole('button', { name: 'Apply Family name' })).toBeInTheDocument();
|
|
220
|
+
expect(screen.getByRole('button', { name: 'Discard Family name' })).toBeInTheDocument();
|
|
221
|
+
expect(screen.getByRole('button', { name: 'Apply Preferences' })).toBeInTheDocument();
|
|
222
|
+
const preferencesEditor = screen.getByRole('textbox', {
|
|
223
|
+
name: 'Edit proposed value for Preferences',
|
|
224
|
+
});
|
|
225
|
+
await userEvent.clear(preferencesEditor);
|
|
226
|
+
await userEvent.type(preferencesEditor, '"');
|
|
227
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply valid changes' }));
|
|
228
|
+
await waitFor(() => expect(screen.getByText('Processed 1 of 3 proposed changes.')).toBeInTheDocument());
|
|
229
|
+
const familyItem = screen
|
|
230
|
+
.getByText('Family name', {
|
|
231
|
+
selector: 'strong',
|
|
232
|
+
})
|
|
233
|
+
.closest('li');
|
|
234
|
+
const preferencesItem = screen
|
|
235
|
+
.getByText('Preferences', {
|
|
236
|
+
selector: 'strong',
|
|
237
|
+
})
|
|
238
|
+
.closest('li');
|
|
239
|
+
if (!familyItem || !preferencesItem) {
|
|
240
|
+
throw new Error('Expected remaining proposal items');
|
|
241
|
+
}
|
|
242
|
+
expect(within(familyItem).getByRole('alert')).toHaveTextContent('This proposal is not valid.');
|
|
243
|
+
expect(within(preferencesItem).getByRole('alert')).toHaveTextContent('This proposal is not valid.');
|
|
244
|
+
expect(screen.queryByText('opaque_policy_code')).not.toBeInTheDocument();
|
|
245
|
+
expect(screen.queryByText('Unexpected end of JSON input')).not.toBeInTheDocument();
|
|
246
|
+
expect(familyName).toBe('Lovelace');
|
|
247
|
+
expect(preferences).toEqual({ layout: 'comfortable' });
|
|
248
|
+
});
|
|
249
|
+
it('returns focus to the affected control after applying the final batch', async () => {
|
|
250
|
+
const registry = createReviewRegistry();
|
|
251
|
+
render(Fixture, { props: { registry } });
|
|
252
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
253
|
+
await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
|
|
254
|
+
await waitFor(() => expect(screen.getByRole('textbox', { name: 'Display name' })).toHaveFocus());
|
|
255
|
+
});
|
|
256
|
+
it('returns focus through the registry for a composite control wrapper', async () => {
|
|
257
|
+
const registry = createReviewRegistry();
|
|
258
|
+
render(Fixture, { props: { registry } });
|
|
259
|
+
await registry.execute({
|
|
260
|
+
action: 'stage',
|
|
261
|
+
identity: { formId: 'profile', controlId: 'volume' },
|
|
262
|
+
value: 50,
|
|
263
|
+
}, { source: 'agent' });
|
|
264
|
+
await userEvent.click(await screen.findByRole('button', { name: 'Apply valid changes' }));
|
|
265
|
+
await waitFor(() => expect(screen.getByRole('slider', { name: 'Volume' })).toHaveFocus());
|
|
266
|
+
});
|
|
267
|
+
it('keeps a nonnumeric optional number proposal invalid and unapplied', async () => {
|
|
268
|
+
const registry = createReviewRegistry();
|
|
269
|
+
render(Fixture, { props: { registry } });
|
|
270
|
+
const numberIdentity = { formId: 'profile', controlId: 'score' };
|
|
271
|
+
expect(await registry.execute({ action: 'stage', identity: numberIdentity, value: 'abc' }, { source: 'agent' })).toMatchObject({ ok: true });
|
|
272
|
+
expect(registry.get(numberIdentity)?.state.staged).toMatchObject({
|
|
273
|
+
valid: false,
|
|
274
|
+
validationMessage: 'invalid_number',
|
|
275
|
+
});
|
|
276
|
+
expect(screen.getByRole('spinbutton', { name: 'Score' })).toHaveValue(1);
|
|
277
|
+
});
|
|
278
|
+
it.each([
|
|
279
|
+
{
|
|
280
|
+
controlId: 'birthday',
|
|
281
|
+
proposal: '2025-02-30',
|
|
282
|
+
currentValue: '',
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
controlId: 'meeting-time',
|
|
286
|
+
proposal: '25:00',
|
|
287
|
+
currentValue: '',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
controlId: 'intensity',
|
|
291
|
+
proposal: 5,
|
|
292
|
+
currentValue: 50,
|
|
293
|
+
},
|
|
294
|
+
])('keeps native $controlId proposals invalid when the browser would canonicalize them', async ({ controlId, proposal, currentValue, }) => {
|
|
295
|
+
const registry = createReviewRegistry();
|
|
296
|
+
render(Fixture, { props: { registry } });
|
|
297
|
+
const nativeIdentity = { formId: 'profile', controlId };
|
|
298
|
+
expect(await registry.execute({ action: 'stage', identity: nativeIdentity, value: proposal }, { source: 'agent' })).toMatchObject({ ok: true });
|
|
299
|
+
expect(registry.get(nativeIdentity)?.state.staged).toMatchObject({
|
|
300
|
+
value: proposal,
|
|
301
|
+
valid: false,
|
|
302
|
+
validationMessage: 'invalid_value',
|
|
303
|
+
});
|
|
304
|
+
expect(registry.get(nativeIdentity)?.state.value).toBe(currentValue);
|
|
305
|
+
expect(await dispatchLocalGesture((event) => executeLocalControlCommand(registry, { action: 'apply', identity: nativeIdentity }, event))).toMatchObject({ ok: false, reason: 'invalid_value' });
|
|
306
|
+
expect(registry.get(nativeIdentity)?.state.value).toBe(currentValue);
|
|
307
|
+
});
|
|
308
|
+
it('does not coerce an emptied numeric draft to zero', async () => {
|
|
309
|
+
const registry = createReviewRegistry();
|
|
310
|
+
render(Fixture, { props: { registry } });
|
|
311
|
+
const numberIdentity = { formId: 'profile', controlId: 'score' };
|
|
312
|
+
await registry.execute({ action: 'stage', identity: numberIdentity, value: 5 }, { source: 'agent' });
|
|
313
|
+
const proposal = await screen.findByRole('textbox', {
|
|
314
|
+
name: 'Edit proposed value for Score',
|
|
315
|
+
});
|
|
316
|
+
await userEvent.clear(proposal);
|
|
317
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply Score' }));
|
|
318
|
+
expect(screen.getByRole('spinbutton', { name: 'Score' })).toHaveValue(1);
|
|
319
|
+
expect(registry.get(numberIdentity)?.state.staged?.value).toBe(5);
|
|
320
|
+
});
|
|
321
|
+
it.each([
|
|
322
|
+
{ staged: true, applied: false },
|
|
323
|
+
{ staged: false, applied: true },
|
|
324
|
+
])('edits a $staged boolean proposal with a native checkbox and applies $applied', async ({ staged, applied, }) => {
|
|
325
|
+
let value = staged;
|
|
326
|
+
const booleanIdentity = { formId: 'profile', controlId: 'enabled' };
|
|
327
|
+
const setValue = vi.fn((next) => {
|
|
328
|
+
value = next;
|
|
329
|
+
});
|
|
330
|
+
const registry = createReviewRegistry();
|
|
331
|
+
registry.register({
|
|
332
|
+
identity: booleanIdentity,
|
|
333
|
+
metadata: { kind: 'checkbox', label: 'Enabled' },
|
|
334
|
+
getValue: () => value,
|
|
335
|
+
setValue,
|
|
336
|
+
});
|
|
337
|
+
render(Fixture, { props: { registry } });
|
|
338
|
+
await registry.execute({ action: 'stage', identity: booleanIdentity, value: staged }, { source: 'agent' });
|
|
339
|
+
const editor = await screen.findByRole('checkbox', {
|
|
340
|
+
name: 'Edit proposed value for Enabled',
|
|
341
|
+
});
|
|
342
|
+
expect(editor).toHaveProperty('checked', staged);
|
|
343
|
+
expect(screen.queryByRole('textbox', {
|
|
344
|
+
name: 'Edit proposed value for Enabled',
|
|
345
|
+
})).not.toBeInTheDocument();
|
|
346
|
+
const submitted = vi.fn((event) => event.preventDefault());
|
|
347
|
+
screen
|
|
348
|
+
.getByRole('form', { name: 'Profile form' })
|
|
349
|
+
.addEventListener('submit', submitted);
|
|
350
|
+
const enter = new KeyboardEvent('keydown', {
|
|
351
|
+
key: 'Enter',
|
|
352
|
+
bubbles: true,
|
|
353
|
+
cancelable: true,
|
|
354
|
+
});
|
|
355
|
+
expect(editor.dispatchEvent(enter)).toBe(false);
|
|
356
|
+
expect(enter.defaultPrevented).toBe(true);
|
|
357
|
+
expect(submitted).not.toHaveBeenCalled();
|
|
358
|
+
expect(registry.get(booleanIdentity)?.state.staged?.value).toBe(staged);
|
|
359
|
+
await userEvent.click(editor);
|
|
360
|
+
expect(editor).toHaveProperty('checked', applied);
|
|
361
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply Enabled' }));
|
|
362
|
+
expect(setValue).toHaveBeenLastCalledWith(applied);
|
|
363
|
+
expect(value).toBe(applied);
|
|
364
|
+
expect(typeof value).toBe('boolean');
|
|
365
|
+
expect(registry.get(booleanIdentity)?.state.staged).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
it('announces stale apply failures with localized review text', async () => {
|
|
368
|
+
let registry;
|
|
369
|
+
registry = createControlInteractionRegistry({
|
|
370
|
+
isLocalGesture: () => true,
|
|
371
|
+
policy: (command) => {
|
|
372
|
+
if (command.action === 'apply') {
|
|
373
|
+
registry.recordUserEdit?.(command.identity);
|
|
374
|
+
}
|
|
375
|
+
return { allowed: true };
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
render(Fixture, { props: { registry } });
|
|
379
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
380
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply Display name' }));
|
|
381
|
+
const status = screen.getByText('The field changed after this proposal was staged.', { selector: 'p[role="status"]' });
|
|
382
|
+
expect(status).not.toHaveTextContent('staged_value_stale');
|
|
383
|
+
});
|
|
384
|
+
it('announces gesture failures with localized invalid text', async () => {
|
|
385
|
+
const registry = createControlInteractionRegistry({
|
|
386
|
+
isLocalGesture: () => false,
|
|
387
|
+
});
|
|
388
|
+
render(Fixture, { props: { registry } });
|
|
389
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
390
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply Display name' }));
|
|
391
|
+
const status = screen.getByText('This proposal is not valid.', {
|
|
392
|
+
selector: 'p[role="status"]',
|
|
393
|
+
});
|
|
394
|
+
expect(status).not.toHaveTextContent('local_gesture_required');
|
|
395
|
+
});
|
|
396
|
+
it('announces the pre-batch proposal total', async () => {
|
|
397
|
+
const registry = createReviewRegistry();
|
|
398
|
+
render(Fixture, { props: { registry } });
|
|
399
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
400
|
+
await registry.execute({
|
|
401
|
+
action: 'stage',
|
|
402
|
+
identity: { formId: 'profile', controlId: 'score' },
|
|
403
|
+
value: 'abc',
|
|
404
|
+
}, { source: 'agent' });
|
|
405
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply valid changes' }));
|
|
406
|
+
expect(screen.getByText('Processed 1 of 2 proposed changes.')).toBeInTheDocument();
|
|
407
|
+
});
|
|
408
|
+
it('marks competing user edits stale and lets the human discard them', async () => {
|
|
409
|
+
const registry = createReviewRegistry();
|
|
410
|
+
render(Fixture, { props: { registry } });
|
|
411
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
412
|
+
const field = screen.getByRole('textbox', { name: 'Display name' });
|
|
413
|
+
await userEvent.clear(field);
|
|
414
|
+
await userEvent.type(field, 'Katherine');
|
|
415
|
+
await waitFor(() => expect(screen.getByText('The field changed after this proposal was staged.')).toBeInTheDocument());
|
|
416
|
+
expect(screen.getByRole('button', { name: 'Apply Display name' })).toBeDisabled();
|
|
417
|
+
await userEvent.click(screen.getByRole('button', { name: 'Discard Display name' }));
|
|
418
|
+
expect(field).toHaveValue('Katherine');
|
|
419
|
+
await waitFor(() => expect(screen.queryByRole('region', { name: 'Review proposed changes' })).not.toBeInTheDocument());
|
|
420
|
+
expect(screen.getByText('Discarded proposed change.', {
|
|
421
|
+
selector: 'p[role="status"]',
|
|
422
|
+
})).toHaveTextContent('Discarded proposed change.');
|
|
423
|
+
});
|
|
424
|
+
it('keeps trusted edit tracking when the consumer supplies an input handler', async () => {
|
|
425
|
+
const registry = createReviewRegistry();
|
|
426
|
+
const formOnInput = vi.fn();
|
|
427
|
+
render(Fixture, { props: { registry, formOnInput } });
|
|
428
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
429
|
+
const field = screen.getByRole('textbox', { name: 'Display name' });
|
|
430
|
+
await userEvent.clear(field);
|
|
431
|
+
await userEvent.type(field, 'Katherine');
|
|
432
|
+
expect(formOnInput).toHaveBeenCalled();
|
|
433
|
+
await waitFor(() => expect(registry.get(identity)?.state.staged?.stale).toBe(true));
|
|
434
|
+
});
|
|
435
|
+
it('preserves a staged proposal across same-identity metadata updates', async () => {
|
|
436
|
+
const registry = createReviewRegistry();
|
|
437
|
+
const view = render(Fixture, {
|
|
438
|
+
props: { registry, displayNameLabel: 'Display name' },
|
|
439
|
+
});
|
|
440
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
441
|
+
await view.rerender({
|
|
442
|
+
registry,
|
|
443
|
+
displayNameLabel: 'Preferred name',
|
|
444
|
+
});
|
|
445
|
+
await waitFor(() => expect(registry.get(identity)?.state.staged?.value).toBe('Grace'));
|
|
446
|
+
expect(screen.getByRole('textbox', {
|
|
447
|
+
name: 'Edit proposed value for Preferred name',
|
|
448
|
+
})).toHaveValue('Grace');
|
|
449
|
+
});
|
|
450
|
+
it('preserves undo history across same-identity metadata updates', async () => {
|
|
451
|
+
const registry = createReviewRegistry();
|
|
452
|
+
const view = render(Fixture, {
|
|
453
|
+
props: { registry, displayNameLabel: 'Display name' },
|
|
454
|
+
});
|
|
455
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
456
|
+
const revision = registry.get(identity)?.state.staged?.revision;
|
|
457
|
+
expect(await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [{ action: 'apply', identity, revision }], event))).toMatchObject({ ok: true });
|
|
458
|
+
expect(screen.getByRole('textbox', { name: 'Display name' })).toHaveValue('Grace');
|
|
459
|
+
await view.rerender({
|
|
460
|
+
registry,
|
|
461
|
+
displayNameLabel: 'Preferred name',
|
|
462
|
+
});
|
|
463
|
+
expect(screen.getByRole('textbox', { name: 'Preferred name' })).toHaveValue('Grace');
|
|
464
|
+
await waitFor(() => expect(registry.get(identity)?.metadata.label).toBe('Preferred name'));
|
|
465
|
+
const undo = await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [{ action: 'undo', identity }], event));
|
|
466
|
+
expect(undo.results[0]).toMatchObject({ ok: true });
|
|
467
|
+
expect(screen.getByRole('textbox', { name: 'Preferred name' })).toHaveValue('Ada');
|
|
468
|
+
});
|
|
469
|
+
it('moves registration when the form registry and identity change', async () => {
|
|
470
|
+
const firstRegistry = createReviewRegistry();
|
|
471
|
+
const secondRegistry = createReviewRegistry();
|
|
472
|
+
const view = render(Fixture, {
|
|
473
|
+
props: { registry: firstRegistry, formId: 'profile' },
|
|
474
|
+
});
|
|
475
|
+
expect(firstRegistry.get(identity)).toBeDefined();
|
|
476
|
+
await view.rerender({ registry: secondRegistry, formId: 'account' });
|
|
477
|
+
await waitFor(() => expect(firstRegistry.get(identity)).toBeUndefined());
|
|
478
|
+
expect(secondRegistry.get({ formId: 'account', controlId: 'display-name' })).toBeDefined();
|
|
479
|
+
});
|
|
480
|
+
it('moves registration when a subject identity mutates in place', async () => {
|
|
481
|
+
const registry = createReviewRegistry();
|
|
482
|
+
const subject = { type: 'record', id: 'one' };
|
|
483
|
+
render(Fixture, { props: { registry, subject } });
|
|
484
|
+
const firstIdentity = { ...identity, subject: { ...subject } };
|
|
485
|
+
expect(registry.get(firstIdentity)).toBeDefined();
|
|
486
|
+
await userEvent.click(screen.getByRole('button', { name: 'Mutate subject' }));
|
|
487
|
+
await waitFor(() => expect(registry.get(firstIdentity)).toBeUndefined());
|
|
488
|
+
expect(registry.get({
|
|
489
|
+
...identity,
|
|
490
|
+
subject: { type: 'record', id: 'two' },
|
|
491
|
+
})).toBeDefined();
|
|
492
|
+
});
|
|
493
|
+
it('rejects disabled and undeclared base-select proposals on apply', async () => {
|
|
494
|
+
const registry = createReviewRegistry();
|
|
495
|
+
render(SelectFixture, { props: { registry } });
|
|
496
|
+
const selectIdentity = { formId: 'account', controlId: 'role' };
|
|
497
|
+
expect(registry
|
|
498
|
+
.get(selectIdentity)
|
|
499
|
+
?.metadata.options?.find((option) => option.value === 'owner')).toMatchObject({ disabled: true });
|
|
500
|
+
for (const candidate of ['admin', 'owner']) {
|
|
501
|
+
await registry.execute({ action: 'stage', identity: selectIdentity, value: candidate }, { source: 'agent' });
|
|
502
|
+
expect(registry.get(selectIdentity)?.state.staged).toMatchObject({
|
|
503
|
+
value: candidate,
|
|
504
|
+
valid: false,
|
|
505
|
+
});
|
|
506
|
+
const batch = await dispatchLocalGesture((event) => executeLocalControlBatch(registry, [
|
|
507
|
+
{
|
|
508
|
+
action: 'apply',
|
|
509
|
+
identity: selectIdentity,
|
|
510
|
+
revision: registry.get(selectIdentity)?.state.staged?.revision,
|
|
511
|
+
},
|
|
512
|
+
], event));
|
|
513
|
+
expect(batch.results[0]).toMatchObject({
|
|
514
|
+
ok: false,
|
|
515
|
+
reason: 'staged_value_invalid',
|
|
516
|
+
});
|
|
517
|
+
expect(screen.getByRole('combobox', { name: 'Role' })).toHaveValue('user');
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
it('rejects an enabled empty option when the base select is required', async () => {
|
|
521
|
+
const registry = createReviewRegistry();
|
|
522
|
+
render(SelectFixture, { props: { registry, required: true } });
|
|
523
|
+
const selectIdentity = { formId: 'account', controlId: 'role' };
|
|
524
|
+
await registry.execute({ action: 'stage', identity: selectIdentity, value: '' }, { source: 'agent' });
|
|
525
|
+
expect(registry.get(selectIdentity)?.state.staged).toMatchObject({
|
|
526
|
+
value: '',
|
|
527
|
+
valid: false,
|
|
528
|
+
});
|
|
529
|
+
expect(screen.getByRole('combobox', { name: 'Role' })).toHaveValue('user');
|
|
530
|
+
});
|
|
531
|
+
it('lets the human correct an invalid proposal before applying it', async () => {
|
|
532
|
+
const registry = createReviewRegistry();
|
|
533
|
+
render(Fixture, { props: { registry } });
|
|
534
|
+
await registry.execute({ action: 'stage', identity, value: '' }, { source: 'agent' });
|
|
535
|
+
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('This proposal is not valid.'));
|
|
536
|
+
const field = screen.getByRole('textbox', { name: 'Display name' });
|
|
537
|
+
expect(field).toHaveValue('Ada');
|
|
538
|
+
const proposal = screen.getByRole('textbox', {
|
|
539
|
+
name: 'Edit proposed value for Display name',
|
|
540
|
+
});
|
|
541
|
+
await userEvent.type(proposal, 'Grace');
|
|
542
|
+
expect(screen.getByRole('button', { name: 'Apply Display name' })).toBeEnabled();
|
|
543
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply Display name' }));
|
|
544
|
+
await waitFor(() => expect(field).toHaveValue('Grace'));
|
|
545
|
+
expect(registry.get(identity)?.state.staged).toBeUndefined();
|
|
546
|
+
});
|
|
547
|
+
it('keeps invalid entries out of valid-only batch actions', async () => {
|
|
548
|
+
const registry = createReviewRegistry();
|
|
549
|
+
render(Fixture, { props: { registry } });
|
|
550
|
+
await registry.execute({ action: 'stage', identity, value: '' }, { source: 'agent' });
|
|
551
|
+
await userEvent.click(screen.getByRole('button', { name: 'Discard valid changes' }));
|
|
552
|
+
expect(registry.get(identity)?.state.staged).toBeDefined();
|
|
553
|
+
});
|
|
554
|
+
it('does not coerce an unedited null proposal into a string', async () => {
|
|
555
|
+
const registry = createReviewRegistry();
|
|
556
|
+
render(Fixture, { props: { registry } });
|
|
557
|
+
await registry.execute({ action: 'stage', identity, value: null }, { source: 'agent' });
|
|
558
|
+
expect(screen.getByRole('textbox', {
|
|
559
|
+
name: 'Edit proposed value for Display name',
|
|
560
|
+
})).toHaveValue('null');
|
|
561
|
+
await userEvent.click(screen.getByRole('button', { name: 'Apply Display name' }));
|
|
562
|
+
expect(screen.getByRole('textbox', { name: 'Display name' })).toHaveValue('Ada');
|
|
563
|
+
expect(registry.get(identity)?.state.staged?.value).toBeNull();
|
|
564
|
+
});
|
|
565
|
+
it('gives same-label subject-qualified proposals unique action names', async () => {
|
|
566
|
+
const registry = createReviewRegistry();
|
|
567
|
+
let secondValue = 'Ada';
|
|
568
|
+
registry.register({
|
|
569
|
+
identity: {
|
|
570
|
+
...identity,
|
|
571
|
+
subject: { type: 'record', id: 'two' },
|
|
572
|
+
},
|
|
573
|
+
metadata: { kind: 'text', label: 'Display name' },
|
|
574
|
+
getValue: () => secondValue,
|
|
575
|
+
setValue: (next) => {
|
|
576
|
+
secondValue = String(next);
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
render(Fixture, {
|
|
580
|
+
props: { registry, subject: { type: 'record', id: 'one' } },
|
|
581
|
+
});
|
|
582
|
+
const renderedFirst = screen.getByRole('textbox', {
|
|
583
|
+
name: 'Display name',
|
|
584
|
+
});
|
|
585
|
+
await registry.execute({
|
|
586
|
+
action: 'stage',
|
|
587
|
+
identity: { ...identity, subject: { type: 'record', id: 'two' } },
|
|
588
|
+
value: 'Katherine',
|
|
589
|
+
}, { source: 'agent' });
|
|
590
|
+
await waitFor(() => expect(renderedFirst).not.toHaveAttribute('data-smrt-staged'));
|
|
591
|
+
await registry.execute({
|
|
592
|
+
action: 'stage',
|
|
593
|
+
identity: { ...identity, subject: { type: 'record', id: 'one' } },
|
|
594
|
+
value: 'Grace',
|
|
595
|
+
}, { source: 'agent' });
|
|
596
|
+
await waitFor(() => expect(renderedFirst).toHaveAttribute('data-smrt-staged', 'true'));
|
|
597
|
+
expect(screen.getByText('profile/display-name · record:one')).toBeVisible();
|
|
598
|
+
expect(screen.getByText('profile/display-name · record:two')).toBeVisible();
|
|
599
|
+
for (const subjectId of ['one', 'two']) {
|
|
600
|
+
const actionName = `Display name record:${subjectId} [identity:["profile","display-name","record","${subjectId}"]]`;
|
|
601
|
+
expect(screen.getByRole('textbox', {
|
|
602
|
+
name: `Edit proposed value for ${actionName}`,
|
|
603
|
+
})).toBeInTheDocument();
|
|
604
|
+
expect(screen.getByRole('button', { name: `Apply ${actionName}` })).toBeInTheDocument();
|
|
605
|
+
expect(screen.getByRole('button', { name: `Discard ${actionName}` })).toBeInTheDocument();
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
it('keeps tagged action identities distinct when human and fallback subjects collide', async () => {
|
|
609
|
+
const registry = createReviewRegistry();
|
|
610
|
+
let secondValue = 'Ada';
|
|
611
|
+
registry.register({
|
|
612
|
+
identity: {
|
|
613
|
+
...identity,
|
|
614
|
+
subject: { type: 'record', id: 'b' },
|
|
615
|
+
},
|
|
616
|
+
metadata: { kind: 'text', label: 'Display name' },
|
|
617
|
+
getValue: () => secondValue,
|
|
618
|
+
setValue: (next) => {
|
|
619
|
+
secondValue = String(next);
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
render(Fixture, {
|
|
623
|
+
props: {
|
|
624
|
+
registry,
|
|
625
|
+
subject: { type: 'record', id: 'a', label: 'record:b' },
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
await registry.execute({
|
|
629
|
+
action: 'stage',
|
|
630
|
+
identity: { ...identity, subject: { type: 'record', id: 'b' } },
|
|
631
|
+
value: 'Katherine',
|
|
632
|
+
}, { source: 'agent' });
|
|
633
|
+
await registry.execute({
|
|
634
|
+
action: 'stage',
|
|
635
|
+
identity: {
|
|
636
|
+
...identity,
|
|
637
|
+
subject: { type: 'record', id: 'a', label: 'record:b' },
|
|
638
|
+
},
|
|
639
|
+
value: 'Grace',
|
|
640
|
+
}, { source: 'agent' });
|
|
641
|
+
const first = 'Display name record:b [identity:["profile","display-name","record","a"]]';
|
|
642
|
+
const second = 'Display name record:b [identity:["profile","display-name","record","b"]]';
|
|
643
|
+
expect(first).not.toBe(second);
|
|
644
|
+
for (const actionName of [first, second]) {
|
|
645
|
+
expect(screen.getByRole('textbox', {
|
|
646
|
+
name: `Edit proposed value for ${actionName}`,
|
|
647
|
+
})).toBeInTheDocument();
|
|
648
|
+
expect(screen.getByRole('button', { name: `Apply ${actionName}` })).toBeInTheDocument();
|
|
649
|
+
expect(screen.getByRole('button', { name: `Discard ${actionName}` })).toBeInTheDocument();
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
it('rejects secret staging and discards proposals on form reset and unmount', async () => {
|
|
653
|
+
const registry = createReviewRegistry();
|
|
654
|
+
const rendered = render(Fixture, { props: { registry } });
|
|
655
|
+
const secret = await registry.execute({
|
|
656
|
+
action: 'stage',
|
|
657
|
+
identity: { formId: 'profile', controlId: 'api-token' },
|
|
658
|
+
value: 'replacement',
|
|
659
|
+
}, { source: 'agent' });
|
|
660
|
+
expect(secret).toMatchObject({ ok: false, reason: 'sensitive_control' });
|
|
661
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
662
|
+
await userEvent.click(screen.getByRole('button', { name: 'Reset' }));
|
|
663
|
+
await waitFor(() => expect(registry.get(identity)?.state.staged).toBeUndefined());
|
|
664
|
+
await registry.execute({ action: 'stage', identity, value: 'Grace' }, { source: 'agent' });
|
|
665
|
+
rendered.unmount();
|
|
666
|
+
expect(registry.get(identity)).toBeUndefined();
|
|
667
|
+
});
|
|
668
|
+
});
|