@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.
- package/README.md +7 -1
- package/dist/appdata-panel-view.js +155 -58
- package/dist/appdata-tree.js +143 -0
- package/dist/connected-appdata-panel.js +9 -1
- package/package.json +1 -1
- package/src/appdata-edge-values.test.tsx +150 -0
- package/src/appdata-panel-view-wechat.test.tsx +268 -0
- package/src/appdata-panel-view.tsx +237 -107
- package/src/appdata-source.ts +5 -0
- package/src/appdata-tree-edit-hardening.test.tsx +384 -0
- package/src/appdata-tree-edit.test.tsx +247 -0
- package/src/appdata-tree.tsx +265 -0
- package/src/appdata-write-serialization.test.tsx +134 -0
- package/src/connected-appdata-panel.test.tsx +23 -15
- package/src/connected-appdata-panel.tsx +10 -0
|
@@ -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
|
+
})
|