@mzzsfy/dsh-context-manager 0.2.1 → 0.4.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 +8 -6
- package/package.json +1 -1
- package/src/client.js +238 -26
- package/src/core.mjs +15 -3
- package/src/index.js +97 -39
- package/test/client-fork-enhance.test.mjs +70 -5
- package/test/client-help.test.mjs +3 -1
- package/test/client-history-button.test.mjs +158 -0
- package/test/client-history-scope.test.mjs +34 -0
- package/test/client-runtime.test.mjs +148 -0
- package/test/core.test.mjs +50 -0
- package/test/index.test.mjs +215 -8
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-context-manager Host 半区:历史输入工作区缓存对齐 + 启停/数据路由
|
|
2
|
-
// (inputs / prompts/toggle 与 history/steer-recall/fork/fork-auto-resend/copy-sid
|
|
3
|
-
//
|
|
2
|
+
// (inputs / prompts/toggle 与 history/history-button/steer-recall/fork/fork-auto-resend/copy-sid
|
|
3
|
+
// 六条启停)。
|
|
4
4
|
// 浮层请求直接读缓存文件立即返回;对齐在后台解压范围内会话产物与缓存合并写回,
|
|
5
5
|
// 运行中会话也参与(实时追加其新输入)。fork 分叉动作走 client 服务面,
|
|
6
6
|
// host 侧只有其启停路由。
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
HISTORY_FOCUS_WAIT_MS,
|
|
20
20
|
HISTORY_INPUT_LIMIT,
|
|
21
21
|
HISTORY_INPUT_MAX_CHARS,
|
|
22
|
+
HISTORY_EXTRACT_VERSION,
|
|
22
23
|
HISTORY_PROMPTS_MAX,
|
|
23
24
|
HISTORY_SCOPES,
|
|
24
25
|
HISTORY_SESSION_SCAN_LIMIT,
|
|
@@ -48,6 +49,8 @@ export const MESSAGES = {
|
|
|
48
49
|
const SETTINGS_SCHEMA = schemastery.object({
|
|
49
50
|
historyEnabled: schemastery.boolean().default(true)
|
|
50
51
|
.description('历史输入浮层(Alt+↑),低频功能,关闭后输入框锚点与快捷键整体不渲染;变更刷新页面生效'),
|
|
52
|
+
historyButtonEnabled: schemastery.boolean().default(true)
|
|
53
|
+
.description('输入框工具排的历史输入按钮,点击开关浮层,与 Alt+↑ 等效;关闭仅隐藏按钮,快捷键与浮层不受影响;变更刷新页面生效'),
|
|
51
54
|
steerRecallEnabled: schemastery.boolean().default(true)
|
|
52
55
|
.description('插话撤回图标,低频功能,关闭后撤回图标整体不渲染;变更刷新页面生效'),
|
|
53
56
|
forkEnabled: schemastery.boolean().default(true)
|
|
@@ -58,6 +61,21 @@ const SETTINGS_SCHEMA = schemastery.object({
|
|
|
58
61
|
.description('会话标题栏的复制 sessionId 按钮,平时隐藏,鼠标悬停标题栏时显示,点击复制当前会话 id;关闭后按钮整体不渲染;变更刷新页面生效'),
|
|
59
62
|
})
|
|
60
63
|
|
|
64
|
+
// 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
|
|
65
|
+
// settings.register 注册命名空间。根级 volatile 包装:0.1.7 下整节为 live 表单,
|
|
66
|
+
// 节写经 loader 原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
|
|
67
|
+
export const Config = volatileWrap(SETTINGS_SCHEMA)
|
|
68
|
+
|
|
69
|
+
function volatileWrap(schema) {
|
|
70
|
+
return typeof schema.volatile === 'function' ? schema.volatile() : schema
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
|
|
74
|
+
// legacy 宿主 config 为普通对象原样透传
|
|
75
|
+
function unwrapVolatile(value) {
|
|
76
|
+
return typeof value?.get === 'function' ? value.get() : value
|
|
77
|
+
}
|
|
78
|
+
|
|
61
79
|
function sendJson(res, status, payload) {
|
|
62
80
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
63
81
|
res.end(JSON.stringify(payload))
|
|
@@ -74,11 +92,14 @@ function respondError(ctx, res, error) {
|
|
|
74
92
|
sendJson(res, 400, { error: message })
|
|
75
93
|
}
|
|
76
94
|
|
|
77
|
-
function readSettings(ctx) {
|
|
95
|
+
function readSettings(ctx, config) {
|
|
78
96
|
const settings = ctx.get('settings')
|
|
79
|
-
|
|
97
|
+
// 双形读(maintain 同构):legacy settings 命名空间 get;0.1.7+ apply 入参
|
|
98
|
+
// config 整节解包。方法面守卫兼防:服务在但缺 get 时回落默认值,防路由 handler 抛错
|
|
99
|
+
const value = settings && typeof settings.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(config)
|
|
80
100
|
return {
|
|
81
101
|
historyEnabled: !value || value.historyEnabled !== false,
|
|
102
|
+
historyButtonEnabled: !value || value.historyButtonEnabled !== false,
|
|
82
103
|
steerRecallEnabled: !value || value.steerRecallEnabled !== false,
|
|
83
104
|
forkEnabled: !value || value.forkEnabled !== false,
|
|
84
105
|
forkAutoResendEnabled: Boolean(value && value.forkAutoResendEnabled === true),
|
|
@@ -86,6 +107,22 @@ function readSettings(ctx) {
|
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
|
|
110
|
+
// 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
|
|
111
|
+
// (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
|
|
112
|
+
// 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦),
|
|
113
|
+
// 仅 volatile 字段变化时 loader 原地热更不重启。await 保证持久化完成后再读回
|
|
114
|
+
async function persistToggle(ctx, patch) {
|
|
115
|
+
const settings = ctx.get('settings')
|
|
116
|
+
if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
|
|
117
|
+
await settings.update(NAMESPACE, patch)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
const editor = ctx.get('configEditor')
|
|
121
|
+
const entry = ctx.fiber?.entry
|
|
122
|
+
if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
|
|
123
|
+
await editor.edit(entry, (current) => ({ ...current, ...patch }))
|
|
124
|
+
}
|
|
125
|
+
|
|
89
126
|
function readBody(req) {
|
|
90
127
|
const BODY_MAX_BYTES = 64 * 1024
|
|
91
128
|
return new Promise((resolve, reject) => {
|
|
@@ -176,16 +213,18 @@ export function apply(ctx, config) {
|
|
|
176
213
|
}
|
|
177
214
|
|
|
178
215
|
// 单会话提取:产物 stat 指纹与缓存一致即复用(零解压);否则解压并更新缓存。
|
|
179
|
-
// 运行中会话走内存指纹(runtimeExtracts),其余走磁盘 extracts
|
|
216
|
+
// 运行中会话走内存指纹(runtimeExtracts),其余走磁盘 extracts。
|
|
217
|
+
// 指纹之外校验提取器版本:版本不符即重解压(升级后旧条目自动补齐新格式)
|
|
180
218
|
async function extractSession(recordItem, fingerprint, extracts, isRunning) {
|
|
181
219
|
const sessionId = recordItem.header.id
|
|
182
220
|
const known = isRunning ? runtimeExtracts.get(sessionId) : extracts[sessionId]
|
|
183
|
-
if (fingerprint !== null && known && known.
|
|
221
|
+
if (fingerprint !== null && known && known.v === HISTORY_EXTRACT_VERSION
|
|
222
|
+
&& known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size) {
|
|
184
223
|
return known.entries
|
|
185
224
|
}
|
|
186
225
|
const snapshot = await ctx.sessionQuery.readSession(sessionId)
|
|
187
226
|
const entries = extractUserInputs(snapshot.events)
|
|
188
|
-
const record = { mtimeMs: fingerprint === null ? 0 : fingerprint.mtimeMs, size: fingerprint === null ? 0 : fingerprint.size, entries }
|
|
227
|
+
const record = { v: HISTORY_EXTRACT_VERSION, mtimeMs: fingerprint === null ? 0 : fingerprint.mtimeMs, size: fingerprint === null ? 0 : fingerprint.size, entries }
|
|
189
228
|
if (isRunning) runtimeExtracts.set(sessionId, record)
|
|
190
229
|
else if (fingerprint !== null) extracts[sessionId] = record
|
|
191
230
|
return entries
|
|
@@ -339,9 +378,10 @@ export function apply(ctx, config) {
|
|
|
339
378
|
}
|
|
340
379
|
}
|
|
341
380
|
// 实时追加:产物 stat 与已知指纹不一致 = 会话有新输入未入缓存——聚焦对齐
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
381
|
+
// 只解该会话并写回。提取器版本不符同判 stale(升级后旧格式条目在首请求
|
|
382
|
+
// 内经聚焦对齐补齐,不等后台批量)。路由有界等待本次对齐(首条 <3s 预算):
|
|
383
|
+
// 正常会话单次请求内直接拿到新数据;超时(巨产物等)返回既有数据 aligned=false,
|
|
384
|
+
// 后续轮询流式补齐。巨产物跳过聚焦(解压耗时与内存峰值失控),维持 aligned=true
|
|
345
385
|
const check = async (cached) => {
|
|
346
386
|
const agents = ctx.get('agents')
|
|
347
387
|
const running = isSessionRunning({ agents, sessionId })
|
|
@@ -349,7 +389,7 @@ export function apply(ctx, config) {
|
|
|
349
389
|
const located = ctx.get('sessionPersistence') ? ctx.get('sessionPersistence').locate(header) : undefined
|
|
350
390
|
const fingerprint = located ? await safeStat(located.path) : null
|
|
351
391
|
return {
|
|
352
|
-
stale: fingerprint !== null && !(known && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size),
|
|
392
|
+
stale: fingerprint !== null && !(known && known.v === HISTORY_EXTRACT_VERSION && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size),
|
|
353
393
|
oversize: fingerprint !== null && fingerprint.size > maxArtifactBytes,
|
|
354
394
|
}
|
|
355
395
|
}
|
|
@@ -446,7 +486,7 @@ export function apply(ctx, config) {
|
|
|
446
486
|
handler: async (req, res) => {
|
|
447
487
|
try {
|
|
448
488
|
if (req.method === 'GET') {
|
|
449
|
-
sendJson(res, 200, { enabled: readSettings(ctx).historyEnabled })
|
|
489
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).historyEnabled })
|
|
450
490
|
return
|
|
451
491
|
}
|
|
452
492
|
if (!rejectMethod(req, res, 'POST')) return
|
|
@@ -456,11 +496,31 @@ export function apply(ctx, config) {
|
|
|
456
496
|
} catch {
|
|
457
497
|
throw new Error(MESSAGES.badJsonBody)
|
|
458
498
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
499
|
+
await persistToggle(ctx, { historyEnabled: Boolean(body && body.enabled) })
|
|
500
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).historyEnabled })
|
|
501
|
+
} catch (error) {
|
|
502
|
+
respondError(ctx, res, error)
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
// 输入框历史按钮启停:与历史浮层开关同构,读走 settings,写经 settings.update
|
|
508
|
+
path: '/api/context/history-button-enabled',
|
|
509
|
+
handler: async (req, res) => {
|
|
510
|
+
try {
|
|
511
|
+
if (req.method === 'GET') {
|
|
512
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).historyButtonEnabled })
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
if (!rejectMethod(req, res, 'POST')) return
|
|
516
|
+
let body
|
|
517
|
+
try {
|
|
518
|
+
body = JSON.parse(await readBody(req))
|
|
519
|
+
} catch {
|
|
520
|
+
throw new Error(MESSAGES.badJsonBody)
|
|
521
|
+
}
|
|
522
|
+
await persistToggle(ctx, { historyButtonEnabled: Boolean(body && body.enabled) })
|
|
523
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).historyButtonEnabled })
|
|
464
524
|
} catch (error) {
|
|
465
525
|
respondError(ctx, res, error)
|
|
466
526
|
}
|
|
@@ -472,7 +532,7 @@ export function apply(ctx, config) {
|
|
|
472
532
|
handler: async (req, res) => {
|
|
473
533
|
try {
|
|
474
534
|
if (req.method === 'GET') {
|
|
475
|
-
sendJson(res, 200, { enabled: readSettings(ctx).steerRecallEnabled })
|
|
535
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).steerRecallEnabled })
|
|
476
536
|
return
|
|
477
537
|
}
|
|
478
538
|
if (!rejectMethod(req, res, 'POST')) return
|
|
@@ -482,11 +542,8 @@ export function apply(ctx, config) {
|
|
|
482
542
|
} catch {
|
|
483
543
|
throw new Error(MESSAGES.badJsonBody)
|
|
484
544
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
// update 异步落盘后才提交新值:await 保证持久化完成后再读回
|
|
488
|
-
await settings.update(NAMESPACE, { steerRecallEnabled: Boolean(body && body.enabled) })
|
|
489
|
-
sendJson(res, 200, { enabled: readSettings(ctx).steerRecallEnabled })
|
|
545
|
+
await persistToggle(ctx, { steerRecallEnabled: Boolean(body && body.enabled) })
|
|
546
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).steerRecallEnabled })
|
|
490
547
|
} catch (error) {
|
|
491
548
|
respondError(ctx, res, error)
|
|
492
549
|
}
|
|
@@ -498,7 +555,7 @@ export function apply(ctx, config) {
|
|
|
498
555
|
handler: async (req, res) => {
|
|
499
556
|
try {
|
|
500
557
|
if (req.method === 'GET') {
|
|
501
|
-
sendJson(res, 200, { enabled: readSettings(ctx).forkEnabled })
|
|
558
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).forkEnabled })
|
|
502
559
|
return
|
|
503
560
|
}
|
|
504
561
|
if (!rejectMethod(req, res, 'POST')) return
|
|
@@ -508,11 +565,8 @@ export function apply(ctx, config) {
|
|
|
508
565
|
} catch {
|
|
509
566
|
throw new Error(MESSAGES.badJsonBody)
|
|
510
567
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// update 异步落盘后才提交新值:await 保证持久化完成后再读回
|
|
514
|
-
await settings.update(NAMESPACE, { forkEnabled: Boolean(body && body.enabled) })
|
|
515
|
-
sendJson(res, 200, { enabled: readSettings(ctx).forkEnabled })
|
|
568
|
+
await persistToggle(ctx, { forkEnabled: Boolean(body && body.enabled) })
|
|
569
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).forkEnabled })
|
|
516
570
|
} catch (error) {
|
|
517
571
|
respondError(ctx, res, error)
|
|
518
572
|
}
|
|
@@ -524,7 +578,7 @@ export function apply(ctx, config) {
|
|
|
524
578
|
handler: async (req, res) => {
|
|
525
579
|
try {
|
|
526
580
|
if (req.method === 'GET') {
|
|
527
|
-
sendJson(res, 200, { enabled: readSettings(ctx).forkAutoResendEnabled })
|
|
581
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).forkAutoResendEnabled })
|
|
528
582
|
return
|
|
529
583
|
}
|
|
530
584
|
if (!rejectMethod(req, res, 'POST')) return
|
|
@@ -534,10 +588,8 @@ export function apply(ctx, config) {
|
|
|
534
588
|
} catch {
|
|
535
589
|
throw new Error(MESSAGES.badJsonBody)
|
|
536
590
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
await settings.update(NAMESPACE, { forkAutoResendEnabled: Boolean(body && body.enabled) })
|
|
540
|
-
sendJson(res, 200, { enabled: readSettings(ctx).forkAutoResendEnabled })
|
|
591
|
+
await persistToggle(ctx, { forkAutoResendEnabled: Boolean(body && body.enabled) })
|
|
592
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).forkAutoResendEnabled })
|
|
541
593
|
} catch (error) {
|
|
542
594
|
respondError(ctx, res, error)
|
|
543
595
|
}
|
|
@@ -549,7 +601,7 @@ export function apply(ctx, config) {
|
|
|
549
601
|
handler: async (req, res) => {
|
|
550
602
|
try {
|
|
551
603
|
if (req.method === 'GET') {
|
|
552
|
-
sendJson(res, 200, { enabled: readSettings(ctx).copySidEnabled })
|
|
604
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).copySidEnabled })
|
|
553
605
|
return
|
|
554
606
|
}
|
|
555
607
|
if (!rejectMethod(req, res, 'POST')) return
|
|
@@ -559,10 +611,8 @@ export function apply(ctx, config) {
|
|
|
559
611
|
} catch {
|
|
560
612
|
throw new Error(MESSAGES.badJsonBody)
|
|
561
613
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
await settings.update(NAMESPACE, { copySidEnabled: Boolean(body && body.enabled) })
|
|
565
|
-
sendJson(res, 200, { enabled: readSettings(ctx).copySidEnabled })
|
|
614
|
+
await persistToggle(ctx, { copySidEnabled: Boolean(body && body.enabled) })
|
|
615
|
+
sendJson(res, 200, { enabled: readSettings(ctx, config).copySidEnabled })
|
|
566
616
|
} catch (error) {
|
|
567
617
|
respondError(ctx, res, error)
|
|
568
618
|
}
|
|
@@ -576,6 +626,14 @@ export function apply(ctx, config) {
|
|
|
576
626
|
}
|
|
577
627
|
|
|
578
628
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
629
|
+
// 方法面守卫:settings 服务缺 register 面(0.1.7 已移除)即走新形态分支;
|
|
630
|
+
// 0.1.7 关闭原生自动设置页,设置面由本包自带分区承担(maintain 同构)
|
|
631
|
+
if (typeof settingsCtx.settings.register !== 'function') {
|
|
632
|
+
if (typeof settingsCtx.settings?.configure === 'function') {
|
|
633
|
+
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
|
|
634
|
+
}
|
|
635
|
+
return
|
|
636
|
+
}
|
|
579
637
|
settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA, { base: config })
|
|
580
638
|
})
|
|
581
639
|
}
|
|
@@ -33,7 +33,8 @@ test('源码契约:进行中轮的按钮说明标注进行中语义', () => {
|
|
|
33
33
|
// F3 分叉后自动重发 的客户端契约:
|
|
34
34
|
// - 开关经 fork-auto-resend-enabled 路由拉取,默认关
|
|
35
35
|
// - 点击登记的草稿带 autoSubmit(跟随开关)
|
|
36
|
-
// - 消费侧 autoSubmit 时经 prompt
|
|
36
|
+
// - 消费侧 autoSubmit 时经 prompt 通道提交,提交即无草稿(官方发送语义:发送后输入框
|
|
37
|
+
// 不残留原文,残留会诱导用户再次发送造成重复消息);prompt 失败才回填文本供手动重发
|
|
37
38
|
// - prompt 不可用(旧宿主)时 submitPrompt 为 null,自动降级仅回填
|
|
38
39
|
|
|
39
40
|
test('源码契约:自动重发开关经新路由拉取且默认关', () => {
|
|
@@ -41,11 +42,16 @@ test('源码契约:自动重发开关经新路由拉取且默认关', () => {
|
|
|
41
42
|
assert.ok(CLIENT_SRC.includes('setAutoResend(Boolean(payload && payload.enabled === true))'), '开关默认必须是关(严格 true 才开)')
|
|
42
43
|
})
|
|
43
44
|
|
|
44
|
-
test('源码契约:autoSubmit
|
|
45
|
+
test('源码契约:autoSubmit 消费即提交不留草稿,失败才回填', () => {
|
|
45
46
|
const consume = CLIENT_SRC.slice(CLIENT_SRC.indexOf('const draft = pendingForkDrafts.get(sessionId)'), CLIENT_SRC.indexOf('return h(ForkDock'))
|
|
46
|
-
|
|
47
|
-
assert.ok(
|
|
48
|
-
|
|
47
|
+
const autoIdx = consume.indexOf('if (autoSubmit && submitRef.current)')
|
|
48
|
+
assert.ok(autoIdx >= 0, 'autoSubmit 且通道可用时应走提交分支')
|
|
49
|
+
const draftIdx = consume.indexOf('inputActions.setDraft(text)')
|
|
50
|
+
assert.ok(draftIdx > autoIdx, '回填不得先于 prompt(提交成功路径输入框必须无残留)')
|
|
51
|
+
const autoBranch = consume.slice(autoIdx)
|
|
52
|
+
assert.ok(autoBranch.includes('submitRef.current({ sessionId, text })'), 'autoSubmit 时应经 prompt 通道提交')
|
|
53
|
+
assert.ok(autoBranch.includes("toast('自动重发失败"), 'prompt 失败应 toast 提示')
|
|
54
|
+
assert.ok(autoBranch.indexOf('setDraft(text)') > autoBranch.indexOf('catch'), '失败兜底回填必须落在 catch 分支内')
|
|
49
55
|
})
|
|
50
56
|
|
|
51
57
|
test('源码契约:prompt 通道参数形态(requestId 幂等 + queue + text 段)', () => {
|
|
@@ -58,3 +64,62 @@ test('源码契约:prompt 通道参数形态(requestId 幂等 + queue + text 段
|
|
|
58
64
|
test('源码契约:旧宿主无 prompt 时通道降级为 null(仅回填)', () => {
|
|
59
65
|
assert.ok(CLIENT_SRC.includes("(remoteSession && typeof remoteSession.prompt === 'function')"), 'prompt 通道应以能力探测守门(含面缺失)')
|
|
60
66
|
})
|
|
67
|
+
|
|
68
|
+
// F4 回填不得覆盖用户输入 的客户端契约(实测 bug:点分叉后 200ms 内用户输入被 setDraft 吞掉;
|
|
69
|
+
// 且消费删除草稿后 effect 依赖变化再跑一次,取到 undefined 走进 setDraft("") 空写清空输入框):
|
|
70
|
+
// - 消费即删草稿后,该会话必须标记为「已消费」,effect 重跑直接短路,绝不空写
|
|
71
|
+
// - 回填走单次投递:非空输入框不覆盖(用户已输入优先),改为 toast 提示
|
|
72
|
+
|
|
73
|
+
test('源码契约:草稿消费后不得再对同会话写草稿(杜绝空写清空)', () => {
|
|
74
|
+
const consume = CLIENT_SRC.slice(CLIENT_SRC.indexOf('const draft = pendingForkDrafts.get(sessionId)'), CLIENT_SRC.indexOf('return h(ForkDock'))
|
|
75
|
+
const guardIdx = CLIENT_SRC.indexOf('consumedForkDrafts.has(sessionId)')
|
|
76
|
+
const addIdx = CLIENT_SRC.indexOf('consumedForkDrafts.add(sessionId)')
|
|
77
|
+
assert.ok(guardIdx >= 0, '消费后应经 consumedForkDrafts 短路重跑')
|
|
78
|
+
assert.ok(addIdx >= 0, '删除草稿的同时应登记已消费')
|
|
79
|
+
assert.ok(guardIdx < addIdx, '短路判断必须先于登记(登记发生在有草稿分支内)')
|
|
80
|
+
assert.ok(guardIdx < CLIENT_SRC.indexOf('const draft = pendingForkDrafts.get(sessionId)'), '短路必须先于取草稿')
|
|
81
|
+
assert.ok(consume.includes('consumedForkDrafts.add(sessionId)'), '登记应落在消费分支内')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('源码契约:回填前经 useInput 快照探测输入框,非空不覆盖', () => {
|
|
85
|
+
const consume = CLIENT_SRC.slice(CLIENT_SRC.indexOf('const draft = pendingForkDrafts.get(sessionId)'), CLIENT_SRC.indexOf('return h(ForkDock'))
|
|
86
|
+
assert.ok(consume.includes('draftOccupied'), '应经 useInput 快照判定输入框是否已被用户占用')
|
|
87
|
+
assert.ok(consume.includes('原输入未回填'), '被占用时应 toast 说明未回填')
|
|
88
|
+
assert.ok(consume.includes('原输入未自动发送'), '自动重发被占用时应 toast 说明未发送')
|
|
89
|
+
assert.ok(consume.indexOf('draftOccupied') < consume.indexOf('inputActions.setDraft(text)'), '占用探测必须先于回填')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// F5 新轮注入 的客户端契约(实测 bug:轮号映射拉取一次即冻结,新轮 turn 不在映射中,
|
|
93
|
+
// 注入资格判空后直接跳过,按钮永不出现,必须刷新页面重拉才恢复):
|
|
94
|
+
// - 扫描发现「有轮号但映射无此轮」时必须重拉映射,而不是静默跳过
|
|
95
|
+
// - 重拉必须有在途门控,避免 MutationObserver 高频触发造成请求风暴
|
|
96
|
+
// - 重拉结果与在途基线对比,相同则不再 setState(否则 setState→重渲染→扫描无限循环)
|
|
97
|
+
|
|
98
|
+
test('源码契约:映射缺失的轮触发重拉而非静默跳过', () => {
|
|
99
|
+
const scan = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function scan() {'), CLIENT_SRC.indexOf('if (typeof MutationObserver'))
|
|
100
|
+
assert.ok(scan.includes('entry === undefined'), '扫描应识别映射缺失的轮')
|
|
101
|
+
assert.ok(scan.includes('refreshRef.current()'), '映射缺失时应触发重拉')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('源码契约:重拉有在途门控', () => {
|
|
105
|
+
const refresh = CLIENT_SRC.slice(CLIENT_SRC.indexOf('refreshRef.current = () =>'), CLIENT_SRC.indexOf('loadRef.current()\n .then((map) => {\n if (disposed) return'))
|
|
106
|
+
assert.ok(CLIENT_SRC.includes('refreshPendingRef'), '重拉必须有在途门控标志')
|
|
107
|
+
assert.ok(refresh.includes('if (refreshPendingRef.current) return'), '门控判断应位于重拉入口首位')
|
|
108
|
+
assert.ok(refresh.includes('refreshPendingRef.current = true'), '进入重拉即置在途')
|
|
109
|
+
assert.ok(refresh.includes('refreshPendingRef.current = false'), '重拉结束应清在途(含失败路径)')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('源码契约:重拉结果与基线相同则不 setState(防扫描循环)', () => {
|
|
113
|
+
const refresh = CLIENT_SRC.slice(CLIENT_SRC.indexOf('refreshRef.current = () =>'), CLIENT_SRC.indexOf('loadRef.current()\n .then((map) => {\n if (disposed) return'))
|
|
114
|
+
assert.ok(refresh.includes('setTurnEnds'), '重拉应更新映射')
|
|
115
|
+
assert.ok(refresh.includes('if (turnEndsRef.current === map) return'), '同一映射引用应短路,避免 setState 触发扫描循环')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('源码契约:重拉入口经 ref 跨组件传递(Bootstrap 装配,扫描处调用)', () => {
|
|
119
|
+
assert.ok(CLIENT_SRC.includes('const refreshRef = useRef(null)'), 'ForkDock 侧应有 refreshRef')
|
|
120
|
+
assert.ok(CLIENT_SRC.includes('refreshRef.current = () => {'), 'Bootstrap 应装配重拉入口')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('源码契约:fork dock 订阅官方 input 快照取草稿', () => {
|
|
124
|
+
assert.ok(CLIENT_SRC.includes('useInput'), 'dock 应订阅宿主注入的 useInput 快照')
|
|
125
|
+
})
|
|
@@ -8,9 +8,10 @@ const CLIENT_SRC = readFileSync(fileURLToPath(new URL('../src/client.js', import
|
|
|
8
8
|
// 设置项悬停说明契约:说明常量必须存在且逐项挂到对应开关(开关行工厂调用位)。
|
|
9
9
|
// 断言以工厂调用为锚:行文本与说明常量必须出现在同一 switchRow(...) 调用内,
|
|
10
10
|
// 防常量互换挂载或挂错行仍通过
|
|
11
|
-
test('
|
|
11
|
+
test('源码契约:六项设置的悬停说明齐备且挂到对应控件', () => {
|
|
12
12
|
const TITLES = {
|
|
13
13
|
HISTORY_SWITCH_TITLE: '历史输入浮层开关',
|
|
14
|
+
HISTORY_BUTTON_SWITCH_TITLE: '输入框历史按钮开关',
|
|
14
15
|
STEER_SWITCH_TITLE: '插话撤回开关',
|
|
15
16
|
FORK_SWITCH_TITLE: '对话 fork 开关',
|
|
16
17
|
FORK_AUTO_RESEND_SWITCH_TITLE: '分叉后自动重发开关',
|
|
@@ -20,6 +21,7 @@ test('源码契约:三项设置的悬停说明齐备且挂到对应控件', () =
|
|
|
20
21
|
}
|
|
21
22
|
const rowAnchors = [
|
|
22
23
|
{ constant: 'HISTORY_SWITCH_TITLE', text: "'历史输入浮层(Alt+↑)'", label: TITLES.HISTORY_SWITCH_TITLE },
|
|
24
|
+
{ constant: 'HISTORY_BUTTON_SWITCH_TITLE', text: "'输入框历史按钮'", label: TITLES.HISTORY_BUTTON_SWITCH_TITLE },
|
|
23
25
|
{ constant: 'STEER_SWITCH_TITLE', text: "'插话撤回'", label: TITLES.STEER_SWITCH_TITLE },
|
|
24
26
|
{ constant: 'FORK_SWITCH_TITLE', text: "'对话 fork'", label: TITLES.FORK_SWITCH_TITLE },
|
|
25
27
|
{ constant: 'FORK_AUTO_RESEND_SWITCH_TITLE', text: "'分叉后自动重发'", label: TITLES.FORK_AUTO_RESEND_SWITCH_TITLE },
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// 输入框历史按钮 BDD:寻行/注入纯函数切片(桩 DOM)+ 注入接线源码契约。
|
|
2
|
+
// client.js 单文件自包含无法 import,切片经 new Function 实例化后对桩 DOM 断言。
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
10
|
+
const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
11
|
+
|
|
12
|
+
// 切片:按钮标记常量起点到历史浮层组件声明终点,范围内仅常量与纯函数
|
|
13
|
+
// (findToolsRow / ensureHistoryButton / histButtonSvg 全部经 doc 参数注入)
|
|
14
|
+
const PURE_START = CLIENT_SRC.indexOf('const HISTORY_BTN_FLAG = ')
|
|
15
|
+
const PURE_END = CLIENT_SRC.indexOf('function HistoryDock(')
|
|
16
|
+
assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js 历史按钮切片定位失败')
|
|
17
|
+
const pure = new Function(
|
|
18
|
+
CLIENT_SRC.slice(PURE_START, PURE_END)
|
|
19
|
+
+ '; return { findToolsRow: findToolsRow, ensureHistoryButton: ensureHistoryButton, HISTORY_BTN_FLAG: HISTORY_BTN_FLAG }',
|
|
20
|
+
)()
|
|
21
|
+
|
|
22
|
+
// ── DOM 桩:最小实现,行为断言所需。
|
|
23
|
+
// 建模局限(显式注明):stubButton 以 onclick 属性模拟 addEventListener(未建
|
|
24
|
+
// EventTarget 语义),插入位置仅覆盖行尾分支(stub 节点无 nextSibling)——
|
|
25
|
+
// 「插到最后一个 _add 之后但行尾尚有其他节点」的 ref 非空分支由源码契约
|
|
26
|
+
// (insertBefore 锚点)与 L3 人工回归兜底,桩不重复建模。 ──
|
|
27
|
+
|
|
28
|
+
function stubButton(className) {
|
|
29
|
+
return {
|
|
30
|
+
className,
|
|
31
|
+
type: undefined,
|
|
32
|
+
title: undefined,
|
|
33
|
+
appended: null,
|
|
34
|
+
addEventListener(type, fn) { this['on' + type] = fn },
|
|
35
|
+
appendChild(node) { this.appended = node },
|
|
36
|
+
setAttribute(name, value) { this['attr_' + name] = value },
|
|
37
|
+
remove() {},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stubRow(buttons) {
|
|
42
|
+
return {
|
|
43
|
+
children: [...buttons],
|
|
44
|
+
inserted: null,
|
|
45
|
+
insertBefore(node, ref) { this.inserted = { node, ref }; this.children.push(node) },
|
|
46
|
+
querySelector(selector) {
|
|
47
|
+
// 注入标记选择器与生产同源:按导出常量拼锚,常量改名时桩同步失真暴露
|
|
48
|
+
if (selector.includes(pure.HISTORY_BTN_FLAG)) return this.children.find((child) => child['attr_' + pure.HISTORY_BTN_FLAG] !== undefined) || null
|
|
49
|
+
if (selector.includes('_add')) return this.children.find((child) => child.className.endsWith('_add')) || null
|
|
50
|
+
return null
|
|
51
|
+
},
|
|
52
|
+
querySelectorAll(selector) {
|
|
53
|
+
if (selector.includes('_add')) return this.children.filter((child) => child.className.endsWith('_add'))
|
|
54
|
+
return []
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// createElement 与 createElementNS 分开计数,svg 与按钮各自可断言;
|
|
60
|
+
// createElementNS 真实返回 tag 形态的节点(svg 内嵌 path)
|
|
61
|
+
function stubDoc(createdButtons, createdSvg) {
|
|
62
|
+
return {
|
|
63
|
+
createElement: (tag) => { const node = stubButton(''); node.tagName = tag; createdButtons.push(node); return node },
|
|
64
|
+
createElementNS: (_ns, tag) => {
|
|
65
|
+
const node = stubButton('')
|
|
66
|
+
node.tagName = tag
|
|
67
|
+
if (tag === 'svg') createdSvg.push(node)
|
|
68
|
+
return node
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── 场景6:寻行纯函数 ──
|
|
74
|
+
|
|
75
|
+
test('findToolsRow:行后缀 _tools 且直含 _add 按钮返回行,任一锚点漂移返回 null', () => {
|
|
76
|
+
const toolsRow = { className: 'uV2eYG_tools' }
|
|
77
|
+
const addInTools = { className: 'uV2eYG_add', parentElement: toolsRow }
|
|
78
|
+
const otherRow = { className: 'uV2eYG_toolbar' }
|
|
79
|
+
const addInOther = { className: 'uV2eYG_add', parentElement: otherRow }
|
|
80
|
+
const doc = (buttons) => ({ querySelectorAll: () => buttons })
|
|
81
|
+
assert.equal(pure.findToolsRow(doc([addInTools])), toolsRow, '双锚点一致应返回工具排行')
|
|
82
|
+
assert.equal(pure.findToolsRow(doc([addInOther])), null, '行类后缀漂移必须拒绝注入')
|
|
83
|
+
assert.equal(pure.findToolsRow(doc([])), null, '无 _add 按钮必须返回 null')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// ── 场景2:幂等注入 ──
|
|
87
|
+
|
|
88
|
+
test('ensureHistoryButton:克隆官方 _add 类名,插到最后一个 _add 之后,重复调用幂等', () => {
|
|
89
|
+
const addA = stubButton('uV2eYG_add')
|
|
90
|
+
const addB = stubButton('uV2eYG_add')
|
|
91
|
+
const row = stubRow([addA, addB])
|
|
92
|
+
const createdButtons = []
|
|
93
|
+
const createdSvg = []
|
|
94
|
+
const doc = stubDoc(createdButtons, createdSvg)
|
|
95
|
+
const toggles = []
|
|
96
|
+
const first = pure.ensureHistoryButton(doc, row, () => toggles.push(1))
|
|
97
|
+
assert.notEqual(first, null, '工具排齐备时应注入成功')
|
|
98
|
+
assert.equal(createdButtons.length, 1, '应创建恰好一个按钮')
|
|
99
|
+
const button = createdButtons[0]
|
|
100
|
+
assert.equal(button.className, 'uV2eYG_add', '必须克隆官方按钮类名获得原生视觉')
|
|
101
|
+
assert.equal(button['attr_' + pure.HISTORY_BTN_FLAG], '', '必须携带注入标记防重复')
|
|
102
|
+
assert.equal(button.type, 'button')
|
|
103
|
+
assert.equal(typeof button.onclick, 'function', '点击必须接线 toggle')
|
|
104
|
+
assert.deepEqual(row.inserted, { node: button, ref: null }, '应插到最后一个 _add 之后')
|
|
105
|
+
assert.equal(createdSvg.length, 1, '应创建历史图标 svg')
|
|
106
|
+
assert.equal(button.appended && button.appended.tagName, 'svg', '图标必须挂到按钮上')
|
|
107
|
+
const again = pure.ensureHistoryButton(doc, row, () => toggles.push(2))
|
|
108
|
+
assert.equal(again, first, '已注入时重复调用必须幂等返回既有按钮')
|
|
109
|
+
assert.equal(createdButtons.length, 1, '幂等调用不得重复创建')
|
|
110
|
+
button.onclick()
|
|
111
|
+
assert.equal(toggles.length, 1, '点击应触发 toggle')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('ensureHistoryButton:工具排缺官方 _add 按钮时返回 null(干净禁用)', () => {
|
|
115
|
+
const row = stubRow([stubButton('uV2eYG_primary')])
|
|
116
|
+
const createdButtons = []
|
|
117
|
+
const createdSvg = []
|
|
118
|
+
const result = pure.ensureHistoryButton(stubDoc(createdButtons, createdSvg), row, () => {})
|
|
119
|
+
assert.equal(result, null, '无克隆源不得注入')
|
|
120
|
+
assert.equal(createdButtons.length, 0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// ── 场景3/4 接线契约:源码断言(观察器主体无法离线实例化)──
|
|
124
|
+
|
|
125
|
+
test('源码契约:HistoryDock 注入 effect 具备扫描守卫/卸载清理/外点守卫/toggle 接线', () => {
|
|
126
|
+
assert.ok(CLIENT_SRC.includes("'button[class$=\"_add\"]'"), '扫描必须按官方 _add 按钮定位工具排')
|
|
127
|
+
const scopeStart = CLIENT_SRC.indexOf('const HISTORY_BTN_FLAG = ')
|
|
128
|
+
const scopeEnd = CLIENT_SRC.indexOf('function SteerRecallDock(')
|
|
129
|
+
const scope = CLIENT_SRC.slice(scopeStart, scopeEnd)
|
|
130
|
+
assert.ok(scope.includes("'[' + HISTORY_BTN_FLAG + ']'"), '移除与查询必须走注入标记选择器')
|
|
131
|
+
// 外点关闭守卫:点击注入按钮不得触发浮层外点关闭(mousedown 先关后开竞态)
|
|
132
|
+
assert.ok(scope.includes(".closest('[' + HISTORY_BTN_FLAG + ']')"), '外点关闭必须跳过注入按钮')
|
|
133
|
+
// toggle:开→关,关→openPopup;经 ref 接线供挂载一次的监听器读到最新状态
|
|
134
|
+
assert.ok(scope.includes('function toggleFromButton()'), '缺 toggle 接线函数')
|
|
135
|
+
assert.ok(scope.includes('= toggleFromButton') && scope.includes('openPopup()'), 'toggle 必须经 ref 接线并闭合 openPopup')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// ── 场景3(按钮开关)/场景5(设置卡行):开关读取与停用接线契约 ──
|
|
139
|
+
|
|
140
|
+
test('源码契约:按钮开关挂载拉取,停用即移除已注入且不再注入,状态到达触发重扫', () => {
|
|
141
|
+
assert.ok(CLIENT_SRC.includes("const HISTORY_BUTTON_ENABLED_URL = '/api/context/history-button-enabled'"), '缺按钮开关路由常量')
|
|
142
|
+
const scopeStart = CLIENT_SRC.indexOf('function HistoryDock(')
|
|
143
|
+
const scopeEnd = CLIENT_SRC.indexOf('function SteerRecallDock(')
|
|
144
|
+
const scope = CLIENT_SRC.slice(scopeStart, scopeEnd)
|
|
145
|
+
// 挂载拉取 + ref 双写(停用路径在观察器外也要即时生效)
|
|
146
|
+
assert.ok(scope.includes('api(HISTORY_BUTTON_ENABLED_URL)'), '开关值必须挂载拉取')
|
|
147
|
+
assert.ok(scope.includes('buttonEnabledRef.current = buttonEnabled'), '开关值必须双写 ref')
|
|
148
|
+
// 扫描守卫必须同时受总开关与按钮开关约束,停用移除已注入
|
|
149
|
+
assert.ok(/if \(enabledRef\.current === false \|\| buttonEnabledRef\.current === false\) \{\s*\n\s*removeAll\(\)/.test(scope), '扫描守卫缺按钮开关分支')
|
|
150
|
+
// 开关值异步到达(不触发 DOM 变更)时需主动重扫
|
|
151
|
+
assert.ok(/\[historyEnabled, buttonEnabled\]/.test(scope), '开关状态到达必须触发重扫')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('源码契约:设置卡第六开关行走 switchRow 工厂,挂按钮开关路由与说明常量', () => {
|
|
155
|
+
assert.ok(CLIENT_SRC.includes("switchRow(HISTORY_BUTTON_ENABLED_URL, '输入框历史按钮'"), '设置卡缺输入框历史按钮开关行')
|
|
156
|
+
assert.ok(CLIENT_SRC.includes('h(HistoryButtonSwitchRow)'), 'ContextPanel 未挂载第六开关行')
|
|
157
|
+
assert.ok(CLIENT_SRC.includes('const HISTORY_BUTTON_SWITCH_TITLE ='), '缺按钮开关说明常量')
|
|
158
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const CLIENT_SRC = readFileSync(fileURLToPath(new URL('../src/client.js', import.meta.url)), 'utf8')
|
|
7
|
+
|
|
8
|
+
// 范围标签契约:默认单胶囊只显当前范围,点击展开 pill 组,选择后收起;←/→ 键盘切换走同一落点
|
|
9
|
+
test('源码契约:范围标签默认单胶囊,点击展开 pill 组选择', () => {
|
|
10
|
+
assert.ok(CLIENT_SRC.includes('const [scopeOpen, setScopeOpen] = useState(false)'), '展开态应默认收起')
|
|
11
|
+
assert.ok(CLIENT_SRC.includes('HISTORY_SCOPE_LABELS[scopeIndex]'), '默认应单胶囊显示当前范围')
|
|
12
|
+
assert.ok(CLIENT_SRC.includes('HISTORY_SCOPE_LABELS.map('), '展开后应循环渲染 pill 组')
|
|
13
|
+
assert.ok(CLIENT_SRC.includes('onClick: () => setScopeOpen(true)'), '单胶囊点击应展开')
|
|
14
|
+
assert.ok(CLIENT_SRC.includes('onClick: () => (idx === scopeIndex ? setScopeOpen(false) : selectScope(idx))'), 'pill 点击应直达对应范围,当前项点击收起')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('源码契约:selectScope 为键盘与点击共用的唯一切换落点,切换即收起', () => {
|
|
18
|
+
assert.ok(CLIENT_SRC.includes('function selectScope('), '应有 selectScope 直达切换函数')
|
|
19
|
+
// 键盘相对切换委托 selectScope,消除重复切换链
|
|
20
|
+
assert.ok(/function switchScope\(\s*delta\s*\)\s*\{[\s\S]{0,200}selectScope\(next\)/.test(CLIENT_SRC),
|
|
21
|
+
'switchScope 应委托 selectScope')
|
|
22
|
+
// 同范围重复点击不重拉
|
|
23
|
+
const body = CLIENT_SRC.match(/function selectScope\([^)]*\)\s*\{[\s\S]*?\n \}/)
|
|
24
|
+
assert.ok(body && body[0].includes('viewRef.current.scopeIndex'), 'selectScope 应以当前范围守卫幂等')
|
|
25
|
+
assert.ok(body && body[0].includes('setScopeOpen(false)'), '切换后应收起展开态')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('源码契约:重开浮层展开态复位,设置悬停说明同步展开语义', () => {
|
|
29
|
+
// openPopup 内 setScopeOpen(false):浮层重开不残留展开态
|
|
30
|
+
assert.ok(/function openPopup\(\)\s*\{[\s\S]{0,300}setScopeOpen\(false\)/.test(CLIENT_SRC),
|
|
31
|
+
'浮层重开应复位展开态')
|
|
32
|
+
assert.ok(CLIENT_SRC.includes('点击顶栏范围标签展开选择'), '历史说明缺范围标签展开选择语义')
|
|
33
|
+
assert.ok(CLIENT_SRC.includes('←/→ 切换范围'), '历史说明应保留键盘切换语义')
|
|
34
|
+
})
|