dsh-paste-code-block 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +118 -118
- package/README.zh-CN.md +118 -118
- package/cordis.patch.yml +4 -4
- package/docs/design.md +373 -337
- package/docs/images/hero.svg +3 -3
- package/docs/images/languages.svg +3 -3
- package/docs/images/render.cjs +20 -20
- package/docs/images/workflow.svg +3 -3
- package/package.json +3 -2
- package/src/chip-nodes.js +46 -46
- package/src/client.js +273 -61
- package/src/i18n.js +140 -140
- package/src/index.js +11 -11
- package/src/parse.js +159 -6
- package/src/projection.js +54 -54
- package/tests/chip-nodes.test.js +71 -0
- package/tests/fixtures.js +9 -0
- package/tests/fold-smoke.test.js +642 -0
- package/tests/fold.test.js +147 -0
- package/tests/i18n.test.js +112 -0
- package/tests/lexical-chip-probe.mjs +109 -0
- package/tests/parse.test.js +183 -0
- package/tests/projection.test.js +64 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fold smoke test — runs the REAL embedded client.js logic (module factory,
|
|
3
|
+
* scanFolds, card building, toggling, locale retitle, orphan sweep, dispose)
|
|
4
|
+
* against a minimal DOM shim. No browser, no dependencies.
|
|
5
|
+
*
|
|
6
|
+
* The shim implements exactly the subset client.js touches:
|
|
7
|
+
* attrs, children, textContent, classList, style, closest (single compound),
|
|
8
|
+
* querySelectorAll ([attr], [attr="v"], [class*="v"], .class, tag; comma +
|
|
9
|
+
* descendant pairs), insertAdjacentElement('afterend'), event listeners.
|
|
10
|
+
*/
|
|
11
|
+
import { test } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { B2 } from './fixtures.js'
|
|
17
|
+
|
|
18
|
+
// ---------------- mini DOM ----------------
|
|
19
|
+
class TextNode {
|
|
20
|
+
constructor(t) { this.nodeType = 3; this._t = t; this.parentElement = null }
|
|
21
|
+
get textContent() { return this._t }
|
|
22
|
+
set textContent(v) { this._t = String(v) }
|
|
23
|
+
}
|
|
24
|
+
class El {
|
|
25
|
+
constructor(tag, doc) {
|
|
26
|
+
this.nodeType = 1
|
|
27
|
+
this.tagName = String(tag).toUpperCase()
|
|
28
|
+
this.attrs = new Map()
|
|
29
|
+
this.childNodes = []
|
|
30
|
+
// Faithful style: unset properties read back as '' like CSSStyleDeclaration.
|
|
31
|
+
this.style = new Proxy({}, {
|
|
32
|
+
get: (o, k) => (k in o ? o[k] : ''),
|
|
33
|
+
set: (o, k, v) => { o[k] = v; return true },
|
|
34
|
+
})
|
|
35
|
+
this._listeners = {}
|
|
36
|
+
this.parentElement = null
|
|
37
|
+
this._doc = doc
|
|
38
|
+
}
|
|
39
|
+
get className() { return this.attrs.get('class') || '' }
|
|
40
|
+
set className(v) { this.attrs.set('class', v) }
|
|
41
|
+
get dataset() { return (this._dataset ||= {}) }
|
|
42
|
+
get type() { return this.attrs.get('type') || '' }
|
|
43
|
+
set type(v) { this.attrs.set('type', v) }
|
|
44
|
+
get classList() {
|
|
45
|
+
const self = this
|
|
46
|
+
return { contains: (c) => ` ${self.className} `.split(' ').filter(Boolean).includes(c) }
|
|
47
|
+
}
|
|
48
|
+
get isConnected() { let e = this; while (e.parentElement) e = e.parentElement; return e === this._doc.body }
|
|
49
|
+
setAttribute(k, v) { this.attrs.set(k, String(v)) }
|
|
50
|
+
getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null }
|
|
51
|
+
removeAttribute(k) { this.attrs.delete(k) }
|
|
52
|
+
get children() { return this.childNodes.filter((n) => n.nodeType === 1) }
|
|
53
|
+
get textContent() { return this.childNodes.map((n) => n.textContent).join('') }
|
|
54
|
+
set textContent(v) { this._clear(); if (v !== '') this._appendNode(new TextNode(String(v))) }
|
|
55
|
+
get nextElementSibling() {
|
|
56
|
+
const sibs = this.parentElement ? this.parentElement.children : []
|
|
57
|
+
return sibs[sibs.indexOf(this) + 1] || null
|
|
58
|
+
}
|
|
59
|
+
get previousElementSibling() {
|
|
60
|
+
const sibs = this.parentElement ? this.parentElement.children : []
|
|
61
|
+
return sibs[sibs.indexOf(this) - 1] || null
|
|
62
|
+
}
|
|
63
|
+
appendChild(n) { this._appendNode(n); return n }
|
|
64
|
+
append(...ns) { for (const n of ns) this._appendNode(n) }
|
|
65
|
+
insertAdjacentElement(pos, el) {
|
|
66
|
+
if (pos !== 'afterend') throw new Error('shim: only afterend needed')
|
|
67
|
+
const p = this.parentElement
|
|
68
|
+
if (!p) throw new Error('shim: no parent')
|
|
69
|
+
el.parentElement?._detach(el)
|
|
70
|
+
p.childNodes.splice(p.childNodes.indexOf(this) + 1, 0, el)
|
|
71
|
+
el.parentElement = p
|
|
72
|
+
}
|
|
73
|
+
remove() { this.parentElement?._detach(this) }
|
|
74
|
+
_detach(n) { const i = this.childNodes.indexOf(n); if (i >= 0) this.childNodes.splice(i, 1); n.parentElement = null }
|
|
75
|
+
_appendNode(n) { if (n.nodeType === 1 && n.parentElement) n.parentElement._detach(n); n.parentElement = this; this.childNodes.push(n) }
|
|
76
|
+
_clear() { for (const c of this.childNodes) c.parentElement = null; this.childNodes.length = 0 }
|
|
77
|
+
addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn) }
|
|
78
|
+
dispatch(type) { for (const fn of this._listeners[type] || []) fn({ preventDefault() {}, stopPropagation() {}, target: this }) }
|
|
79
|
+
querySelectorAll(sel) { return queryAll(this._doc, sel, this) }
|
|
80
|
+
querySelector(sel) { return this.querySelectorAll(sel)[0] || null }
|
|
81
|
+
closest(sel) {
|
|
82
|
+
let e = this
|
|
83
|
+
while (e) { if (matchSingle(e, sel.trim())) return e; e = e.parentElement }
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
matches(sel) { return matchSingle(this, sel.trim()) }
|
|
87
|
+
}
|
|
88
|
+
function walk(root, fn) {
|
|
89
|
+
for (const c of root.childNodes) if (c.nodeType === 1) { fn(c); walk(c, fn) }
|
|
90
|
+
}
|
|
91
|
+
function parseCompound(comp) {
|
|
92
|
+
const out = { tag: null, classes: [], attrs: [] }
|
|
93
|
+
for (const m of comp.matchAll(/(^[A-Za-z][\w-]*)|\.([\w-]+)|\[([\w-]+)(?:([*^$]?=)"([^"]*)")?\]/g)) {
|
|
94
|
+
if (m[1]) out.tag = m[1].toUpperCase()
|
|
95
|
+
else if (m[2]) out.classes.push(m[2])
|
|
96
|
+
else out.attrs.push({ name: m[3], op: m[4] || null, value: m[5] ?? null })
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
function matchCompound(el, comp) {
|
|
101
|
+
const c = parseCompound(comp)
|
|
102
|
+
if (c.tag && el.tagName !== c.tag) return false
|
|
103
|
+
const cls = ` ${el.className} `
|
|
104
|
+
for (const k of c.classes) if (!cls.includes(` ${k} `)) return false
|
|
105
|
+
for (const a of c.attrs) {
|
|
106
|
+
const v = el.getAttribute(a.name)
|
|
107
|
+
if (v === null) return false
|
|
108
|
+
if (a.op === '=*' || a.op === '*=') { if (!v.includes(a.value)) return false }
|
|
109
|
+
else if (a.op === '^=') { if (!v.startsWith(a.value)) return false }
|
|
110
|
+
else if (a.op === '$=') { if (!v.endsWith(a.value)) return false }
|
|
111
|
+
else if (a.op === '=') { if (v !== a.value) return false }
|
|
112
|
+
}
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
function matchSingle(el, sel) { return sel.split(',').some((part) => matchChain(el, part)) }
|
|
116
|
+
function matchChain(el, part) {
|
|
117
|
+
const comps = part.trim().split(/\s+(?![^[]*\])/)
|
|
118
|
+
if (!matchCompound(el, comps[comps.length - 1])) return false
|
|
119
|
+
let node = el.parentElement
|
|
120
|
+
for (let i = comps.length - 2; i >= 0; i -= 1) {
|
|
121
|
+
let found = false
|
|
122
|
+
while (node) { if (matchCompound(node, comps[i])) { found = true; node = node.parentElement; break } node = node.parentElement }
|
|
123
|
+
if (!found) return false
|
|
124
|
+
}
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
function queryAll(doc, sel, root) {
|
|
128
|
+
const out = []
|
|
129
|
+
walk(root || doc.body, (el) => { if (sel.split(',').some((p) => matchChain(el, p)) && !out.includes(el)) out.push(el) })
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const doc = { body: null, head: null }
|
|
134
|
+
doc.body = new El('body', doc)
|
|
135
|
+
doc.head = new El('head', doc)
|
|
136
|
+
const docListeners = {}
|
|
137
|
+
globalThis.Element = El // client.js guards listeners with `instanceof Element`
|
|
138
|
+
globalThis.document = {
|
|
139
|
+
body: doc.body,
|
|
140
|
+
head: doc.head,
|
|
141
|
+
createElement: (tag) => new El(tag, doc),
|
|
142
|
+
activeElement: null,
|
|
143
|
+
addEventListener(type, fn) { (docListeners[type] ||= []).push(fn) },
|
|
144
|
+
removeEventListener(type, fn) {
|
|
145
|
+
const l = docListeners[type]
|
|
146
|
+
if (l) { const i = l.indexOf(fn); if (i >= 0) l.splice(i, 1) }
|
|
147
|
+
},
|
|
148
|
+
querySelectorAll: (sel) => queryAll(doc, sel),
|
|
149
|
+
}
|
|
150
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
151
|
+
value: { clipboard: { writeText: async () => {} } }, configurable: true,
|
|
152
|
+
})
|
|
153
|
+
globalThis.MutationObserver = class { constructor(cb) { this.cb = cb } observe() {} disconnect() {} }
|
|
154
|
+
globalThis.requestAnimationFrame = (fn) => fn()
|
|
155
|
+
globalThis.window = {
|
|
156
|
+
__ModuleLoader__: {
|
|
157
|
+
load(def) {
|
|
158
|
+
const fakeRequire = (id) => (id === 'react' ? {
|
|
159
|
+
useState: () => [0, () => {}], useEffect() {}, useLayoutEffect() {}, useRef: () => ({ current: null }),
|
|
160
|
+
createElement: () => null, Fragment: {}, memo: (x) => x,
|
|
161
|
+
} : (() => { throw new Error('unexpected require ' + id) })())
|
|
162
|
+
const loaded = def.factory(fakeRequire)
|
|
163
|
+
globalThis.__pcb = loaded
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------- load the real client bundle ----------------
|
|
169
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
170
|
+
const src = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
|
|
171
|
+
new Function('window', 'document', 'navigator', 'MutationObserver', 'requestAnimationFrame', src)(
|
|
172
|
+
globalThis.window, globalThis.document, globalThis.navigator, globalThis.MutationObserver, globalThis.requestAnimationFrame,
|
|
173
|
+
)
|
|
174
|
+
assert.ok(globalThis.__pcb && typeof globalThis.__pcb.apply === 'function', 'client.js registered with the module loader')
|
|
175
|
+
|
|
176
|
+
// ---------------- boot apply() with a fake ctx ----------------
|
|
177
|
+
Object.defineProperty(globalThis, 'localStorage', {
|
|
178
|
+
configurable: true,
|
|
179
|
+
value: (() => {
|
|
180
|
+
const ls = new Map()
|
|
181
|
+
return {
|
|
182
|
+
getItem: (k) => (ls.has(k) ? ls.get(k) : null),
|
|
183
|
+
setItem: (k, v) => { ls.set(k, String(v)) },
|
|
184
|
+
removeItem: (k) => { ls.delete(k) },
|
|
185
|
+
key: (i) => [...ls.keys()][i] ?? null,
|
|
186
|
+
get length() { return ls.size },
|
|
187
|
+
}
|
|
188
|
+
})(),
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
let locale = 'zh'
|
|
192
|
+
const DICT = {
|
|
193
|
+
zh: { 'block.code': '代码块 {n}', 'block.text': '文本块 {n}', 'fold.code': '代码块', 'fold.text': '文本块', 'fold.aria': '展开或折叠该块', 'lines.one': '{count} 行', 'lines.other': '{count} 行', expand: '展开', collapse: '折叠', copy: '复制' },
|
|
194
|
+
en: { 'block.code': 'Code #{n}', 'block.text': 'Text #{n}', 'fold.code': 'Code', 'fold.text': 'Text', 'fold.aria': 'Expand or collapse this block', 'lines.one': '{count} line', 'lines.other': '{count} lines', expand: 'Expand', collapse: 'Collapse', copy: 'Copy' },
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Fake DSH session/input services so the controller can attach chips for real:
|
|
198
|
+
// scope(sid) resolves a shell snapshot that behaves like the published input
|
|
199
|
+
// state, and bail() runs the reference-insertion verb against it.
|
|
200
|
+
const shells = new Map()
|
|
201
|
+
function makeShell(sid) {
|
|
202
|
+
const snapshot = { draft: '', phase: 'plain', draftRev: 0, occurrences: [] }
|
|
203
|
+
const shell = {
|
|
204
|
+
sessionId: String(sid), snapshot, editor: {},
|
|
205
|
+
notify() {},
|
|
206
|
+
setDraft(text) { snapshot.draft = String(text); snapshot.occurrences = []; snapshot.draftRev += 1 },
|
|
207
|
+
}
|
|
208
|
+
shells.set(String(sid), shell)
|
|
209
|
+
return shell
|
|
210
|
+
}
|
|
211
|
+
function bail(actx, name, detail) {
|
|
212
|
+
const snap = actx.shell.snapshot
|
|
213
|
+
if (name === 'slash/input-insert-reference') {
|
|
214
|
+
const { reference, span } = detail
|
|
215
|
+
const at = Math.min(span.start, snap.draft.length)
|
|
216
|
+
snap.draft = snap.draft.slice(0, at) + reference.clipboardText + snap.draft.slice(at)
|
|
217
|
+
snap.occurrences.push({ source: reference.source, ref: reference.ref, offset: at, length: reference.clipboardText.length })
|
|
218
|
+
snap.draftRev += 1
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
if (name === 'slash/input-insert-text' && detail.text === '') {
|
|
222
|
+
snap.draft = snap.draft.slice(0, detail.span.start) + snap.draft.slice(detail.span.end)
|
|
223
|
+
snap.draftRev += 1
|
|
224
|
+
return true
|
|
225
|
+
}
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const subs = []
|
|
230
|
+
const cleanups = []
|
|
231
|
+
const ctx = {
|
|
232
|
+
locale: {
|
|
233
|
+
register() {},
|
|
234
|
+
bind: () => (key, params) => {
|
|
235
|
+
const dict = DICT[locale] || DICT.en
|
|
236
|
+
let text = dict[key] != null ? dict[key] : (DICT.en[key] != null ? DICT.en[key] : key)
|
|
237
|
+
if (params) text = text.replace(/\{(\w+)\}/g, (m2, name) => (name in params ? String(params[name]) : m2))
|
|
238
|
+
return text
|
|
239
|
+
},
|
|
240
|
+
subscribe: (cb) => { subs.push(cb); return () => {} },
|
|
241
|
+
},
|
|
242
|
+
effect: (fn) => { const c = fn(); if (typeof c === 'function') cleanups.push(c) },
|
|
243
|
+
slots: { inject() {}, register() {} },
|
|
244
|
+
inputTriggers: { registerSource() {} },
|
|
245
|
+
sessions: {
|
|
246
|
+
scope: (sid) => {
|
|
247
|
+
const shell = shells.get(String(sid))
|
|
248
|
+
if (!shell) return null
|
|
249
|
+
return { shell, bail: (actx, name, detail) => bail(actx, name, detail) }
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
conversation: { input: { for: (actx) => actx.shell } },
|
|
253
|
+
}
|
|
254
|
+
globalThis.__pcb.apply(ctx)
|
|
255
|
+
const controller = globalThis.__pcb.__controllerForTests
|
|
256
|
+
assert.ok(controller, 'apply() exposed the controller test seam')
|
|
257
|
+
|
|
258
|
+
// ---------------- fixture ----------------
|
|
259
|
+
function flowRow(kind, key, runs) {
|
|
260
|
+
const row = new El('div', doc)
|
|
261
|
+
row.setAttribute('data-chat-flow-kind', kind)
|
|
262
|
+
row.setAttribute('data-chat-flow-key', key)
|
|
263
|
+
const stack = new El('div', doc)
|
|
264
|
+
const bubble = new El('div', doc)
|
|
265
|
+
bubble.className = '_bubble_1x2y3_9'
|
|
266
|
+
stack.appendChild(bubble)
|
|
267
|
+
row.appendChild(stack)
|
|
268
|
+
doc.body.appendChild(row)
|
|
269
|
+
for (const text of runs) {
|
|
270
|
+
const span = new El('span', doc)
|
|
271
|
+
// DSH renders an @mention as its own chip element, not a plainRun — that is
|
|
272
|
+
// exactly why one sent message can come back as several runs and a fence
|
|
273
|
+
// can straddle them.
|
|
274
|
+
if (text && typeof text === 'object') {
|
|
275
|
+
span.className = '_mentionChip_ab12cd_3'
|
|
276
|
+
span.textContent = text.mention
|
|
277
|
+
} else {
|
|
278
|
+
span.className = '_plainRun_z12h9_6'
|
|
279
|
+
span.textContent = text
|
|
280
|
+
}
|
|
281
|
+
bubble.appendChild(span)
|
|
282
|
+
}
|
|
283
|
+
return bubble
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const fenced = '帮我看看这段代码\n```python\na = 1\nprint(a)\n```\n然后是文本\n```text\nlong pasted paragraph line\nsecond line\n```'
|
|
287
|
+
const b1 = flowRow('user', 'node-1', [fenced])
|
|
288
|
+
const b2 = flowRow('user', 'node-2', ['short note, no fences here'])
|
|
289
|
+
const b3 = flowRow('steering', 'node-3', ['```json\n{"x": 1}\n```'])
|
|
290
|
+
// The composer serializes a language-less CODE block as a BARE fence (prose
|
|
291
|
+
// always gets an explicit ```text) — so bare fences must fold as 代码块,
|
|
292
|
+
// with a guessed language when possible (0.2.1 regression). Adjacent blocks in
|
|
293
|
+
// one message live in ONE run (DSH only splits runs at @mention chips).
|
|
294
|
+
const b4 = flowRow('user', 'node-4', [
|
|
295
|
+
'```\ndef greet():\n print("hi")\n```\n```\njust some words\nplain text-ish body\n```',
|
|
296
|
+
])
|
|
297
|
+
|
|
298
|
+
// The 0.2.6 user bug, as captured (tests/fixtures.js#B2): the message DSH sent
|
|
299
|
+
// back holds a bare fence that only closes at the very end, wrapped around an
|
|
300
|
+
// inner ```text block, with @mention chips splitting the bubble into 5 runs.
|
|
301
|
+
// Per-run scanning paired the outer fence with the inner one and folded a bogus
|
|
302
|
+
// one-line card; the plan must be made over the whole bubble instead.
|
|
303
|
+
const MENTION = '@smalltailqwq/dsh-client-ui-skin-deep-whale-manager'
|
|
304
|
+
function mentionRuns(text, mention) {
|
|
305
|
+
const out = []
|
|
306
|
+
let rest = text
|
|
307
|
+
for (;;) {
|
|
308
|
+
const i = rest.indexOf(mention)
|
|
309
|
+
if (i < 0) { out.push(rest); return out }
|
|
310
|
+
out.push(rest.slice(0, i), { mention })
|
|
311
|
+
rest = rest.slice(i + mention.length)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const b5runs = mentionRuns(B2, MENTION)
|
|
315
|
+
const b5 = flowRow('user', 'node-5', b5runs)
|
|
316
|
+
|
|
317
|
+
// apply()'s late-mount scan ran against an empty body; kick one now that the
|
|
318
|
+
// fixtures exist (the shim's MutationObserver is inert, so drive it directly).
|
|
319
|
+
function scanAgain() { subs[0]() } // locale subscribe bumps foldRev + schedules the scan
|
|
320
|
+
scanAgain()
|
|
321
|
+
|
|
322
|
+
// 1. fenced runs folded: run hidden, host present, 2 cards with zh titles
|
|
323
|
+
test('sent fences fold into cards; prose run is hidden not removed', () => {
|
|
324
|
+
const run = b1.children[0]
|
|
325
|
+
assert.equal(run.style.display, 'none')
|
|
326
|
+
assert.ok(run.getAttribute('data-dsh-pcb-hide'), 'hidden run carries the fold stamp')
|
|
327
|
+
assert.ok((b1.getAttribute('data-dsh-pcb-sig') || '').startsWith('single|'), 'one run holds both fences: per-run path')
|
|
328
|
+
const host = b1.children[1]
|
|
329
|
+
assert.ok(host.classList.contains('dsh-pcb-fold-host'))
|
|
330
|
+
const cards = host.children.filter((c) => c.classList.contains('dsh-pcb-fold'))
|
|
331
|
+
assert.equal(cards.length, 2)
|
|
332
|
+
assert.equal(cards[0].getAttribute('data-pcb-type'), 'code')
|
|
333
|
+
assert.equal(cards[0].children[0].querySelector('.dsh-pcb-fold-title').textContent, 'python · 2 行')
|
|
334
|
+
assert.equal(cards[0].getAttribute('data-open'), '0', 'collapsed by default')
|
|
335
|
+
assert.equal(cards[1].getAttribute('data-pcb-type'), 'text')
|
|
336
|
+
assert.equal(cards[1].children[0].querySelector('.dsh-pcb-fold-title').textContent, '文本块 · 2 行')
|
|
337
|
+
const pre = cards[0].children[1]
|
|
338
|
+
assert.equal(pre.className, 'dsh-pcb-fold-body')
|
|
339
|
+
assert.equal(pre.textContent, 'a = 1\nprint(a)')
|
|
340
|
+
// prose segments around the fences stay visible
|
|
341
|
+
const prose = host.children.filter((c) => c.classList.contains('dsh-pcb-fold-prose'))
|
|
342
|
+
assert.equal(prose.length, 2)
|
|
343
|
+
assert.equal(prose[0].textContent, '帮我看看这段代码')
|
|
344
|
+
assert.equal(prose[1].textContent, '然后是文本')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('non-fenced messages are left alone (nothing hidden, nothing marked)', () => {
|
|
348
|
+
const run = b2.children[0]
|
|
349
|
+
assert.equal(run.style.display, '')
|
|
350
|
+
assert.equal(run.getAttribute('data-dsh-pcb-hide'), null, 'nothing was hidden')
|
|
351
|
+
assert.equal(b2.getAttribute('data-dsh-pcb-sig'), null, 'bubble left unfingered')
|
|
352
|
+
assert.equal(b2.children.filter((c) => c.classList.contains('dsh-pcb-fold-host')).length, 0)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test('steering bubbles fold too', () => {
|
|
356
|
+
const host = b3.children[1]
|
|
357
|
+
assert.ok(host && host.classList.contains('dsh-pcb-fold-host'))
|
|
358
|
+
assert.equal(host.querySelector('.dsh-pcb-fold-title').textContent, 'json · 1 行')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
test('bare fences fold as CODE (guessed language when possible), never as 文本块', () => {
|
|
362
|
+
const host = b4.children[1]
|
|
363
|
+
assert.ok(host.classList.contains('dsh-pcb-fold-host'))
|
|
364
|
+
const cards = host.children.filter((c) => c.classList.contains('dsh-pcb-fold'))
|
|
365
|
+
assert.equal(cards.length, 2, 'both bare fences fold')
|
|
366
|
+
assert.equal(cards[0].getAttribute('data-pcb-type'), 'code')
|
|
367
|
+
assert.equal(cards[0].children[0].querySelector('.dsh-pcb-fold-title').textContent, 'python · 2 行')
|
|
368
|
+
assert.equal(cards[1].getAttribute('data-pcb-type'), 'code')
|
|
369
|
+
assert.equal(cards[1].children[0].querySelector('.dsh-pcb-fold-title').textContent, '代码块 · 2 行')
|
|
370
|
+
assert.equal(b4.getAttribute('data-dsh-pcb-sig').startsWith('single|'), true)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
// 1b. the 0.2.6 regression, at the DOM level: a fence that straddles the runs
|
|
374
|
+
// DSH split at its @mention chips is planned over the whole bubble.
|
|
375
|
+
test('a fence straddling mention-chip runs folds at bubble scope (0.2.6)', () => {
|
|
376
|
+
scanAgain()
|
|
377
|
+
const host = b5.children[b5.children.length - 1]
|
|
378
|
+
assert.ok(host.classList.contains('dsh-pcb-fold-host'), 'one host, appended after the runs')
|
|
379
|
+
assert.equal(b5.children.filter((c) => c.classList.contains('dsh-pcb-fold-host')).length, 1)
|
|
380
|
+
assert.ok((b5.getAttribute('data-dsh-pcb-sig') || '').startsWith('bubble|'), 'bubble-scope signature')
|
|
381
|
+
assert.equal(b5.children.length, b5runs.length + 1, 'runs are hidden, never removed')
|
|
382
|
+
for (const run of b5.children.slice(0, b5runs.length)) {
|
|
383
|
+
assert.equal(run.style.display, 'none', 'the plan owns the whole bubble')
|
|
384
|
+
assert.ok(run.getAttribute('data-dsh-pcb-hide'), 'hidden run carries the fold stamp')
|
|
385
|
+
}
|
|
386
|
+
const cards = host.children.filter((c) => c.classList.contains('dsh-pcb-fold'))
|
|
387
|
+
assert.equal(cards.length, 1, 'the outer fence pairs with its own closing fence')
|
|
388
|
+
assert.equal(cards[0].getAttribute('data-pcb-type'), 'code')
|
|
389
|
+
const body = cards[0].children[1].textContent
|
|
390
|
+
assert.ok(body.includes('aegis') && body.includes('```text'), 'the inner block stays inside the folded card')
|
|
391
|
+
assert.ok(body.split('\n').length > 5, 'not the bogus one-line card per-run scanning produced')
|
|
392
|
+
const prose = host.children.filter((c) => c.classList.contains('dsh-pcb-fold-prose'))
|
|
393
|
+
assert.equal(prose.length, 1, 'the trailing line stays visible as prose')
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// 2. idempotence + open-state persistence + locale retitle
|
|
397
|
+
test('rescan keeps exactly one host, preserves expand state, retitles on locale switch', () => {
|
|
398
|
+
const toggle = b1.children[1].querySelector('.dsh-pcb-fold-toggle')
|
|
399
|
+
toggle.dispatch('click')
|
|
400
|
+
const card = b1.children[1].children.filter((c) => c.classList.contains('dsh-pcb-fold'))[0]
|
|
401
|
+
assert.equal(card.getAttribute('data-open'), '1')
|
|
402
|
+
|
|
403
|
+
scanAgain() // foldRev bump -> stamps stale -> full rebuild (same language)
|
|
404
|
+
assert.equal(b1.children.filter((c) => c.classList.contains('dsh-pcb-fold-host')).length, 1, 'no duplicate hosts')
|
|
405
|
+
const card2 = b1.children[1].children.filter((c) => c.classList.contains('dsh-pcb-fold'))[0]
|
|
406
|
+
assert.equal(card2.getAttribute('data-open'), '1', 'user-opened card survives the rebuild')
|
|
407
|
+
|
|
408
|
+
locale = 'en'
|
|
409
|
+
scanAgain()
|
|
410
|
+
const card3 = b1.children[1].children.filter((c) => c.classList.contains('dsh-pcb-fold'))[0]
|
|
411
|
+
assert.equal(card3.children[0].querySelector('.dsh-pcb-fold-title').textContent, 'python · 2 lines')
|
|
412
|
+
assert.equal(b1.children[1].children.filter((c) => c.classList.contains('dsh-pcb-fold'))[1].children[0].querySelector('.dsh-pcb-fold-title').textContent, 'Text · 2 lines')
|
|
413
|
+
locale = 'zh'
|
|
414
|
+
scanAgain()
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
// 3. content edits re-fold
|
|
418
|
+
test('a run whose text changes re-folds (or unfolds when fences vanish)', () => {
|
|
419
|
+
const run = b2.children[0]
|
|
420
|
+
run.textContent = '带围栏\n```\nbody line\n```\n'
|
|
421
|
+
scanAgain()
|
|
422
|
+
assert.equal(run.style.display, 'none')
|
|
423
|
+
assert.equal(b2.children.filter((c) => c.classList.contains('dsh-pcb-fold-host')).length, 1)
|
|
424
|
+
run.textContent = 'now plain again'
|
|
425
|
+
scanAgain()
|
|
426
|
+
assert.equal(run.style.display, '')
|
|
427
|
+
assert.equal(b2.children.filter((c) => c.classList.contains('dsh-pcb-fold-host')).length, 0, 'fold undone when no fences remain')
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
// 4. orphan host sweep after a React remount
|
|
431
|
+
test('stale hosts are swept when React replaces the run underneath them', () => {
|
|
432
|
+
const run = b3.children[0]
|
|
433
|
+
const staleHost = b3.children[1]
|
|
434
|
+
run.remove()
|
|
435
|
+
const fresh = new El('span', doc)
|
|
436
|
+
fresh.className = '_plainRun_z12h9_6'
|
|
437
|
+
fresh.textContent = '```json\n{"x": 1}\n```'
|
|
438
|
+
b3.childNodes.unshift(fresh)
|
|
439
|
+
fresh.parentElement = b3
|
|
440
|
+
scanAgain()
|
|
441
|
+
const hosts = b3.children.filter((c) => c.classList.contains('dsh-pcb-fold-host'))
|
|
442
|
+
assert.equal(hosts.length, 1, 'exactly one host after the sweep')
|
|
443
|
+
assert.notEqual(hosts[0], staleHost, 'the stale host was removed, the new one survived')
|
|
444
|
+
assert.equal(fresh.style.display, 'none')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// 5. draft re-seed recovery (workspace switch / page reload)
|
|
448
|
+
test('recovery re-attaches chips lost to a text-only draft re-seed (memory path)', () => {
|
|
449
|
+
const pre = makeShell('rs-mem')
|
|
450
|
+
pre.snapshot.draft = '帮我看看' // the user typed this before pasting the blocks
|
|
451
|
+
assert.ok(controller.attach('rs-mem', { lang: 'python', content: 'a = 1\nprint(a)', lines: ['a = 1', 'print(a)'], isCode: true }))
|
|
452
|
+
assert.ok(controller.attach('rs-mem', { lang: 'text', content: 'hello\nworld', lines: ['hello', 'world'], isCode: false }))
|
|
453
|
+
controller.mirrorNow('rs-mem')
|
|
454
|
+
assert.ok(localStorage.getItem('dsh-pcb-recovery.rs-mem'), 'mirror written')
|
|
455
|
+
// Switch away and back: the editor comes up seeded from plain text — one
|
|
456
|
+
// stray U+200B per lost chip, no occurrences. The draft matches the last
|
|
457
|
+
// one observed (the persistence replay), so the typing-window guard lets
|
|
458
|
+
// the restore through.
|
|
459
|
+
const seeded = makeShell('rs-mem')
|
|
460
|
+
seeded.snapshot.draft = '帮我看看\u200B\u200B'
|
|
461
|
+
assert.equal(controller.recover('rs-mem', seeded.snapshot), true)
|
|
462
|
+
assert.equal(seeded.snapshot.draft, '帮我看看\u200B\u200B', 'prose + re-attached chips')
|
|
463
|
+
assert.equal(seeded.snapshot.occurrences.filter((o) => o.source === 'paste-code-block').length, 2)
|
|
464
|
+
assert.deepEqual(controller.listFor('rs-mem').map((b) => [b.label, b.content]), [
|
|
465
|
+
['代码块 1', 'a = 1\nprint(a)'],
|
|
466
|
+
['文本块 1', 'hello\nworld'],
|
|
467
|
+
])
|
|
468
|
+
assert.equal(controller.recover('rs-mem', seeded.snapshot), false, 'idempotent: chips are alive now')
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
test('recovery restores from the localStorage mirror when memory is gone (reload)', () => {
|
|
472
|
+
const pre = makeShell('rs-mir')
|
|
473
|
+
pre.snapshot.draft = 'abc'
|
|
474
|
+
controller.attach('rs-mir', { lang: '', content: 'plain pasted content\nsecond line', lines: ['plain pasted content', 'second line'], isCode: true })
|
|
475
|
+
controller.mirrorNow('rs-mir')
|
|
476
|
+
// Page reload: a FRESH controller — this session's memory is gone entirely,
|
|
477
|
+
// including the typing-window fingerprints.
|
|
478
|
+
controller.list.delete('rs-mir')
|
|
479
|
+
controller.used.delete('rs-mir')
|
|
480
|
+
controller.lastInput.delete('rs-mir')
|
|
481
|
+
const seeded = makeShell('rs-mir')
|
|
482
|
+
seeded.snapshot.draft = 'abc\u200B'
|
|
483
|
+
assert.equal(controller.recover('rs-mir', seeded.snapshot), true)
|
|
484
|
+
assert.equal(controller.listFor('rs-mir').length, 1)
|
|
485
|
+
assert.equal(controller.listFor('rs-mir')[0].content, 'plain pasted content\nsecond line')
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test('a deleted chip never resurrects: no stray markers means nothing to restore', () => {
|
|
489
|
+
makeShell('rs-del')
|
|
490
|
+
controller.attach('rs-del', { lang: 'text', content: 'only block', lines: ['only block'], isCode: false })
|
|
491
|
+
controller.mirrorNow('rs-del')
|
|
492
|
+
// User Backspaces the chip: occurrences empty while the block is listed → prune.
|
|
493
|
+
controller.reconcile('rs-del', [], 'plain')
|
|
494
|
+
assert.equal(controller.listFor('rs-del').length, 0)
|
|
495
|
+
const seeded = makeShell('rs-del')
|
|
496
|
+
seeded.snapshot.draft = 'abc' // chip deletion removed its ZWSP along with the node
|
|
497
|
+
assert.equal(controller.recover('rs-del', seeded.snapshot), false)
|
|
498
|
+
assert.equal(controller.listFor('rs-del').length, 0)
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('a send attempt drops the mirror', async () => {
|
|
502
|
+
makeShell('rs-send')
|
|
503
|
+
controller.attach('rs-send', { lang: 'text', content: 'goes out', lines: ['goes out'], isCode: false })
|
|
504
|
+
controller.mirrorNow('rs-send')
|
|
505
|
+
const id = controller.listFor('rs-send')[0].id
|
|
506
|
+
assert.ok((await controller.serialize(id)).trimStart().startsWith('```text'), 'round-trip fence')
|
|
507
|
+
assert.equal(localStorage.getItem('dsh-pcb-recovery.rs-send'), null)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
test('a stray marker with nothing restorable NEVER rewrites the draft (0.2.2 typing bug)', () => {
|
|
511
|
+
const seeded = makeShell('rs-orphan')
|
|
512
|
+
seeded.snapshot.draft = 'lonely-zwsp\u200B'
|
|
513
|
+
// 0.2.1 stripped it via setDraft — a whole-document rewrite that destroyed
|
|
514
|
+
// live IME compositions (first typed character lost, view bounced). External
|
|
515
|
+
// invisible copy is not ours to sweep; leave the draft exactly as it is.
|
|
516
|
+
assert.equal(controller.recover('rs-orphan', seeded.snapshot), false)
|
|
517
|
+
assert.equal(seeded.snapshot.draft, 'lonely-zwsp\u200B', 'draft untouched')
|
|
518
|
+
assert.equal(seeded.snapshot.draftRev, 0, 'no setDraft fired')
|
|
519
|
+
assert.equal(controller.listFor('rs-orphan').length, 0)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
test('typing window: a draft that moved since the last pass is never rewritten (0.2.2)', () => {
|
|
523
|
+
makeShell('rs-type')
|
|
524
|
+
// Fresh boot: last pass saw the draft WITHOUT the trailing char; the
|
|
525
|
+
// re-seed signature (stray ZWSP + dead occurrences) must not fire in the
|
|
526
|
+
// middle of typing — the draft has moved.
|
|
527
|
+
assert.equal(controller.recover('rs-type', { draft: '帮我看看\u200B', occurrences: [], phase: 'plain' }), false, 'boot pass records the draft, nothing restorable')
|
|
528
|
+
controller.attach('rs-type', { lang: 'text', content: 'kept block', lines: ['kept block'], isCode: false })
|
|
529
|
+
const typed = { draft: '帮我看看\u200B好的', occurrences: [], phase: 'plain' } // occurrence view gone stale
|
|
530
|
+
assert.equal(controller.recover('rs-type', typed), false, 'moved draft = live typing — do not touch')
|
|
531
|
+
assert.equal(typed.draft, '帮我看看\u200B好的', 'draft untouched')
|
|
532
|
+
// The same (unmoved) draft on a later pass still restores: re-seed semantics intact.
|
|
533
|
+
assert.equal(controller.recover('rs-type', { draft: typed.draft, occurrences: [], phase: 'plain' }), true)
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
test('hand-deleting the last chip drops the mirror synchronously (no stale resurrection)', () => {
|
|
537
|
+
makeShell('rs-prune')
|
|
538
|
+
controller.attach('rs-prune', { lang: 'text', content: 'deleted block', lines: ['deleted block'], isCode: false })
|
|
539
|
+
assert.ok(localStorage.getItem('dsh-pcb-recovery.rs-prune'), 'mirror written by attach')
|
|
540
|
+
controller.reconcile('rs-prune', [], 'plain') // Backspace over the chip: occurrence gone
|
|
541
|
+
assert.equal(controller.listFor('rs-prune').length, 0, 'pruned')
|
|
542
|
+
assert.equal(localStorage.getItem('dsh-pcb-recovery.rs-prune'), null, 'mirror dropped in the same breath')
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test('workspace carry-over: a moved draft pulls its blocks from the donor session', () => {
|
|
546
|
+
makeShell('rs-a')
|
|
547
|
+
controller.attach('rs-a', { lang: 'text', content: 'moved block', lines: ['moved block'], isCode: false })
|
|
548
|
+
// A publishes its final draft (with the live chip) before the switch.
|
|
549
|
+
assert.equal(controller.recover('rs-a', { draft: 'note\u200B', occurrences: [{ source: 'paste-code-block', ref: 'rs-a-1', length: 1 }], phase: 'plain' }), false)
|
|
550
|
+
// DSH carries the plain draft to B's blank session; the chip does not move.
|
|
551
|
+
const b = makeShell('rs-b')
|
|
552
|
+
b.snapshot.draft = 'note\u200B'
|
|
553
|
+
assert.equal(controller.recover('rs-b', b.snapshot), true)
|
|
554
|
+
assert.equal(controller.listFor('rs-a').length, 0, 'donor state moved out')
|
|
555
|
+
assert.equal(localStorage.getItem('dsh-pcb-recovery.rs-a'), null, 'donor mirror moved out')
|
|
556
|
+
assert.equal(controller.listFor('rs-b').length, 1)
|
|
557
|
+
assert.equal(controller.listFor('rs-b')[0].content, 'moved block')
|
|
558
|
+
assert.equal(b.snapshot.occurrences.filter((o) => o.source === 'paste-code-block').length, 1)
|
|
559
|
+
assert.ok(localStorage.getItem('dsh-pcb-recovery.rs-b'), 'mirrored under the new session now')
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
test('carry-over survives a page reload via the mirrored donor draft', () => {
|
|
563
|
+
makeShell('rs-c')
|
|
564
|
+
controller.attach('rs-c', { lang: 'text', content: 'reloaded block', lines: ['reloaded block'], isCode: false })
|
|
565
|
+
controller.recover('rs-c', { draft: 'hi\u200B', occurrences: [{ source: 'paste-code-block', ref: 'rs-c-1', length: 1 }], phase: 'plain' })
|
|
566
|
+
controller.mirrorNow('rs-c')
|
|
567
|
+
// Reload: all controller memory is gone.
|
|
568
|
+
controller.list.delete('rs-c'); controller.used.delete('rs-c'); controller.lastInput.delete('rs-c')
|
|
569
|
+
const d = makeShell('rs-d')
|
|
570
|
+
d.snapshot.draft = 'hi\u200B'
|
|
571
|
+
assert.equal(controller.recover('rs-d', d.snapshot), true)
|
|
572
|
+
assert.equal(controller.listFor('rs-d').length, 1)
|
|
573
|
+
assert.equal(controller.listFor('rs-d')[0].content, 'reloaded block')
|
|
574
|
+
assert.equal(localStorage.getItem('dsh-pcb-recovery.rs-c'), null)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
// ---------------- paste-storm guard (0.2.3) ----------------
|
|
578
|
+
function firePaste(text) {
|
|
579
|
+
const ev = {
|
|
580
|
+
clipboardData: { getData: (kind) => (kind === 'text/plain' ? text : '') },
|
|
581
|
+
prevented: false,
|
|
582
|
+
preventDefault() { ev.prevented = true },
|
|
583
|
+
stopPropagation() {},
|
|
584
|
+
}
|
|
585
|
+
for (const fn of (docListeners.paste || []).slice()) fn(ev)
|
|
586
|
+
return ev
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
test('a paste-storm of identical clipboard text collapses in-burst but honors deliberate re-paste (0.2.5)', () => {
|
|
590
|
+
const storm = globalThis.__pcb.__pasteStormForTests
|
|
591
|
+
assert.ok(storm && typeof storm.seen === 'function', 'storm seam exposed')
|
|
592
|
+
const sid = 'rs-storm'
|
|
593
|
+
makeShell(sid)
|
|
594
|
+
const seat = new El('div', doc)
|
|
595
|
+
seat.setAttribute('data-dsh-pcb', '')
|
|
596
|
+
seat.setAttribute('data-dsh-pcb-session', sid)
|
|
597
|
+
doc.body.appendChild(seat)
|
|
598
|
+
globalThis.document.activeElement = seat
|
|
599
|
+
assert.ok((docListeners.paste || []).length >= 1, 'apply() registered the document paste capture')
|
|
600
|
+
const text = 'storm line one\nstorm line two\nstorm line three'
|
|
601
|
+
const events = []
|
|
602
|
+
for (let i = 0; i < 12; i += 1) events.push(firePaste(text))
|
|
603
|
+
assert.equal(controller.listFor(sid).length, 1, 'a held/automated repeat collapses into one paste')
|
|
604
|
+
assert.ok(events.every((e) => e.prevented), 'suppressed pastes are consumed — raw text never falls through')
|
|
605
|
+
// Different content is honored even back-to-back, and becomes the new
|
|
606
|
+
// "last paste" the guard compares against.
|
|
607
|
+
firePaste('different content A\ndifferent content B')
|
|
608
|
+
assert.equal(controller.listFor(sid).length, 2, 'distinct clipboard text is never a storm')
|
|
609
|
+
// Non-block text keeps DSH's native path untouched.
|
|
610
|
+
const plain = firePaste('hi')
|
|
611
|
+
assert.equal(plain.prevented, false, 'non-block paste is not intercepted')
|
|
612
|
+
assert.equal(controller.listFor(sid).length, 2)
|
|
613
|
+
// Re-paste of the same text once the burst window has passed is a FRESH
|
|
614
|
+
// intent (0.2.5): deliberate re-pasting gets its own block even though the
|
|
615
|
+
// first one is still attached. Only machine-paced in-burst repeats collapse.
|
|
616
|
+
firePaste(text)
|
|
617
|
+
assert.equal(controller.listFor(sid).length, 3, 're-paste of identical text is a new block after the burst window')
|
|
618
|
+
// …its immediate repeat still collapses inside the window…
|
|
619
|
+
firePaste(text)
|
|
620
|
+
assert.equal(controller.listFor(sid).length, 3, 'in-window repeat is still suppressed')
|
|
621
|
+
// …and once the window expires, an identical re-paste is honored again.
|
|
622
|
+
storm.lastAt = 0
|
|
623
|
+
firePaste(text)
|
|
624
|
+
assert.equal(controller.listFor(sid).length, 4, 'after the window, re-pasting is a fresh intent')
|
|
625
|
+
assert.ok(controller.listFor(sid).filter((b) => b.sourceText === text).length >= 2, 'each deliberate re-paste records sourceText')
|
|
626
|
+
// Distinct content is always honored, chip or no chip.
|
|
627
|
+
firePaste('brand new content X\ncontent Y')
|
|
628
|
+
assert.equal(controller.listFor(sid).length, 5)
|
|
629
|
+
globalThis.document.activeElement = null
|
|
630
|
+
seat.remove()
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
// 6. dispose hands the transcript back
|
|
634
|
+
test('dispose removes every fold host and unhides every run', () => {
|
|
635
|
+
for (const c of cleanups) c()
|
|
636
|
+
assert.equal(document.querySelectorAll('.dsh-pcb-fold-host').length, 0)
|
|
637
|
+
assert.equal(document.querySelectorAll('[data-dsh-pcb-hide]').length, 0)
|
|
638
|
+
assert.equal(document.querySelectorAll('[data-dsh-pcb-sig]').length, 0)
|
|
639
|
+
for (const b of [b1, b3, b4, b5]) {
|
|
640
|
+
assert.equal(b.children.filter((c) => c.classList.contains('dsh-pcb-fold-host')).length, 0)
|
|
641
|
+
}
|
|
642
|
+
})
|