dsh-wisp 1.27.0 → 1.28.0
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 +55 -0
- package/lib/client.js +312 -51
- package/lib/client.template.js +312 -51
- package/package.json +1 -1
- package/tools/smoke.mjs +79 -5
- package/verify-wisp.mjs +170 -14
package/verify-wisp.mjs
CHANGED
|
@@ -304,10 +304,22 @@ function createHarness(options = {}) {
|
|
|
304
304
|
}
|
|
305
305
|
const pendingNode = {}
|
|
306
306
|
|
|
307
|
+
const documentListeners = Object.create(null)
|
|
307
308
|
const documentShim = {
|
|
308
309
|
body: bodyEl,
|
|
309
310
|
head: headEl,
|
|
310
311
|
get activeElement() { return activeEl },
|
|
312
|
+
/* 真 document 有事件接口,替身也得有 —— 否则"插件是否正确处理文档级 Esc"根本测不到,
|
|
313
|
+
而且少一个方法会让插件在替身里整个起不来(这坑踩过一次)。 */
|
|
314
|
+
addEventListener(type, fn) { (documentListeners[type] ??= []).push(fn) },
|
|
315
|
+
removeEventListener(type, fn) {
|
|
316
|
+
const list = documentListeners[type]
|
|
317
|
+
if (!list) return
|
|
318
|
+
const at = list.indexOf(fn)
|
|
319
|
+
if (at >= 0) list.splice(at, 1)
|
|
320
|
+
},
|
|
321
|
+
dispatch(type, event) { for (const fn of [...(documentListeners[type] ?? [])]) fn(event ?? {}) },
|
|
322
|
+
get documentListenerCount() { return Object.values(documentListeners).reduce((n, l) => n + l.length, 0) },
|
|
311
323
|
createElement: (t) => makeEl(t),
|
|
312
324
|
getElementById: (id) => created.find((e) => e.id === id && !e.removed) ?? null,
|
|
313
325
|
/* 采样点上的元素栈:按"最上面优先"返回。窗口背景那一段要靠它判断
|
|
@@ -476,7 +488,8 @@ function createHarness(options = {}) {
|
|
|
476
488
|
// 默认不给 —— 这样"没有 host 时降级"才测得到;要测检查更新就传 hostCall。
|
|
477
489
|
const hostSeat = typeof options.hostCall === 'function' ? { call: options.hostCall } : undefined
|
|
478
490
|
// eslint-disable-next-line no-new-func
|
|
479
|
-
new Function
|
|
491
|
+
/* 追加 sourceURL:new Function 里的代码否则在堆栈里是 anonymous,定位不到行。 */
|
|
492
|
+
new Function('window', 'document', 'host', ...TRAPPED, src + '\n//# sourceURL=wisp-client-half.js')(
|
|
480
493
|
win, documentShim, hostSeat, ...TRAPPED.map((name) => traps[name]),
|
|
481
494
|
)
|
|
482
495
|
},
|
|
@@ -1455,12 +1468,16 @@ if (clientSrc !== null) {
|
|
|
1455
1468
|
ctxEvent 定义在文件后半部分,在这里用就是 TDZ。 */
|
|
1456
1469
|
eat.win.__wisp.configure({ hungry: true })
|
|
1457
1470
|
const eatBody = eat.find('wisp-root').querySelector('.wisp-body')
|
|
1458
|
-
eatBody.dispatch('keydown', {
|
|
1471
|
+
const openEatMenu = () => eatBody.dispatch('keydown', {
|
|
1459
1472
|
type: 'keydown', key: 'Enter', shiftKey: false, defaultPrevented: false,
|
|
1460
1473
|
preventDefault() { this.defaultPrevented = true }, stopPropagation() {},
|
|
1461
1474
|
})
|
|
1462
|
-
const
|
|
1463
|
-
|
|
1475
|
+
const eatLive = () => eat.all('wisp-menu-item').filter((el) => el.removed !== true)
|
|
1476
|
+
openEatMenu()
|
|
1477
|
+
/* 开关在「行为」组里:先展开(分组行带着 data-group) */
|
|
1478
|
+
const groupRowOf = (name) => eatLive().find((el) => el.dataset && el.dataset.group === name)
|
|
1479
|
+
groupRowOf('行为').dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
1480
|
+
const eatToggle = eatLive().find((el) => (el.textContent || '').includes('饿了'))
|
|
1464
1481
|
check(Boolean(eatToggle), 'the menu carries a rice toggle',
|
|
1465
1482
|
eat.all('wisp-menu-item').filter((el) => el.removed !== true).length + ' 项')
|
|
1466
1483
|
if (eatToggle) {
|
|
@@ -2243,14 +2260,49 @@ if (clientSrc !== null) {
|
|
|
2243
2260
|
固定部分 = 6 个动作 + 4 个开关 + 4 个角落 = 14;其余是每套皮肤一项。 */
|
|
2244
2261
|
/* api.skins 是数组(SKIN_IDS 的拷贝),不是对象 —— 用 Object.keys 只是碰巧数对了。 */
|
|
2245
2262
|
const skinCount = Array.isArray(api.skins) ? api.skins.length : Object.keys(api.skins ?? {}).length
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2263
|
+
/* ---- 结构:顶层只放动作,其余收进可折叠分组 ----------------------------
|
|
2264
|
+
原来 19 个条目全平铺、只靠三行不可点的头部标签分区 —— 那不是结构,是排版。
|
|
2265
|
+
现在顶层 7 项,外观 / 行为 / 位置 三组各自折叠。 */
|
|
2266
|
+
const topLevel = itemsOf()
|
|
2267
|
+
check(topLevel.length === 7, 'the top level holds seven entries, not nineteen',
|
|
2268
|
+
`${topLevel.length} 项:${topLevel.map((i) => i.textContent).join(' | ')}`)
|
|
2269
|
+
const groupRow = (name) => itemsOf().find((i) => i.dataset && i.dataset.group === name)
|
|
2270
|
+
const groupNames = ['外观', '行为', '位置']
|
|
2271
|
+
check(groupNames.every((n) => groupRow(n) !== undefined), 'the three groups are there',
|
|
2272
|
+
groupNames.map((n) => `${n}=${groupRow(n) ? '有' : '无'}`).join(' '))
|
|
2273
|
+
check(groupNames.every((n) => groupRow(n).dataset.open === 'false'),
|
|
2274
|
+
'and they start collapsed', groupNames.map((n) => groupRow(n).dataset.open).join(','))
|
|
2275
|
+
check(groupNames.every((n) => groupRow(n).getAttribute('aria-expanded') === 'false'),
|
|
2276
|
+
'with aria-expanded telling assistive tech the same thing')
|
|
2277
|
+
/* 展开一个分组:点它一下,菜单在原地重开,条目多出这一组的孩子 */
|
|
2278
|
+
/* 展开/收起**只从 DOM 判断**:分组行自己带着 data-open 与 aria-expanded,
|
|
2279
|
+
而 api 这个绑定是第一个替身的(728 行),不是这一节的实例 —— 拿它读菜单状态
|
|
2280
|
+
会得到"菜单明明开着、API 却说没开"的假失败(踩过)。 */
|
|
2281
|
+
const expand = (name) => {
|
|
2282
|
+
const row = groupRow(name)
|
|
2283
|
+
if (!row) return false
|
|
2284
|
+
row.dispatch('click', itemEvent())
|
|
2285
|
+
return groupRow(name) !== undefined && groupRow(name).dataset.open === 'true'
|
|
2286
|
+
}
|
|
2287
|
+
const collapse = (name) => {
|
|
2288
|
+
const row = groupRow(name)
|
|
2289
|
+
if (!row) return false
|
|
2290
|
+
row.dispatch('click', itemEvent())
|
|
2291
|
+
return groupRow(name) !== undefined && groupRow(name).dataset.open === 'false'
|
|
2292
|
+
}
|
|
2293
|
+
check(expand('外观'), 'clicking a group expands it in place',
|
|
2294
|
+
`row=${JSON.stringify(groupRow('外观') ? groupRow('外观').dataset : null)} 项=${itemsOf().length}`)
|
|
2295
|
+
check(itemsOf().length === 7 + skinCount + 2,
|
|
2296
|
+
'the skins and the two size entries appear once it is open',
|
|
2297
|
+
`${itemsOf().length} 项 = 7 + ${skinCount} 皮肤 + 2 大小`)
|
|
2298
|
+
check(collapse('外观'), 'clicking it again collapses it')
|
|
2299
|
+
check(itemsOf().length === 7, 'and the menu is back to seven entries', String(itemsOf().length))
|
|
2300
|
+
/* 换一套皮肤之后,展开的分组要自动收起(closeMenu 会清掉 activeGroup) */
|
|
2249
2301
|
const byText = (t) => itemsOf().filter((i) => i.textContent.includes(t))
|
|
2250
|
-
|
|
2251
|
-
check(
|
|
2252
|
-
|
|
2253
|
-
|
|
2302
|
+
expand('外观')
|
|
2303
|
+
check(groupRow('外观').getAttribute('aria-expanded') === 'true',
|
|
2304
|
+
'and aria-expanded follows the expansion')
|
|
2305
|
+
/* 皮肤现在在「外观」组里 —— 上一步已经把该组展开了。 */
|
|
2254
2306
|
/* 从 API 派生,**不写死皮肤名**。写死过「初版女仆」,改名成「素绘女仆」之后
|
|
2255
2307
|
三条断言当场全炸,其中一条 undefined.dispatch 还把整个套件截断了。 */
|
|
2256
2308
|
/* 既有 API 的形状是**有序数组** [{id,label}],不是 id→label 的对象。 */
|
|
@@ -2268,6 +2320,7 @@ if (clientSrc !== null) {
|
|
|
2268
2320
|
'the menu lives beside her body, not inside it', 'inside it would be mirrored and scale with her')
|
|
2269
2321
|
|
|
2270
2322
|
/* ---- 三个行为开关(原本在设置页里,那页撤掉后落到菜单) -------------- */
|
|
2323
|
+
check(expand('行为'), 'the behaviour group opens') /* 开关都在这一组里 */
|
|
2271
2324
|
const toggleItem = (label) => itemsOf().find((i) => i.textContent.replace(/^[✓ ]\s*/, '') === label)
|
|
2272
2325
|
const togglesPresent = ['自己溜达', '跟随状态', '跑完撒花'].every((l) => toggleItem(l) !== undefined)
|
|
2273
2326
|
check(togglesPresent, 'all three behaviour toggles are in the menu',
|
|
@@ -2279,6 +2332,8 @@ if (clientSrc !== null) {
|
|
|
2279
2332
|
check(fifth.config.wander === !wanderBefore, 'clicking a toggle really flips the config',
|
|
2280
2333
|
`${wanderBefore} -> ${fifth.config.wander}`)
|
|
2281
2334
|
body5.dispatch('contextmenu', rightClickOnHer().ev)
|
|
2335
|
+
/* 重开菜单=一次新手势,所以分组回到收起状态(这是设计)。要再点开关就得再展开。 */
|
|
2336
|
+
check(expand('行为'), 'reopening the menu collapses the groups again')
|
|
2282
2337
|
const reopened = toggleItem('自己溜达')
|
|
2283
2338
|
check(reopened !== undefined && reopened.textContent.startsWith(' '),
|
|
2284
2339
|
'and reopening the menu shows the new state',
|
|
@@ -2292,6 +2347,7 @@ if (clientSrc !== null) {
|
|
|
2292
2347
|
// 自己开菜单,不依赖上一节留下的状态 —— 隐含的跨节状态是测试脆弱性的常见来源。
|
|
2293
2348
|
body5.dispatch('contextmenu', rightClickOnHer().ev)
|
|
2294
2349
|
check(menuOf() !== null, 'the menu opens for the skin checks')
|
|
2350
|
+
check(expand('外观'), 'and the skins group can be opened')
|
|
2295
2351
|
const skinBefore = fifth.skin
|
|
2296
2352
|
const posBefore = fifth.position
|
|
2297
2353
|
const srcBeforeSkin = h.srcs[h.srcs.length - 1]
|
|
@@ -2314,9 +2370,9 @@ if (clientSrc !== null) {
|
|
|
2314
2370
|
check(fifth.configure({ skin: 'deepsea' }) && fifth.skin === 'deepsea',
|
|
2315
2371
|
'configure({skin}) goes through the same path as the menu', String(fifth.skin))
|
|
2316
2372
|
|
|
2317
|
-
// 点「放大一点」——
|
|
2373
|
+
// 点「放大一点」—— 真改尺寸,并且菜单自己关掉(大小两项在「外观」组里,先展开)
|
|
2318
2374
|
body5.dispatch('contextmenu', rightClickOnHer().ev)
|
|
2319
|
-
|
|
2375
|
+
check(expand('外观'), 'the size entries need the appearance group open')
|
|
2320
2376
|
byText('放大一点')[0].dispatch('click', itemEvent())
|
|
2321
2377
|
check(fifth.element.style.width === '630px' && fifth.element.style.height === '945px',
|
|
2322
2378
|
'a menu action runs (zoom one step up)', `${fifth.element.style.width}x${fifth.element.style.height}`)
|
|
@@ -2326,6 +2382,7 @@ if (clientSrc !== null) {
|
|
|
2326
2382
|
const secondClick = rightClickOnHer()
|
|
2327
2383
|
body5.dispatch('contextmenu', secondClick.ev)
|
|
2328
2384
|
check(menuOf() !== null, 'the menu reopens')
|
|
2385
|
+
check(expand('位置'), 'the corners live in the position group')
|
|
2329
2386
|
byText('右下角')[0].dispatch('click', itemEvent())
|
|
2330
2387
|
const homed = fifth.position
|
|
2331
2388
|
check(homed.x === secondClick.homeX && homed.y === secondClick.homeY,
|
|
@@ -2352,7 +2409,7 @@ if (clientSrc !== null) {
|
|
|
2352
2409
|
})
|
|
2353
2410
|
check(kbd().at !== null && kbd().at.tagName === 'BUTTON',
|
|
2354
2411
|
'opening the menu focuses its first item', kbd().label)
|
|
2355
|
-
check(kbd().at?.className === 'wisp-menu-item' && kbd().label === '
|
|
2412
|
+
check(kbd().at?.className === 'wisp-menu-item' && kbd().label === '关于她…',
|
|
2356
2413
|
'and the focus lands on the first real action', kbd().label)
|
|
2357
2414
|
check(kbd().zero === 1, 'exactly one item is in the tab order (roving tabindex)', `${kbd().zero}`)
|
|
2358
2415
|
check(itemsOf()[0].getAttribute('aria-selected') === 'true',
|
|
@@ -2528,6 +2585,16 @@ if (clientSrc !== null) {
|
|
|
2528
2585
|
found[0].dispatch('click', itemEvent())
|
|
2529
2586
|
return true
|
|
2530
2587
|
}
|
|
2588
|
+
/* 菜单改成分组之后,组里的条目要先把组展开才看得见。
|
|
2589
|
+
这个 helper 让"点某个分组里的东西"是一次调用,而不是每处各写一遍。 */
|
|
2590
|
+
const openGroupOn = (inst, name) => {
|
|
2591
|
+
const host = inst.element.parentNode
|
|
2592
|
+
const row = host.querySelectorAll('.wisp-menu-item').find((i) => i.dataset && i.dataset.group === name)
|
|
2593
|
+
if (!row) return false
|
|
2594
|
+
row.dispatch('click', itemEvent())
|
|
2595
|
+
return host.querySelectorAll('.wisp-menu-item')
|
|
2596
|
+
.some((i) => i.dataset && i.dataset.group === name && i.dataset.open === 'true')
|
|
2597
|
+
}
|
|
2531
2598
|
// 四个角落。期望值按【当前】尺寸算 —— 写死尺寸的话,一旦尺寸变了断言会
|
|
2532
2599
|
// 指着正确的实现说它错。
|
|
2533
2600
|
const cur = boxOf(pinned)
|
|
@@ -2539,6 +2606,7 @@ if (clientSrc !== null) {
|
|
|
2539
2606
|
}
|
|
2540
2607
|
for (const [label, want] of Object.entries(EXPECT)) {
|
|
2541
2608
|
openMenuOn(pinned)
|
|
2609
|
+
openGroupOn(pinned, '位置') // 四个角落现在收在「位置」组里
|
|
2542
2610
|
const hit = pickExact(pinned, label)
|
|
2543
2611
|
check(hit && pinned.position.x === want.x && pinned.position.y === want.y,
|
|
2544
2612
|
`the ${label} item puts her there`, `${pinned.position.x},${pinned.position.y} (want ${want.x},${want.y})`)
|
|
@@ -2706,6 +2774,7 @@ if (clientSrc !== null) {
|
|
|
2706
2774
|
active = keepSoak
|
|
2707
2775
|
} catch (error) {
|
|
2708
2776
|
bad('browser half executes', `${error.constructor.name}: ${error.message}`)
|
|
2777
|
+
if (error && error.stack) console.log(String(error.stack).split('\n').slice(0, 6).map((l) => ' ' + l.trim()).join('\n'))
|
|
2709
2778
|
console.log(error.stack)
|
|
2710
2779
|
} finally {
|
|
2711
2780
|
active = null
|
|
@@ -2713,6 +2782,93 @@ if (clientSrc !== null) {
|
|
|
2713
2782
|
}
|
|
2714
2783
|
}
|
|
2715
2784
|
|
|
2785
|
+
/* ================================================== 3y. dialogs ============ */
|
|
2786
|
+
head('3y. dialogs: about her, and the action preview')
|
|
2787
|
+
|
|
2788
|
+
/* 菜单顶层只留动作,"她是谁"和"她有哪些动作"放进弹窗。这里测的就是这两个弹窗:
|
|
2789
|
+
能不能开、内容对不对、三种关法,以及**反复开合不会把文档级监听越堆越多**
|
|
2790
|
+
(那是真写进去过的 bug:清理函数是个空壳)。 */
|
|
2791
|
+
{
|
|
2792
|
+
const dlg = createHarness({ timer: true, composerText: '' })
|
|
2793
|
+
const keepDlg = active
|
|
2794
|
+
active = dlg
|
|
2795
|
+
dlg.evaluate(clientSrc)
|
|
2796
|
+
dlg.module().default.apply(dlg.ctx, { reactions: true, wander: false, celebrate: false })
|
|
2797
|
+
dlg.advance(1300, 100)
|
|
2798
|
+
const dlgApi = dlg.win.__wisp
|
|
2799
|
+
const bundleVersion = /const VERSION = '([^']+)'/.exec(clientSrc)?.[1] ?? null
|
|
2800
|
+
|
|
2801
|
+
/* 替身的 find() 会把已移除的节点也返回(它自己的约定),所以要显式认 removed。 */
|
|
2802
|
+
const dialogGone = () => {
|
|
2803
|
+
const el = dlg.find('wisp-dialog')
|
|
2804
|
+
return el === null || el.removed === true
|
|
2805
|
+
}
|
|
2806
|
+
/* 按段落/页脚类读文本:简化 DOM 不会汇总子节点 textContent,读卡片本身只会得到空串。 */
|
|
2807
|
+
const dialogText = () => [].concat(
|
|
2808
|
+
dlg.all('wisp-dialog-p').filter((el) => el.removed !== true).map((el) => String(el.textContent)),
|
|
2809
|
+
dlg.all('wisp-dialog-foot-l').filter((el) => el.removed !== true).map((el) => String(el.textContent)),
|
|
2810
|
+
dlg.all('wisp-dialog-foot-r').filter((el) => el.removed !== true).map((el) => String(el.textContent)),
|
|
2811
|
+
).join('\n')
|
|
2812
|
+
|
|
2813
|
+
check(dlgApi.dialog === null, 'nothing is open at the start', String(dlgApi.dialog))
|
|
2814
|
+
check(dialogGone(), 'and no dialog sits in the DOM before it is asked for')
|
|
2815
|
+
|
|
2816
|
+
dlgApi.openAbout()
|
|
2817
|
+
check(dlgApi.dialog === 'about', 'asking about her opens the about dialog', String(dlgApi.dialog))
|
|
2818
|
+
const card = dlg.find('wisp-dialog')
|
|
2819
|
+
check(card !== null, 'the dialog card is in the DOM')
|
|
2820
|
+
check(dlg.find('wisp-dialog-backdrop') !== null, 'with a backdrop behind it')
|
|
2821
|
+
const aboutText = dialogText()
|
|
2822
|
+
check(aboutText.includes('DeepSeek娘'), 'it says who she is')
|
|
2823
|
+
check(bundleVersion !== null && aboutText.includes(bundleVersion),
|
|
2824
|
+
'and which version this is', `${bundleVersion} / 文本里找得到: ${aboutText.includes(String(bundleVersion))}`)
|
|
2825
|
+
check(aboutText.includes('社区'), 'and credits the community the look comes from')
|
|
2826
|
+
check(aboutText.includes('非官方'), 'and says plainly that it is unofficial')
|
|
2827
|
+
|
|
2828
|
+
dlg.document.dispatch('keydown', { key: 'Escape', preventDefault() {} })
|
|
2829
|
+
check(dlgApi.dialog === null && dialogGone(), 'Escape closes it',
|
|
2830
|
+
`dialog=${String(dlgApi.dialog)} 监听数=${dlg.document.documentListenerCount}`)
|
|
2831
|
+
check(dlg.document.documentListenerCount === 1,
|
|
2832
|
+
'exactly one document-level key listener exists', String(dlg.document.documentListenerCount))
|
|
2833
|
+
|
|
2834
|
+
dlgApi.openActions()
|
|
2835
|
+
check(dlgApi.dialog === 'actions', 'the action preview opens', String(dlgApi.dialog))
|
|
2836
|
+
const cells = dlg.all('wisp-cell').filter((el) => el.removed !== true)
|
|
2837
|
+
check(cells.length === 8, 'and lists eight actions — one per mood', `${cells.length} 格`)
|
|
2838
|
+
check(cells.every((c) => typeof c.dataset.mood === 'string' && c.dataset.mood !== ''),
|
|
2839
|
+
'each cell names its mood', cells.map((c) => c.dataset.mood).join(','))
|
|
2840
|
+
check(cells.every((c) => c.querySelector('img') !== null), 'each cell carries a sprite')
|
|
2841
|
+
|
|
2842
|
+
const eatCell = cells.find((c) => c.dataset.mood === 'eat')
|
|
2843
|
+
eatCell.dispatch('click', { preventDefault() {}, stopPropagation() {} })
|
|
2844
|
+
check(dlgApi.dialog === null, 'clicking an action closes the dialog so you can see her')
|
|
2845
|
+
check(dlgApi.currentMood === 'eat', 'and she takes that pose', String(dlgApi.currentMood))
|
|
2846
|
+
dlg.advance(1500, 100)
|
|
2847
|
+
check(dlgApi.currentMood === 'eat',
|
|
2848
|
+
'the reaction poll leaves the preview alone while it lasts', String(dlgApi.currentMood))
|
|
2849
|
+
dlg.advance(9000, 500)
|
|
2850
|
+
check(dlgApi.currentMood !== 'eat', 'and the preview does not stick forever', String(dlgApi.currentMood))
|
|
2851
|
+
|
|
2852
|
+
dlgApi.openActions()
|
|
2853
|
+
const x = dlg.find('wisp-dialog-x')
|
|
2854
|
+
check(x !== null, 'the close button is there')
|
|
2855
|
+
x.dispatch('click', { preventDefault() {} })
|
|
2856
|
+
check(dlgApi.dialog === null, 'and it closes the dialog')
|
|
2857
|
+
|
|
2858
|
+
dlgApi.openAbout()
|
|
2859
|
+
dlg.find('wisp-dialog-backdrop').dispatch('pointerdown', {})
|
|
2860
|
+
check(dlgApi.dialog === null, 'clicking the backdrop closes it too')
|
|
2861
|
+
|
|
2862
|
+
for (let i = 0; i < 3; i++) { dlgApi.openAbout(); dlgApi.closeDialog() }
|
|
2863
|
+
check(dlg.document.documentListenerCount === 1,
|
|
2864
|
+
'opening and closing repeatedly does not stack listeners', String(dlg.document.documentListenerCount))
|
|
2865
|
+
|
|
2866
|
+
dlgApi.openAbout()
|
|
2867
|
+
dlgApi.destroy()
|
|
2868
|
+
check(dlgApi.dialog === null, 'destroy() takes the dialog with it')
|
|
2869
|
+
active = keepDlg
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2716
2872
|
/* ================================================== 3z. generation audit ==== */
|
|
2717
2873
|
head('3z. every shipped sprite traces back to a text-only call')
|
|
2718
2874
|
|