@dimina-kit/inspect 0.4.0-dev.20260716214527 → 0.4.0-dev.20260718085557

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.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * AppDataPanel hardening — three edit-path safety contracts on top of the
3
+ * base tree-edit behavior covered by appdata-tree-edit.test.tsx:
4
+ *
5
+ * 1. A nested key whose own text contains `.` or `[`/`]` makes its setData
6
+ * path ambiguous (the runtime's own-key short-circuit only helps at the
7
+ * top level; nested writes go through path parsing that would split on
8
+ * those characters and land on the wrong field). Such rows carry no
9
+ * `data-path` and are not editable; sibling rows with safe keys are
10
+ * unaffected.
11
+ * 2. The panel's undo/redo stack advances only once `onSetData` reports
12
+ * success (`undefined` counts as success for backward compatibility;
13
+ * `false` — sync or resolved — means the write-back was rejected and the
14
+ * record must not move). A record whose bridge no longer exists in
15
+ * `state.bridges` is stale and gets dropped without calling `onSetData`.
16
+ * 3. A number edit that parses to a non-finite value (`Infinity`, or a
17
+ * literal that overflows to it) is rejected: the edit exits without
18
+ * calling `onSetData`.
19
+ */
20
+ import { describe, expect, it, vi } from 'vitest'
21
+ import { fireEvent, render, waitFor, within } from '@testing-library/react'
22
+ import { AppDataPanel, type AppDataPanelState as AppDataState } from './appdata-panel-view.js'
23
+
24
+ function editableState(): AppDataState {
25
+ return {
26
+ bridges: [{ id: 'b1', pagePath: '/pages/index/index' }],
27
+ activeBridgeId: 'b1',
28
+ entries: {
29
+ b1: {
30
+ comp: {
31
+ count: 1,
32
+ flag: true,
33
+ },
34
+ },
35
+ },
36
+ }
37
+ }
38
+
39
+ function unsafeKeyState(): AppDataState {
40
+ return {
41
+ bridges: [{ id: 'b1', pagePath: '/pages/index/index' }],
42
+ activeBridgeId: 'b1',
43
+ entries: {
44
+ b1: {
45
+ comp: {
46
+ profile: { 'first.name': 'A', safe: 'ok' },
47
+ 'top.level': 'T',
48
+ arr: [{ 'x[0]': 1, y: 2 }],
49
+ },
50
+ },
51
+ },
52
+ }
53
+ }
54
+
55
+ function unsafeBooleanState(): AppDataState {
56
+ return {
57
+ bridges: [{ id: 'b1', pagePath: '/pages/index/index' }],
58
+ activeBridgeId: 'b1',
59
+ entries: {
60
+ b1: {
61
+ comp: {
62
+ profile: { 'first.enabled': true, safe: false },
63
+ },
64
+ },
65
+ },
66
+ }
67
+ }
68
+
69
+ function nestedEmptyKeyState(): AppDataState {
70
+ return {
71
+ bridges: [{ id: 'b1', pagePath: '/pages/index/index' }],
72
+ activeBridgeId: 'b1',
73
+ entries: {
74
+ b1: {
75
+ comp: {
76
+ profile: { '': 'A', safe: 'B' },
77
+ },
78
+ },
79
+ },
80
+ }
81
+ }
82
+
83
+ function checkboxFor(container: HTMLElement, path: string): HTMLInputElement {
84
+ const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
85
+ return row.querySelector('input[type="checkbox"]') as HTMLInputElement
86
+ }
87
+
88
+ function valueElFor(container: HTMLElement, path: string): HTMLElement {
89
+ const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
90
+ return within(row).getByTestId('appdata-value')
91
+ }
92
+
93
+ /** Finds a tree row by its key label text — needed for keys with no
94
+ * `data-path` to query by, since that attribute's presence is exactly what
95
+ * some of these tests are asserting. */
96
+ function rowByKey(container: HTMLElement, key: string): HTMLElement {
97
+ const candidates = within(container).getAllByText(key)
98
+ const label = candidates.find((el) => el.tagName === 'SPAN' && el.className.includes('text-code-blue'))
99
+ if (!label) throw new Error(`no row found for key "${key}"`)
100
+ return label.closest('div') as HTMLElement
101
+ }
102
+
103
+ /** Finds a tree row whose key label is the empty string — `getByText('')`
104
+ * can't target it, since an empty string matches too much. */
105
+ function rowByEmptyKey(container: HTMLElement): HTMLElement {
106
+ const spans = container.querySelectorAll('span.text-code-blue')
107
+ const label = Array.from(spans).find((el) => el.textContent === '')
108
+ if (!label) throw new Error('no row found for the empty key')
109
+ return label.closest('div') as HTMLElement
110
+ }
111
+
112
+ /** Resolves/rejects on demand — lets a test hold `onSetData` open mid-dispatch
113
+ * to exercise pending/reentrancy behavior before settling it. */
114
+ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
115
+ let resolve!: (value: T) => void
116
+ const promise = new Promise<T>((res) => { resolve = res })
117
+ return { promise, resolve }
118
+ }
119
+
120
+ describe('AppDataPanel: unsafe nested-key paths are read-only', () => {
121
+ it('gives the `first.name` row no data-path and blocks its double-click editor', () => {
122
+ const { container, getByText } = render(
123
+ <AppDataPanel state={unsafeKeyState()} onSelectBridge={vi.fn()} onSetData={vi.fn()} />,
124
+ )
125
+ fireEvent.click(getByText('profile'))
126
+ const row = rowByKey(container, 'first.name')
127
+
128
+ expect(row.hasAttribute('data-path')).toBe(false)
129
+ fireEvent.dblClick(within(row).getByTestId('appdata-value'))
130
+ expect(within(container).queryByRole('textbox')).toBeNull()
131
+ })
132
+
133
+ it('keeps the sibling `profile.safe` row editable — only the unsafe key is blocked, not the whole subtree', () => {
134
+ const onSetData = vi.fn()
135
+ const { container, getByText } = render(
136
+ <AppDataPanel state={unsafeKeyState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
137
+ )
138
+ fireEvent.click(getByText('profile'))
139
+ const row = rowByKey(container, 'safe')
140
+ expect(row.getAttribute('data-path')).toBe('profile.safe')
141
+
142
+ fireEvent.dblClick(within(row).getByTestId('appdata-value'))
143
+ const input = within(container).getByRole('textbox') as HTMLInputElement
144
+ fireEvent.change(input, { target: { value: 'updated' } })
145
+ fireEvent.keyDown(input, { key: 'Enter' })
146
+
147
+ expect(onSetData).toHaveBeenCalledWith('b1', { 'profile.safe': 'updated' })
148
+ })
149
+
150
+ it('keeps a top-level key containing a dot editable — a single path segment resolves by own-key short-circuit', () => {
151
+ const onSetData = vi.fn()
152
+ const { container } = render(
153
+ <AppDataPanel state={unsafeKeyState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
154
+ )
155
+ const row = rowByKey(container, 'top.level')
156
+ expect(row.getAttribute('data-path')).toBe('top.level')
157
+
158
+ fireEvent.dblClick(within(row).getByTestId('appdata-value'))
159
+ const input = within(container).getByRole('textbox') as HTMLInputElement
160
+ fireEvent.change(input, { target: { value: 'T2' } })
161
+ fireEvent.keyDown(input, { key: 'Enter' })
162
+
163
+ expect(onSetData).toHaveBeenCalledWith('b1', { 'top.level': 'T2' })
164
+ })
165
+
166
+ it('gives the `arr[0].x[0]` row no data-path and blocks its editor — the element key itself contains brackets', () => {
167
+ const { container, getByText } = render(
168
+ <AppDataPanel state={unsafeKeyState()} onSelectBridge={vi.fn()} onSetData={vi.fn()} />,
169
+ )
170
+ fireEvent.click(getByText('arr'))
171
+ const row = rowByKey(container, 'x[0]')
172
+
173
+ expect(row.hasAttribute('data-path')).toBe(false)
174
+ fireEvent.dblClick(within(row).getByTestId('appdata-value'))
175
+ expect(within(container).queryByRole('textbox')).toBeNull()
176
+ })
177
+
178
+ it('keeps the sibling `arr[0].y` row editable', () => {
179
+ const onSetData = vi.fn()
180
+ const { container, getByText } = render(
181
+ <AppDataPanel state={unsafeKeyState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
182
+ )
183
+ fireEvent.click(getByText('arr'))
184
+ const row = rowByKey(container, 'y')
185
+ expect(row.getAttribute('data-path')).toBe('arr[0].y')
186
+
187
+ fireEvent.dblClick(within(row).getByTestId('appdata-value'))
188
+ const input = within(container).getByRole('textbox') as HTMLInputElement
189
+ fireEvent.change(input, { target: { value: '9' } })
190
+ fireEvent.keyDown(input, { key: 'Enter' })
191
+
192
+ expect(onSetData).toHaveBeenCalledWith('b1', { 'arr[0].y': 9 })
193
+ })
194
+
195
+ it('renders no checkbox for a boolean value whose key is unsafe', () => {
196
+ const { container, getByText } = render(
197
+ <AppDataPanel state={unsafeBooleanState()} onSelectBridge={vi.fn()} onSetData={vi.fn()} />,
198
+ )
199
+ fireEvent.click(getByText('profile'))
200
+ const row = rowByKey(container, 'first.enabled')
201
+
202
+ expect(row.querySelector('input[type="checkbox"]')).toBeNull()
203
+ })
204
+ })
205
+
206
+ describe('AppDataPanel: undo/redo stack advances only after onSetData succeeds', () => {
207
+ it('leaves 撤销 disabled when onSetData returns false synchronously', () => {
208
+ const onSetData = vi.fn(() => false)
209
+ const { container, getByTitle } = render(
210
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
211
+ )
212
+ fireEvent.click(checkboxFor(container, 'flag'))
213
+
214
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
215
+ })
216
+
217
+ it('leaves 撤销 disabled when onSetData resolves to false', async () => {
218
+ const onSetData = vi.fn(() => Promise.resolve(false))
219
+ const { container, getByTitle } = render(
220
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
221
+ )
222
+ fireEvent.click(checkboxFor(container, 'flag'))
223
+
224
+ await waitFor(() => expect(onSetData).toHaveBeenCalled())
225
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
226
+ })
227
+
228
+ it('does not move the undo record when the 撤销 write-back itself is rejected', () => {
229
+ const onSetData = vi.fn(() => true)
230
+ const { container, getByTitle } = render(
231
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
232
+ )
233
+ fireEvent.click(checkboxFor(container, 'flag'))
234
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
235
+
236
+ onSetData.mockImplementation(() => false)
237
+ fireEvent.click(getByTitle('撤销'))
238
+
239
+ expect(onSetData).toHaveBeenCalledWith('b1', { flag: true })
240
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
241
+ expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true)
242
+ })
243
+
244
+ it('drops a stale undo record and skips onSetData once its bridge no longer exists', () => {
245
+ const onSetData = vi.fn(() => true)
246
+ const { container, getByTitle, rerender } = render(
247
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
248
+ )
249
+ fireEvent.click(checkboxFor(container, 'flag'))
250
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
251
+
252
+ const otherBridgeState: AppDataState = {
253
+ bridges: [{ id: 'b2', pagePath: '/pages/two/two' }],
254
+ activeBridgeId: 'b2',
255
+ entries: { b2: { comp: { count: 1 } } },
256
+ }
257
+ rerender(<AppDataPanel state={otherBridgeState} onSelectBridge={vi.fn()} onSetData={onSetData} />)
258
+ onSetData.mockClear()
259
+
260
+ fireEvent.click(getByTitle('撤销'))
261
+
262
+ expect(onSetData).not.toHaveBeenCalled()
263
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
264
+ expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true)
265
+ })
266
+ })
267
+
268
+ describe('AppDataPanel: number edit rejects non-finite input', () => {
269
+ it('does not commit and exits edit mode when the input parses to Infinity', () => {
270
+ const onSetData = vi.fn()
271
+ const { container } = render(
272
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
273
+ )
274
+ fireEvent.dblClick(valueElFor(container, 'count'))
275
+ const input = within(container).getByRole('textbox') as HTMLInputElement
276
+ fireEvent.change(input, { target: { value: 'Infinity' } })
277
+ fireEvent.keyDown(input, { key: 'Enter' })
278
+
279
+ expect(onSetData).not.toHaveBeenCalled()
280
+ expect(within(container).queryByRole('textbox')).toBeNull()
281
+ })
282
+
283
+ it('does not commit when the input overflows to Infinity (1e309)', () => {
284
+ const onSetData = vi.fn()
285
+ const { container } = render(
286
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
287
+ )
288
+ fireEvent.dblClick(valueElFor(container, 'count'))
289
+ const input = within(container).getByRole('textbox') as HTMLInputElement
290
+ fireEvent.change(input, { target: { value: '1e309' } })
291
+ fireEvent.keyDown(input, { key: 'Enter' })
292
+
293
+ expect(onSetData).not.toHaveBeenCalled()
294
+ expect(within(container).queryByRole('textbox')).toBeNull()
295
+ })
296
+ })
297
+
298
+ describe('AppDataPanel: a nested empty-string key is read-only', () => {
299
+ it('gives the empty-key row no data-path and blocks its double-click editor — the runtime toPath for `profile.` resolves to just [\'profile\'], so a write would clobber the whole object', () => {
300
+ const { container, getByText } = render(
301
+ <AppDataPanel state={nestedEmptyKeyState()} onSelectBridge={vi.fn()} onSetData={vi.fn()} />,
302
+ )
303
+ fireEvent.click(getByText('profile'))
304
+ const row = rowByEmptyKey(container)
305
+
306
+ expect(row.hasAttribute('data-path')).toBe(false)
307
+ fireEvent.dblClick(within(row).getByTestId('appdata-value'))
308
+ expect(within(container).queryByRole('textbox')).toBeNull()
309
+ })
310
+
311
+ it('keeps the sibling `profile.safe` row editable — only the empty key is blocked', () => {
312
+ const onSetData = vi.fn()
313
+ const { container, getByText } = render(
314
+ <AppDataPanel state={nestedEmptyKeyState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
315
+ )
316
+ fireEvent.click(getByText('profile'))
317
+ const row = rowByKey(container, 'safe')
318
+ expect(row.getAttribute('data-path')).toBe('profile.safe')
319
+
320
+ fireEvent.dblClick(within(row).getByTestId('appdata-value'))
321
+ const input = within(container).getByRole('textbox') as HTMLInputElement
322
+ fireEvent.change(input, { target: { value: 'updated' } })
323
+ fireEvent.keyDown(input, { key: 'Enter' })
324
+
325
+ expect(onSetData).toHaveBeenCalledWith('b1', { 'profile.safe': 'updated' })
326
+ })
327
+ })
328
+
329
+ describe('AppDataPanel: 撤销 guards against a second click racing an in-flight dispatch', () => {
330
+ it('calls onSetData once, disables 撤销 while pending, and settles into a single redo record', async () => {
331
+ const onSetData = vi.fn<(bridgeId: string, patch: Record<string, unknown>) => boolean | Promise<boolean>>(() => true)
332
+ const { container, getByTitle } = render(
333
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
334
+ )
335
+ fireEvent.click(checkboxFor(container, 'flag'))
336
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
337
+
338
+ const write = deferred<boolean>()
339
+ onSetData.mockImplementation(() => write.promise)
340
+ onSetData.mockClear()
341
+
342
+ fireEvent.click(getByTitle('撤销'))
343
+ // The write is in flight — the undo stack is still nonempty, so the
344
+ // button must be disabled by pending state, not by an empty stack.
345
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
346
+
347
+ fireEvent.click(getByTitle('撤销'))
348
+ write.resolve(true)
349
+
350
+ await waitFor(() => expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(false))
351
+ expect(onSetData).toHaveBeenCalledTimes(1)
352
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
353
+
354
+ fireEvent.click(getByTitle('重做'))
355
+ await waitFor(() => expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true))
356
+ })
357
+ })
358
+
359
+ describe('AppDataPanel: a rejected dispatch is treated as failure, not an unhandled rejection', () => {
360
+ it('leaves 撤销 disabled and raises no unhandled promise rejection when onSetData rejects', async () => {
361
+ const unhandled: unknown[] = []
362
+ const onUnhandledRejection = (reason: unknown): void => { unhandled.push(reason) }
363
+ process.on('unhandledRejection', onUnhandledRejection)
364
+
365
+ try {
366
+ const onSetData = vi.fn(() => Promise.reject(new Error('ipc torn down')))
367
+ const { container, getByTitle } = render(
368
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
369
+ )
370
+ fireEvent.click(checkboxFor(container, 'flag'))
371
+
372
+ await waitFor(() => expect(onSetData).toHaveBeenCalled())
373
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
374
+
375
+ // Gives Node's unhandled-rejection detection a turn to fire before the
376
+ // assertion, so an uncaught rejection inside `dispatch` surfaces here
377
+ // rather than bleeding into a later, unrelated test.
378
+ await new Promise((resolve) => setTimeout(resolve, 0))
379
+ expect(unhandled).toHaveLength(0)
380
+ } finally {
381
+ process.off('unhandledRejection', onUnhandledRejection)
382
+ }
383
+ })
384
+ })
@@ -0,0 +1,247 @@
1
+ /**
2
+ * AppDataPanel — WeChat setData-style tree editing.
3
+ *
4
+ * Editing is gated entirely on `onSetData` being passed: without it the tree
5
+ * is read-only (no checkboxes, double-click does nothing). With it, every
6
+ * primitive (string/number/boolean) value row carries a `data-path` in the
7
+ * WeChat setData path syntax (`user.name`, `list[0].id`); booleans edit via
8
+ * an inline checkbox, string/number values edit via a double-click-activated
9
+ * textbox committed on Enter (Escape cancels); object/array/null values
10
+ * never become editable. The panel also keeps its own undo/redo stack over
11
+ * committed edits, independent of whatever the host echoes back through
12
+ * `state`.
13
+ */
14
+ import { describe, expect, it, vi } from 'vitest'
15
+ import { fireEvent, render, within } from '@testing-library/react'
16
+ import { AppDataPanel, type AppDataPanelState as AppDataState } from './appdata-panel-view.js'
17
+
18
+ function editableState(): AppDataState {
19
+ return {
20
+ bridges: [{ id: 'b1', pagePath: '/pages/index/index' }],
21
+ activeBridgeId: 'b1',
22
+ entries: {
23
+ b1: {
24
+ comp: {
25
+ count: 1,
26
+ flag: true,
27
+ label: 'hello',
28
+ user: { name: 'alice', age: 3 },
29
+ list: [{ id: 1 }, { id: 2 }],
30
+ empty: null,
31
+ },
32
+ },
33
+ },
34
+ }
35
+ }
36
+
37
+ function valueElFor(container: HTMLElement, path: string): HTMLElement {
38
+ const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
39
+ return within(row).getByTestId('appdata-value')
40
+ }
41
+
42
+ function checkboxFor(container: HTMLElement, path: string): HTMLInputElement {
43
+ const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
44
+ return row.querySelector('input[type="checkbox"]') as HTMLInputElement
45
+ }
46
+
47
+ describe('AppDataPanel: read-only tree without onSetData', () => {
48
+ it('renders no checkboxes when onSetData is not provided', () => {
49
+ const { container } = render(<AppDataPanel state={editableState()} onSelectBridge={vi.fn()} />)
50
+ expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(0)
51
+ })
52
+
53
+ it('does not open an editable textbox on double-click when onSetData is not provided', () => {
54
+ const { getByText, queryByRole } = render(<AppDataPanel state={editableState()} onSelectBridge={vi.fn()} />)
55
+ fireEvent.dblClick(getByText('hello'))
56
+ expect(queryByRole('textbox')).toBeNull()
57
+ })
58
+ })
59
+
60
+ describe('AppDataPanel: data-path attributes (edit mode)', () => {
61
+ it('sets data-path to the top-level key for a root primitive', () => {
62
+ const { container } = render(
63
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={vi.fn()} />,
64
+ )
65
+ expect(container.querySelector('[data-path="count"]')).not.toBeNull()
66
+ })
67
+
68
+ it('sets data-path to dot notation for a nested object key', () => {
69
+ const { container, getByText } = render(
70
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={vi.fn()} />,
71
+ )
72
+ fireEvent.click(getByText('user'))
73
+ expect(container.querySelector('[data-path="user.name"]')).not.toBeNull()
74
+ })
75
+
76
+ it('sets data-path to WeChat setData array-index-then-key notation for an array element field', () => {
77
+ const { container, getByText } = render(
78
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={vi.fn()} />,
79
+ )
80
+ fireEvent.click(getByText('list'))
81
+ expect(container.querySelector('[data-path="list[0].id"]')).not.toBeNull()
82
+ expect(container.querySelector('[data-path="list[1].id"]')).not.toBeNull()
83
+ })
84
+
85
+ it('does not set data-path on object/array/null values', () => {
86
+ const { container } = render(
87
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={vi.fn()} />,
88
+ )
89
+ // A sibling primitive at the same level DOES carry data-path — proves the
90
+ // absence above is the object/array/null exclusion, not a global lack of
91
+ // data-path support.
92
+ expect(container.querySelector('[data-path="count"]')).not.toBeNull()
93
+ expect(container.querySelector('[data-path="user"]')).toBeNull()
94
+ expect(container.querySelector('[data-path="list"]')).toBeNull()
95
+ expect(container.querySelector('[data-path="empty"]')).toBeNull()
96
+ })
97
+ })
98
+
99
+ describe('AppDataPanel: boolean checkbox toggle', () => {
100
+ it('renders a checked checkbox reflecting a true boolean value', () => {
101
+ const { container } = render(
102
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={vi.fn()} />,
103
+ )
104
+ expect(checkboxFor(container, 'flag').checked).toBe(true)
105
+ })
106
+
107
+ it('calls onSetData with the flipped value when the checkbox is clicked', () => {
108
+ const onSetData = vi.fn()
109
+ const { container } = render(
110
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
111
+ )
112
+ fireEvent.click(checkboxFor(container, 'flag'))
113
+ expect(onSetData).toHaveBeenCalledWith('b1', { flag: false })
114
+ })
115
+ })
116
+
117
+ describe('AppDataPanel: string/number inline edit via double-click', () => {
118
+ it('opens a prefilled textbox on double-click of a string value', () => {
119
+ const { container } = render(
120
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={vi.fn()} />,
121
+ )
122
+ fireEvent.dblClick(valueElFor(container, 'label'))
123
+ const input = within(container).getByRole('textbox') as HTMLInputElement
124
+ expect(input.value).toBe('hello')
125
+ })
126
+
127
+ it('commits a string edit as raw text on Enter and exits edit mode', () => {
128
+ const onSetData = vi.fn()
129
+ const { container } = render(
130
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
131
+ )
132
+ fireEvent.dblClick(valueElFor(container, 'label'))
133
+ const input = within(container).getByRole('textbox') as HTMLInputElement
134
+ fireEvent.change(input, { target: { value: 'world' } })
135
+ fireEvent.keyDown(input, { key: 'Enter' })
136
+
137
+ expect(onSetData).toHaveBeenCalledWith('b1', { label: 'world' })
138
+ expect(within(container).queryByRole('textbox')).toBeNull()
139
+ })
140
+
141
+ it('commits a number edit as Number(input) on Enter', () => {
142
+ const onSetData = vi.fn()
143
+ const { container } = render(
144
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
145
+ )
146
+ fireEvent.dblClick(valueElFor(container, 'count'))
147
+ const input = within(container).getByRole('textbox') as HTMLInputElement
148
+ fireEvent.change(input, { target: { value: '42' } })
149
+ fireEvent.keyDown(input, { key: 'Enter' })
150
+
151
+ expect(onSetData).toHaveBeenCalledWith('b1', { count: 42 })
152
+ })
153
+
154
+ it('does not commit and exits edit mode when the number input is not a valid number', () => {
155
+ const onSetData = vi.fn()
156
+ const { container } = render(
157
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
158
+ )
159
+ fireEvent.dblClick(valueElFor(container, 'count'))
160
+ const input = within(container).getByRole('textbox') as HTMLInputElement
161
+ fireEvent.change(input, { target: { value: 'not-a-number' } })
162
+ fireEvent.keyDown(input, { key: 'Enter' })
163
+
164
+ expect(onSetData).not.toHaveBeenCalled()
165
+ expect(within(container).queryByRole('textbox')).toBeNull()
166
+ })
167
+
168
+ it('discards the edit and exits edit mode on Escape without calling onSetData', () => {
169
+ const onSetData = vi.fn()
170
+ const { container } = render(
171
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
172
+ )
173
+ fireEvent.dblClick(valueElFor(container, 'label'))
174
+ const input = within(container).getByRole('textbox') as HTMLInputElement
175
+ fireEvent.change(input, { target: { value: 'discarded' } })
176
+ fireEvent.keyDown(input, { key: 'Escape' })
177
+
178
+ expect(onSetData).not.toHaveBeenCalled()
179
+ expect(within(container).queryByRole('textbox')).toBeNull()
180
+ })
181
+ })
182
+
183
+ describe('AppDataPanel: undo/redo', () => {
184
+ it('starts with 撤销 and 重做 both disabled', () => {
185
+ const { getByTitle } = render(
186
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={vi.fn()} />,
187
+ )
188
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
189
+ expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true)
190
+ })
191
+
192
+ it('enables 撤销 (but not 重做) after one committed edit', () => {
193
+ const onSetData = vi.fn()
194
+ const { container, getByTitle } = render(
195
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
196
+ )
197
+ fireEvent.click(checkboxFor(container, 'flag'))
198
+
199
+ expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
200
+ expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true)
201
+ })
202
+
203
+ it('calls onSetData with the pre-edit value on 撤销 and enables 重做', () => {
204
+ const onSetData = vi.fn()
205
+ const { container, getByTitle } = render(
206
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
207
+ )
208
+ fireEvent.click(checkboxFor(container, 'flag'))
209
+ onSetData.mockClear()
210
+
211
+ fireEvent.click(getByTitle('撤销'))
212
+
213
+ expect(onSetData).toHaveBeenCalledWith('b1', { flag: true })
214
+ expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(false)
215
+ })
216
+
217
+ it('calls onSetData with the post-edit value on 重做', () => {
218
+ const onSetData = vi.fn()
219
+ const { container, getByTitle } = render(
220
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
221
+ )
222
+ fireEvent.click(checkboxFor(container, 'flag'))
223
+ fireEvent.click(getByTitle('撤销'))
224
+ onSetData.mockClear()
225
+
226
+ fireEvent.click(getByTitle('重做'))
227
+
228
+ expect(onSetData).toHaveBeenCalledWith('b1', { flag: false })
229
+ })
230
+
231
+ it('clears the redo stack once a new edit is committed after an undo', () => {
232
+ const onSetData = vi.fn()
233
+ const { container, getByTitle } = render(
234
+ <AppDataPanel state={editableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
235
+ )
236
+ fireEvent.click(checkboxFor(container, 'flag'))
237
+ fireEvent.click(getByTitle('撤销'))
238
+ expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(false)
239
+
240
+ fireEvent.dblClick(valueElFor(container, 'count'))
241
+ const input = within(container).getByRole('textbox') as HTMLInputElement
242
+ fireEvent.change(input, { target: { value: '99' } })
243
+ fireEvent.keyDown(input, { key: 'Enter' })
244
+
245
+ expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true)
246
+ })
247
+ })