@dimina-kit/inspect 0.4.0-dev.20260716214527 → 0.4.0-dev.20260717120050
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,265 @@
|
|
|
1
|
+
// The AppData tree: one page's merged setData state as a collapsible,
|
|
2
|
+
// optionally editable tree (WeChat DevTools AppData semantics). Pure
|
|
3
|
+
// presentation + local expansion/edit state; committed edits are reported
|
|
4
|
+
// upward as (path, next, prev) — the panel owns the undo/redo stack and the
|
|
5
|
+
// actual write-back, and the rendered VALUES always come from `root`, never
|
|
6
|
+
// from a local echo of an edit.
|
|
7
|
+
import React, { useState } from 'react'
|
|
8
|
+
|
|
9
|
+
/** One expand/collapse-all toolbar action. Every keepalive tree instance sees
|
|
10
|
+
* the same command object; a tree applies it only when `bridgeId` is its own
|
|
11
|
+
* and `seq` is unseen — a background tree must not replay a command that was
|
|
12
|
+
* aimed at the tree visible when the button was clicked. */
|
|
13
|
+
export interface AppDataTreeCommand {
|
|
14
|
+
seq: number
|
|
15
|
+
mode: 'expanded' | 'collapsed'
|
|
16
|
+
bridgeId: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AppDataTreeProps {
|
|
20
|
+
root: Record<string, unknown>
|
|
21
|
+
bridgeId: string
|
|
22
|
+
command: AppDataTreeCommand | null
|
|
23
|
+
/** Absent → read-only tree (no checkboxes, double-click is inert). */
|
|
24
|
+
onCommit?: (path: string, next: unknown, prev: unknown) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** WeChat setData path syntax: dots between keys, indices in brackets. */
|
|
28
|
+
function childPath(parent: string, key: string | number): string {
|
|
29
|
+
if (typeof key === 'number') return `${parent}[${key}]`
|
|
30
|
+
return parent === '' ? key : `${parent}.${key}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isContainer(v: unknown): v is object {
|
|
34
|
+
return typeof v === 'object' && v !== null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sortedEntries(value: object): Array<[string | number, unknown]> {
|
|
38
|
+
// Array.isArray narrows to any[]; the explicit unknown[] view keeps the
|
|
39
|
+
// elements typed (type-coverage counts every any-typed identifier).
|
|
40
|
+
if (Array.isArray(value)) return (value as unknown[]).map((v, i) => [i, v])
|
|
41
|
+
return Object.keys(value).sort().map((k) => [k, (value as Record<string, unknown>)[k]])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function countLabel(value: object): string {
|
|
45
|
+
return Array.isArray(value) ? `[${value.length}]` : `{${Object.keys(value).length}}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// default: only the root row is open; expanded/collapsed: everything is.
|
|
49
|
+
type Baseline = 'default' | 'expanded' | 'collapsed'
|
|
50
|
+
const ROOT_PATH = ''
|
|
51
|
+
|
|
52
|
+
interface TreeState {
|
|
53
|
+
baseline: Baseline
|
|
54
|
+
overrides: Map<string, boolean>
|
|
55
|
+
editingPath: string | null
|
|
56
|
+
draft: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isExpanded(state: TreeState, path: string): boolean {
|
|
60
|
+
const override = state.overrides.get(path)
|
|
61
|
+
if (override !== undefined) return override
|
|
62
|
+
if (state.baseline === 'expanded') return true
|
|
63
|
+
if (state.baseline === 'collapsed') return false
|
|
64
|
+
return path === ROOT_PATH
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface NodeContext {
|
|
68
|
+
state: TreeState
|
|
69
|
+
toggle: (path: string, value: unknown) => void
|
|
70
|
+
beginEdit: (path: string, initial: string) => void
|
|
71
|
+
setDraft: (draft: string) => void
|
|
72
|
+
endEdit: () => void
|
|
73
|
+
onCommit?: (path: string, next: unknown, prev: unknown) => void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ValueCell({ path, value, editable, ctx }: {
|
|
77
|
+
path: string
|
|
78
|
+
value: unknown
|
|
79
|
+
editable: boolean
|
|
80
|
+
ctx: NodeContext
|
|
81
|
+
}) {
|
|
82
|
+
if (typeof value === 'boolean') {
|
|
83
|
+
return (
|
|
84
|
+
<span className="inline-flex items-center gap-1 text-code-keyword">
|
|
85
|
+
{editable && (
|
|
86
|
+
<input
|
|
87
|
+
type="checkbox"
|
|
88
|
+
checked={value}
|
|
89
|
+
onChange={() => ctx.onCommit?.(path, !value, value)}
|
|
90
|
+
className="accent-accent"
|
|
91
|
+
/>
|
|
92
|
+
)}
|
|
93
|
+
{String(value)}
|
|
94
|
+
</span>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
if (value === null || value === undefined) {
|
|
98
|
+
return <span className="text-code-keyword">{String(value)}</span>
|
|
99
|
+
}
|
|
100
|
+
if (ctx.state.editingPath === path) {
|
|
101
|
+
const commit = (): void => {
|
|
102
|
+
const prev = value
|
|
103
|
+
if (typeof prev === 'number') {
|
|
104
|
+
const draft = ctx.state.draft.trim()
|
|
105
|
+
const next = Number(draft)
|
|
106
|
+
ctx.endEdit()
|
|
107
|
+
// Only finite numbers commit: Infinity/-Infinity (and 1e309-style
|
|
108
|
+
// overflow) are not serializable AppData values.
|
|
109
|
+
if (draft === '' || !Number.isFinite(next)) return
|
|
110
|
+
ctx.onCommit?.(path, next, prev)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const next = ctx.state.draft
|
|
114
|
+
ctx.endEdit()
|
|
115
|
+
ctx.onCommit?.(path, next, prev)
|
|
116
|
+
}
|
|
117
|
+
return (
|
|
118
|
+
<input
|
|
119
|
+
type="text"
|
|
120
|
+
autoFocus
|
|
121
|
+
value={ctx.state.draft}
|
|
122
|
+
onChange={(e) => ctx.setDraft(e.target.value)}
|
|
123
|
+
onKeyDown={(e) => {
|
|
124
|
+
if (e.key === 'Enter') commit()
|
|
125
|
+
else if (e.key === 'Escape') ctx.endEdit()
|
|
126
|
+
}}
|
|
127
|
+
onBlur={commit}
|
|
128
|
+
className="bg-surface-3 border border-accent rounded px-1 text-[12px] font-mono min-w-0 w-40"
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
const color = typeof value === 'number'
|
|
133
|
+
? { color: 'var(--color-code-number)' }
|
|
134
|
+
: undefined
|
|
135
|
+
return (
|
|
136
|
+
<span
|
|
137
|
+
data-testid="appdata-value"
|
|
138
|
+
className={typeof value === 'string' ? 'text-code-orange' : undefined}
|
|
139
|
+
style={color}
|
|
140
|
+
onDoubleClick={editable ? () => ctx.beginEdit(path, String(value)) : undefined}
|
|
141
|
+
>
|
|
142
|
+
{String(value)}
|
|
143
|
+
</span>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** True when a key segment would be re-parsed by the runtime's lodash-style
|
|
148
|
+
* `toPath` (dots / brackets), or silently DROPPED by it (empty keys — toPath
|
|
149
|
+
* never pushes an empty segment, so `profile.` parses to just `['profile']`
|
|
150
|
+
* and a write would overwrite the parent). Array indices are numbers and
|
|
151
|
+
* always safe. */
|
|
152
|
+
function segmentUnsafe(key: string | number): boolean {
|
|
153
|
+
return typeof key === 'string' && (key === '' || /[.[\]]/.test(key))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function TreeNode({ path, label, value, depth, unsafeSegments, ctx }: {
|
|
157
|
+
path: string
|
|
158
|
+
label: string
|
|
159
|
+
value: unknown
|
|
160
|
+
depth: number
|
|
161
|
+
/** Some segment on this node's path (its own key included) contains `.`/`[`/`]`. */
|
|
162
|
+
unsafeSegments: boolean
|
|
163
|
+
ctx: NodeContext
|
|
164
|
+
}) {
|
|
165
|
+
const indent = { paddingLeft: depth * 14 }
|
|
166
|
+
if (isContainer(value)) {
|
|
167
|
+
const open = isExpanded(ctx.state, path)
|
|
168
|
+
return (
|
|
169
|
+
<div>
|
|
170
|
+
<div
|
|
171
|
+
className="flex items-center gap-1 px-2 py-px cursor-pointer hover:bg-surface-3 text-[12px] font-mono"
|
|
172
|
+
style={indent}
|
|
173
|
+
onClick={() => ctx.toggle(path, value)}
|
|
174
|
+
>
|
|
175
|
+
<span className="text-text-secondary w-3 shrink-0 text-center select-none">
|
|
176
|
+
{open ? '▾' : '▸'}
|
|
177
|
+
</span>
|
|
178
|
+
<span className="text-code-blue">{label}</span>
|
|
179
|
+
<span className="text-text-secondary">{countLabel(value)}</span>
|
|
180
|
+
</div>
|
|
181
|
+
{open && sortedEntries(value).map(([key, child]) => (
|
|
182
|
+
<TreeNode
|
|
183
|
+
key={String(key)}
|
|
184
|
+
path={childPath(path, key)}
|
|
185
|
+
label={String(key)}
|
|
186
|
+
value={child}
|
|
187
|
+
depth={depth + 1}
|
|
188
|
+
unsafeSegments={unsafeSegments || segmentUnsafe(key)}
|
|
189
|
+
ctx={ctx}
|
|
190
|
+
/>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
const primitive = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
|
196
|
+
// A multi-segment path with an unsafe segment cannot round-trip through the
|
|
197
|
+
// runtime's string-path `set()` — the patch key would be re-split on the
|
|
198
|
+
// dots/brackets inside the key and write a DIFFERENT field. A single-segment
|
|
199
|
+
// (top-level) key is safe regardless of content: the runtime's own-key check
|
|
200
|
+
// short-circuits before path parsing. `__proto__` is unwritable at ANY
|
|
201
|
+
// depth: the runtime's isUnsafeProperty drops the write outright. Unsafe
|
|
202
|
+
// rows render read-only.
|
|
203
|
+
const pathAmbiguous = (depth > 1 && unsafeSegments) || label === '__proto__'
|
|
204
|
+
const editable = primitive && ctx.onCommit !== undefined && !pathAmbiguous
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
className="flex items-center gap-1 px-2 py-px text-[12px] font-mono"
|
|
208
|
+
style={indent}
|
|
209
|
+
{...(editable ? { 'data-path': path } : {})}
|
|
210
|
+
>
|
|
211
|
+
<span className="w-3 shrink-0" />
|
|
212
|
+
<span className="text-code-blue">{label}</span>
|
|
213
|
+
<span className="text-text-secondary">:</span>
|
|
214
|
+
<ValueCell path={path} value={value} editable={editable} ctx={ctx} />
|
|
215
|
+
</div>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function AppDataTree({ root, bridgeId, command, onCommit }: AppDataTreeProps) {
|
|
220
|
+
const [baseline, setBaseline] = useState<Baseline>('default')
|
|
221
|
+
const [overrides, setOverrides] = useState<Map<string, boolean>>(new Map())
|
|
222
|
+
const [appliedSeq, setAppliedSeq] = useState(0)
|
|
223
|
+
const [editingPath, setEditingPath] = useState<string | null>(null)
|
|
224
|
+
const [draft, setDraft] = useState('')
|
|
225
|
+
|
|
226
|
+
// Applying a toolbar command during render (state-adjust-in-render, same
|
|
227
|
+
// pattern as useActiveBridgeId) keeps the frame it lands on consistent.
|
|
228
|
+
if (command && command.bridgeId === bridgeId && command.seq !== appliedSeq) {
|
|
229
|
+
setBaseline(command.mode)
|
|
230
|
+
setOverrides(new Map())
|
|
231
|
+
setAppliedSeq(command.seq)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const state: TreeState = { baseline, overrides, editingPath, draft }
|
|
235
|
+
const ctx: NodeContext = {
|
|
236
|
+
state,
|
|
237
|
+
toggle: (path, value) => {
|
|
238
|
+
const next = new Map(overrides)
|
|
239
|
+
const opening = !isExpanded(state, path)
|
|
240
|
+
next.set(path, opening)
|
|
241
|
+
// Opening an array also opens its container elements: the elements are
|
|
242
|
+
// anonymous index rows, so surfacing their fields in the same click is
|
|
243
|
+
// what makes `list[0].id` reachable without a second dig.
|
|
244
|
+
if (opening && Array.isArray(value)) {
|
|
245
|
+
;(value as unknown[]).forEach((element, i) => {
|
|
246
|
+
if (isContainer(element)) next.set(childPath(path, i), true)
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
setOverrides(next)
|
|
250
|
+
},
|
|
251
|
+
beginEdit: (path, initial) => {
|
|
252
|
+
setEditingPath(path)
|
|
253
|
+
setDraft(initial)
|
|
254
|
+
},
|
|
255
|
+
setDraft,
|
|
256
|
+
endEdit: () => setEditingPath(null),
|
|
257
|
+
onCommit,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div data-testid="appdata-tree" className="py-1">
|
|
262
|
+
<TreeNode path={ROOT_PATH} label="object" value={root} depth={0} unsafeSegments={false} ctx={ctx} />
|
|
263
|
+
</div>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppDataPanel — a single write gate admits at most one in-flight write
|
|
3
|
+
* across every write path (commit and replay share it): while a write is
|
|
4
|
+
* pending, any further commit or replay request is dropped outright — no
|
|
5
|
+
* local echo, no queued follow-up, no second `onSetData` call. This is what
|
|
6
|
+
* keeps undo/redo stack order honest when a click races an in-flight async
|
|
7
|
+
* write:
|
|
8
|
+
*
|
|
9
|
+
* 1. A commit fired while a replay (撤销/重做) is in flight is dropped, so a
|
|
10
|
+
* resolving replay can never re-add a record that a same-turn commit
|
|
11
|
+
* already invalidated by clearing the redo stack.
|
|
12
|
+
* 2. A second commit fired while the first is still in flight is dropped, so
|
|
13
|
+
* the undo stack only ever grows by the write that was actually
|
|
14
|
+
* dispatched — never out of commit order.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
17
|
+
import { fireEvent, render, waitFor, within } from '@testing-library/react'
|
|
18
|
+
import { AppDataPanel, type AppDataPanelState as AppDataState } from './appdata-panel-view.js'
|
|
19
|
+
|
|
20
|
+
function writableState(): AppDataState {
|
|
21
|
+
return {
|
|
22
|
+
bridges: [{ id: 'b1', pagePath: '/pages/index/index' }],
|
|
23
|
+
activeBridgeId: 'b1',
|
|
24
|
+
entries: {
|
|
25
|
+
b1: {
|
|
26
|
+
comp: {
|
|
27
|
+
flag: true,
|
|
28
|
+
label: 'hello',
|
|
29
|
+
seed: 'S0',
|
|
30
|
+
a: 'A0',
|
|
31
|
+
b: 'B0',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checkboxFor(container: HTMLElement, path: string): HTMLInputElement {
|
|
39
|
+
const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
|
|
40
|
+
return row.querySelector('input[type="checkbox"]') as HTMLInputElement
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function valueElFor(container: HTMLElement, path: string): HTMLElement {
|
|
44
|
+
const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
|
|
45
|
+
return within(row).getByTestId('appdata-value')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Double-click a string/number row, replace its text, and commit on Enter. */
|
|
49
|
+
function commitText(container: HTMLElement, path: string, value: string): void {
|
|
50
|
+
fireEvent.dblClick(valueElFor(container, path))
|
|
51
|
+
const input = within(container).getByRole('textbox') as HTMLInputElement
|
|
52
|
+
fireEvent.change(input, { target: { value } })
|
|
53
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Resolves/rejects on demand — lets a test hold `onSetData` open mid-dispatch
|
|
57
|
+
* to exercise pending/reentrancy behavior before settling it. */
|
|
58
|
+
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
|
59
|
+
let resolve!: (value: T) => void
|
|
60
|
+
const promise = new Promise<T>((res) => { resolve = res })
|
|
61
|
+
return { promise, resolve }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('AppDataPanel: a commit is dropped while a replay is in flight', () => {
|
|
65
|
+
it('never calls onSetData for the dropped commit, and settles into a single redo record — never a resurrected one', async () => {
|
|
66
|
+
const onSetData = vi.fn<(bridgeId: string, patch: Record<string, unknown>) => boolean | Promise<boolean>>(() => true)
|
|
67
|
+
const { container, getByTitle } = render(
|
|
68
|
+
<AppDataPanel state={writableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
|
|
69
|
+
)
|
|
70
|
+
fireEvent.click(checkboxFor(container, 'flag'))
|
|
71
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
|
|
72
|
+
|
|
73
|
+
const write = deferred<boolean>()
|
|
74
|
+
onSetData.mockImplementation(() => write.promise)
|
|
75
|
+
onSetData.mockClear()
|
|
76
|
+
|
|
77
|
+
fireEvent.click(getByTitle('撤销'))
|
|
78
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
79
|
+
|
|
80
|
+
// A commit on an unrelated field lands mid-replay — the write gate must
|
|
81
|
+
// drop it before it ever reaches onSetData.
|
|
82
|
+
commitText(container, 'label', 'ignored')
|
|
83
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
84
|
+
|
|
85
|
+
write.resolve(true)
|
|
86
|
+
await waitFor(() => expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(false))
|
|
87
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
|
|
88
|
+
// Stack state matches "the dropped commit never happened": exactly the
|
|
89
|
+
// one replayed record moved to redo, nothing extra came back onto it.
|
|
90
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
91
|
+
|
|
92
|
+
fireEvent.click(getByTitle('重做'))
|
|
93
|
+
await waitFor(() => expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true))
|
|
94
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('AppDataPanel: a second commit is dropped while the first is in flight', () => {
|
|
99
|
+
it('calls onSetData once for the in-flight commit, disables 撤销/重做 while it is pending even with a nonempty stack, and undoes only the write that was actually dispatched', async () => {
|
|
100
|
+
const onSetData = vi.fn<(bridgeId: string, patch: Record<string, unknown>) => boolean | Promise<boolean>>(() => true)
|
|
101
|
+
const { container, getByTitle } = render(
|
|
102
|
+
<AppDataPanel state={writableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
|
|
103
|
+
)
|
|
104
|
+
// Seed a synchronously-settled undo record so the stack is nonempty
|
|
105
|
+
// before the race below — otherwise "disabled" would be indistinguishable
|
|
106
|
+
// from an empty-stack disable, not the write gate's pending state.
|
|
107
|
+
commitText(container, 'seed', 'S1')
|
|
108
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
|
|
109
|
+
|
|
110
|
+
const pending: Array<(value: boolean) => void> = []
|
|
111
|
+
onSetData.mockImplementation(() => new Promise<boolean>((resolve) => { pending.push(resolve) }))
|
|
112
|
+
onSetData.mockClear()
|
|
113
|
+
|
|
114
|
+
commitText(container, 'a', 'A1')
|
|
115
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
116
|
+
// The write gate's pending state disables both buttons even though the
|
|
117
|
+
// undo stack is nonempty — an in-flight commit holds the gate exactly
|
|
118
|
+
// like an in-flight replay does.
|
|
119
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
|
|
120
|
+
expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true)
|
|
121
|
+
|
|
122
|
+
commitText(container, 'b', 'B1')
|
|
123
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
124
|
+
|
|
125
|
+
pending[0]?.(true)
|
|
126
|
+
await waitFor(() => expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false))
|
|
127
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
128
|
+
|
|
129
|
+
fireEvent.click(getByTitle('撤销'))
|
|
130
|
+
// Only `a` was ever committed — `b` never reached onSetData — so the
|
|
131
|
+
// record undo replays is necessarily `a`'s.
|
|
132
|
+
expect(onSetData).toHaveBeenLastCalledWith('b1', { a: 'A0' })
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -55,6 +55,14 @@ async function flush() {
|
|
|
55
55
|
})
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** Finds a Pages sidebar row by its rendered label (pagePath, or id when pagePath is null). */
|
|
59
|
+
function pageItem(label: string): HTMLElement {
|
|
60
|
+
const items = screen.getAllByTestId('appdata-page-item')
|
|
61
|
+
const found = items.find(el => (el.textContent ?? '').trim() === label)
|
|
62
|
+
if (!found) throw new Error(`no page item labeled "${label}" among [${items.map(el => el.textContent).join(', ')}]`)
|
|
63
|
+
return found
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
afterEach(() => {
|
|
59
67
|
cleanup()
|
|
60
68
|
vi.unstubAllGlobals()
|
|
@@ -254,7 +262,7 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
254
262
|
expect(screen.getByTestId('appdata-panel').textContent).toContain('暂无页面数据(仅显示 Page 级 data)')
|
|
255
263
|
})
|
|
256
264
|
|
|
257
|
-
it('
|
|
265
|
+
it('renders the Pages sidebar with a single item even when there is only one bridge', async () => {
|
|
258
266
|
const { source, getSnapshotQueue } = createFakeSource()
|
|
259
267
|
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: '/pages/index/index' }], {
|
|
260
268
|
b1: { 'pages/index/index': { count: 1 } },
|
|
@@ -262,10 +270,11 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
262
270
|
render(<ConnectedAppDataPanel source={source} />)
|
|
263
271
|
await flush()
|
|
264
272
|
|
|
265
|
-
expect(screen.
|
|
273
|
+
expect(screen.getByTestId('appdata-pages')).not.toBeNull()
|
|
274
|
+
expect(screen.getAllByTestId('appdata-page-item')).toHaveLength(1)
|
|
266
275
|
})
|
|
267
276
|
|
|
268
|
-
it('renders one
|
|
277
|
+
it('renders one Pages sidebar item per bridge, labeled by pagePath and falling back to id when pagePath is null', async () => {
|
|
269
278
|
const { source, getSnapshotQueue } = createFakeSource()
|
|
270
279
|
getSnapshotQueue.push(Promise.resolve(snapshot([
|
|
271
280
|
{ id: 'b1', pagePath: '/pages/index/index' },
|
|
@@ -277,10 +286,9 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
277
286
|
render(<ConnectedAppDataPanel source={source} />)
|
|
278
287
|
await flush()
|
|
279
288
|
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
expect(
|
|
283
|
-
expect(tab2.textContent).toBe('b2')
|
|
289
|
+
const labels = screen.getAllByTestId('appdata-page-item').map(el => (el.textContent ?? '').trim())
|
|
290
|
+
expect(labels).toContain('/pages/index/index')
|
|
291
|
+
expect(labels).toContain('b2')
|
|
284
292
|
})
|
|
285
293
|
|
|
286
294
|
it('keeps every bridge container mounted and toggles display instead of unmounting on tab switch', async () => {
|
|
@@ -303,7 +311,7 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
303
311
|
expect(c2.style.display).toBe('flex')
|
|
304
312
|
expect(c1.style.display).toBe('none')
|
|
305
313
|
|
|
306
|
-
fireEvent.click(
|
|
314
|
+
fireEvent.click(pageItem('/pages/index/index'))
|
|
307
315
|
|
|
308
316
|
expect(c1.style.display).toBe('flex')
|
|
309
317
|
expect(c2.style.display).toBe('none')
|
|
@@ -312,7 +320,7 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
312
320
|
expect(document.querySelector('[data-bridge-id="b2"]')).toBe(c2)
|
|
313
321
|
})
|
|
314
322
|
|
|
315
|
-
it('
|
|
323
|
+
it('merges every entry\'s data into one tree so no bridge data is lost through the connected wiring', async () => {
|
|
316
324
|
const { source, getSnapshotQueue } = createFakeSource()
|
|
317
325
|
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: null }], {
|
|
318
326
|
b1: {
|
|
@@ -323,9 +331,9 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
323
331
|
render(<ConnectedAppDataPanel source={source} />)
|
|
324
332
|
await flush()
|
|
325
333
|
|
|
326
|
-
const
|
|
327
|
-
expect(
|
|
328
|
-
expect(
|
|
334
|
+
const tree = within(document.querySelector('[data-bridge-id="b1"]') as HTMLElement).getByTestId('appdata-tree')
|
|
335
|
+
expect(tree.textContent).toContain('count')
|
|
336
|
+
expect(tree.textContent).toContain('visible')
|
|
329
337
|
})
|
|
330
338
|
})
|
|
331
339
|
|
|
@@ -373,7 +381,7 @@ describe('ConnectedAppDataPanel: active bridge auto-follow', () => {
|
|
|
373
381
|
render(<ConnectedAppDataPanel source={source} />)
|
|
374
382
|
await flush()
|
|
375
383
|
|
|
376
|
-
fireEvent.click(
|
|
384
|
+
fireEvent.click(pageItem('/pages/index/index'))
|
|
377
385
|
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
|
|
378
386
|
|
|
379
387
|
act(() => {
|
|
@@ -393,7 +401,7 @@ describe('ConnectedAppDataPanel: active bridge auto-follow', () => {
|
|
|
393
401
|
render(<ConnectedAppDataPanel source={source} />)
|
|
394
402
|
await flush()
|
|
395
403
|
|
|
396
|
-
fireEvent.click(
|
|
404
|
+
fireEvent.click(pageItem('/pages/index/index'))
|
|
397
405
|
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
|
|
398
406
|
|
|
399
407
|
act(() => {
|
|
@@ -415,7 +423,7 @@ describe('ConnectedAppDataPanel: active bridge auto-follow', () => {
|
|
|
415
423
|
await flush()
|
|
416
424
|
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
|
|
417
425
|
|
|
418
|
-
fireEvent.click(
|
|
426
|
+
fireEvent.click(pageItem('/pages/detail/detail'))
|
|
419
427
|
expect((document.querySelector('[data-bridge-id="b2"]') as HTMLElement).style.display).toBe('flex')
|
|
420
428
|
|
|
421
429
|
rerender(<ConnectedAppDataPanel source={source} activePagePath="/pages/detail/detail" />)
|
|
@@ -52,11 +52,21 @@ export function ConnectedAppDataPanel({
|
|
|
52
52
|
|
|
53
53
|
const { activeBridgeId, setActiveBridge } = useActiveBridgeId(snapshot.bridges, activePagePath)
|
|
54
54
|
|
|
55
|
+
// Only a source with a write-back channel makes the tree editable. The
|
|
56
|
+
// dispatch result flows through to the panel: its undo/redo stacks only
|
|
57
|
+
// advance when the runtime actually accepted the write. The authoritative
|
|
58
|
+
// new VALUE still arrives back through the snapshot push, never locally.
|
|
59
|
+
const setData = source.setData?.bind(source)
|
|
60
|
+
const onSetData = setData
|
|
61
|
+
? (bridgeId: string, patch: Record<string, unknown>): Promise<boolean> => setData(bridgeId, patch)
|
|
62
|
+
: undefined
|
|
63
|
+
|
|
55
64
|
return (
|
|
56
65
|
<AppDataPanel
|
|
57
66
|
state={{ bridges: snapshot.bridges, activeBridgeId, entries: snapshot.entries }}
|
|
58
67
|
onSelectBridge={setActiveBridge}
|
|
59
68
|
isRuntimeRunning={isRuntimeRunning}
|
|
69
|
+
onSetData={onSetData}
|
|
60
70
|
/>
|
|
61
71
|
)
|
|
62
72
|
}
|