@modusensus/dsh-mneme 0.12.0 → 0.13.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 +48 -19
- package/docs/CONFIGURATION.md +43 -4
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +581 -29
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3537 -1205
- package/lib/config.js +139 -1
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/dream/decisions.js +56 -5
- package/lib/dream/sleep.js +616 -15
- package/lib/dream.js +45 -13
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +8 -9
- package/lib/index.js +92 -2
- package/lib/inject.js +189 -18
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +87 -4
- package/lib/peak-hours.js +57 -2
- package/lib/semantic.js +347 -60
- package/lib/service.js +193 -66
- package/lib/settings.js +112 -14
- package/lib/store.js +347 -21
- package/lib/summarize.js +38 -11
- package/lib/tools.js +35 -4
- package/lib/user-settings.js +128 -32
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +116 -2
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/src/api-standalone.js +4 -1
- package/src/api.js +581 -29
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +139 -1
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/dream/decisions.js +56 -5
- package/src/dream/sleep.js +616 -15
- package/src/dream.js +45 -13
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +8 -9
- package/src/index.js +92 -2
- package/src/inject.js +189 -18
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +87 -4
- package/src/peak-hours.js +57 -2
- package/src/semantic.js +347 -60
- package/src/service.js +193 -66
- package/src/settings.js +112 -14
- package/src/store.js +347 -21
- package/src/summarize.js +38 -11
- package/src/tools.js +35 -4
- package/src/user-settings.js +128 -32
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +116 -2
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +7 -1
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +375 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +779 -154
- package/test/conflict-freeze.test.js +45 -1
- package/test/continuity.test.js +8 -1
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/image-memory.test.js +171 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +7 -7
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +598 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +355 -0
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +7 -0
- package/test/wiki-t1-jobs.test.js +125 -0
package/test/client.test.js
CHANGED
|
@@ -82,40 +82,53 @@ test("memory entry registers the host panel seats and keeps the footer fallback"
|
|
|
82
82
|
);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
// chorography(仓库地志)第二个座位(M1
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
test("chorography wiki
|
|
91
|
-
assert.ok(
|
|
92
|
-
clientSource.includes('id: "dsh-mneme-wiki"'),
|
|
93
|
-
"the wiki sidebar seat must use its own id"
|
|
94
|
-
);
|
|
85
|
+
// chorography(仓库地志)第二个座位(M1)。#598 起成对规则拆成两半:
|
|
86
|
+
// main 的 key 仍逐字同为 "dsh-mneme-wiki" 且无条件注册——#562 的墙(布局存着
|
|
87
|
+
// activePanelId 时少注册,宿主 selectPanel 直接抛);侧栏 glyph 改读 features
|
|
88
|
+
// 按 chorographyEnabled 决定,与 M.O.(v0.9.0)/日记(v0.13)的座位闸同一形态,
|
|
89
|
+
// fail-closed。默认关的功能不该常驻一个点进去只有「未启用」的入口。
|
|
90
|
+
test("chorography wiki main stays unconditional; the sidebar glyph is feature-gated (#598)", () => {
|
|
95
91
|
assert.ok(
|
|
96
92
|
clientSource.includes('key: "dsh-mneme-wiki"'),
|
|
97
|
-
"the wiki main seat key must
|
|
93
|
+
"the wiki main seat key must stay verbatim and unconditional (#562)"
|
|
98
94
|
);
|
|
99
95
|
assert.ok(
|
|
100
96
|
clientSource.includes("h(WikiMainPanel, { t })"),
|
|
101
97
|
"the wiki main seat must render WikiMainPanel"
|
|
102
98
|
);
|
|
99
|
+
assert.ok(
|
|
100
|
+
clientSource.includes('id: "dsh-mneme-wiki"'),
|
|
101
|
+
"the wiki sidebar seat keeps its own id (now registered by the gate)"
|
|
102
|
+
);
|
|
103
|
+
assert.ok(
|
|
104
|
+
clientSource.includes('"dsh-mneme: chorography seat gate"'),
|
|
105
|
+
"the glyph registration must live behind the features gate"
|
|
106
|
+
);
|
|
107
|
+
assert.ok(
|
|
108
|
+
/if \(!\(\(j && j\.effective\) \|\| \{\}\)\.chorographyEnabled\) return;/.test(clientSource),
|
|
109
|
+
"the gate must fail-closed on the chorographyEnabled flag"
|
|
110
|
+
);
|
|
111
|
+
assert.ok(
|
|
112
|
+
clientSource.includes("chorography seat stays off"),
|
|
113
|
+
"a failed features fetch must leave the seat off, not guess"
|
|
114
|
+
);
|
|
103
115
|
assert.ok(
|
|
104
116
|
clientSource.includes('phase: "disabled"'),
|
|
105
|
-
"
|
|
117
|
+
"the disabled empty state must survive for layouts that still point at the wiki panel (#562 keeps main alive for exactly this)"
|
|
106
118
|
);
|
|
107
|
-
// wiki
|
|
108
|
-
//
|
|
119
|
+
// wiki glyph 的清理随登记进了座位闸(cordis 上下文销毁时一并回收);
|
|
120
|
+
// panellist 回调的扫帚只剩记忆对 + wiki 的 main + kb 对。
|
|
109
121
|
assert.ok(
|
|
110
|
-
/return \(\) => \{\s*disposeGlyph\(\);\s*disposeMain\(\)
|
|
111
|
-
"the panellist cleanup must dispose
|
|
122
|
+
/return \(\) => \{\s*disposeGlyph\(\);\s*disposeMain\(\);[\s\S]*?disposeWikiMain\(\);/.test(clientSource),
|
|
123
|
+
"the panellist cleanup must dispose the memory pair and wiki main, in order"
|
|
112
124
|
);
|
|
113
125
|
});
|
|
114
126
|
|
|
115
|
-
// 收尾档(P34
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
127
|
+
// 收尾档(P34)留下的边界:面板的「重建」触发点只接 T0(秒级同步回执)。
|
|
128
|
+
// T1 后来按契约 §3.5 单开了自己的路由(/choro/t1 + 轮询),所以这里的
|
|
129
|
+
// 「不上 HTTP」只对重建按钮成立,不再是整个面板的规矩——重建仍然是同步回执,
|
|
130
|
+
// 锁三件事:按钮打到 /choro/run、rebuilding 态防连点、t1Loss/指引文案存在
|
|
131
|
+
// (spec §3「现在生成」的落点 + M2 补洞的警告透出)。
|
|
119
132
|
test("chorography wiki page exposes the rebuild action against /choro/run", () => {
|
|
120
133
|
assert.ok(
|
|
121
134
|
clientSource.includes('"/api/dsh-mneme/choro/run"'),
|
|
@@ -135,6 +148,195 @@ test("chorography wiki page exposes the rebuild action against /choro/run", () =
|
|
|
135
148
|
);
|
|
136
149
|
});
|
|
137
150
|
|
|
151
|
+
// issue #607 正面修复:空态(phase === "no-repo")以前只有一句「去跑 CLI」的
|
|
152
|
+
// 纯文字,一个可点的东西都没有,而那句命令在 PATH 里根本不存在。回归表现:
|
|
153
|
+
// · 空态退回纯文字 → 用户第一次进这块只能自己去翻文档;
|
|
154
|
+
// · 命令示例写回裸 CLI 名 → 抄进终端就是 "command not found";
|
|
155
|
+
// · 文案出现「cd 进目录」→ 命令收的是路径参数,cd 进去反而找不着仓库。
|
|
156
|
+
test("#607: the no-repo empty state offers a clickable path-to-generate entry", () => {
|
|
157
|
+
assert.ok(
|
|
158
|
+
clientSource.includes("mneme-wikirunrow"),
|
|
159
|
+
"the no-repo empty state must render an input+button row, not a bare paragraph"
|
|
160
|
+
);
|
|
161
|
+
assert.ok(
|
|
162
|
+
clientSource.includes('t("wiki.empty.generate")'),
|
|
163
|
+
"the empty state must carry its own generate button label"
|
|
164
|
+
);
|
|
165
|
+
assert.ok(
|
|
166
|
+
clientSource.includes('t("wiki.empty.pathPlaceholder")'),
|
|
167
|
+
"the empty state must ask for the repository's absolute path (the CLI takes a path argument)"
|
|
168
|
+
);
|
|
169
|
+
// 命令要给绝对路径:dsh-mneme-choro 不在 PATH 里,裸名抄进终端必然找不到。
|
|
170
|
+
// 值里含转义引号(路径要用引号包住),所以逐字取到未转义的那个收尾引号。
|
|
171
|
+
const cmds = [...clientSource.matchAll(/"wiki\.empty\.cliCmd":\s*"((?:[^"\\]|\\.)+)"/g)].map((m) => m[1]);
|
|
172
|
+
assert.equal(cmds.length, 2, "wiki.empty.cliCmd must exist in both dictionaries");
|
|
173
|
+
for (const cmd of cmds) {
|
|
174
|
+
assert.ok(
|
|
175
|
+
cmd.includes("node ") && cmd.includes("dsh-mneme-choro.mjs"),
|
|
176
|
+
"the CLI example must be a node call on the bin script, got: " + cmd
|
|
177
|
+
);
|
|
178
|
+
assert.ok(cmd.includes("--apply"), "the example must carry --apply, otherwise it only previews");
|
|
179
|
+
}
|
|
180
|
+
// 收的是路径参数,不能教用户 cd 进目录:cd 进去再传 "." 又会因为命令不在
|
|
181
|
+
// PATH 里失败,这是 #607 报告里最常被踩的一步。判据落在两处:旧的「在仓库
|
|
182
|
+
// 目录里跑」这句必须消失;剩下的提及只允许是「不必先 cd」这种反例澄清。
|
|
183
|
+
const noRepos = [...clientSource.matchAll(/"wiki\.empty\.noRepo":\s*"((?:[^"\\]|\\.)+)"/g)].map((m) => m[1]);
|
|
184
|
+
assert.equal(noRepos.length, 2, "wiki.empty.noRepo must exist in both dictionaries");
|
|
185
|
+
for (const copy of noRepos) {
|
|
186
|
+
assert.equal(
|
|
187
|
+
/在仓库目录里跑|inside the repository|run this inside/i.test(copy),
|
|
188
|
+
false,
|
|
189
|
+
"the copy must not tell the user to run the command from inside the repository, got: " + copy
|
|
190
|
+
);
|
|
191
|
+
// #607 的三件事缺一不可:是什么、在哪里开、命令要给绝对路径。
|
|
192
|
+
assert.ok(copy.includes("mneme"), "the copy must say which plugin this belongs to");
|
|
193
|
+
}
|
|
194
|
+
const hints = [...clientSource.matchAll(/"wiki\.empty\.cliHint":\s*"((?:[^"\\]|\\.)+)"/g)].map((m) => m[1]);
|
|
195
|
+
assert.equal(hints.length, 2, "wiki.empty.cliHint must exist in both dictionaries");
|
|
196
|
+
for (const hint of hints) {
|
|
197
|
+
assert.ok(
|
|
198
|
+
/不必先 cd|no need to cd/i.test(hint),
|
|
199
|
+
"a mention of cd must be the explicit disclaimer, got: " + hint
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
assert.ok(
|
|
203
|
+
clientSource.includes('t("wiki.run.notGit")'),
|
|
204
|
+
"a not-git rejection must be translated into an explanation, not echoed as a bare code"
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// 空态那条 POST 要带 path 字段(契约 §3.2):老实现只认 repo,新实现认 path,
|
|
209
|
+
// 两个都带上才能同时跑在两版后端上。回归表现:只发 repo → 第一次进来自报家门
|
|
210
|
+
// 的目录永远登记不上。
|
|
211
|
+
test("#607: generating from the empty state posts the absolute path to /choro/run", () => {
|
|
212
|
+
const fn = sliceComponent("WikiMainPanel");
|
|
213
|
+
assert.ok(/body = \{ path: p \}/.test(fn), "the run body must carry the path field");
|
|
214
|
+
assert.ok(fn.includes("body.repo = "), "the run body must still carry repo when one is already loaded");
|
|
215
|
+
assert.ok(fn.includes('"/api/dsh-mneme/choro/run"'), "both entry points share the token-write route");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// 失败也要能读懂(#607 的另一半:原文案把错误码直接糊在屏幕上)。
|
|
219
|
+
// 后端把 wiki 层的 err.code "not-git" 翻成了对外契约的 "not-a-git-repo",两个码
|
|
220
|
+
// 都要认——只认一个就会出现「同一个失败,换个后端版本就只剩错误码」的半修状态。
|
|
221
|
+
test("#607: run failures explain themselves instead of echoing a bare error code", () => {
|
|
222
|
+
const fn = sliceComponent("WikiMainPanel");
|
|
223
|
+
assert.ok(fn.includes('data.error === "not-git"'), "the wiki-layer code must still be understood");
|
|
224
|
+
assert.ok(fn.includes('data.error === "not-a-git-repo"'), "the public API code is the one the server actually sends");
|
|
225
|
+
assert.ok(fn.includes('t("wiki.run.notGit")'), "not-git must resolve to an explanation, not a code");
|
|
226
|
+
assert.ok(fn.includes('t("wiki.run.notAbsolute")'), "a relative path must say it has to be absolute");
|
|
227
|
+
assert.ok(fn.includes('t("wiki.run.notFound")'), "a missing directory must say so");
|
|
228
|
+
|
|
229
|
+
// 解释本身要讲清「为什么」:非 git 没有可比基线,且不含「cd 进目录」这类误导。
|
|
230
|
+
const notGit = clientSource.match(/"wiki\.run\.notGit":\s*"((?:[^"\\]|\\.)+)"/g) || [];
|
|
231
|
+
assert.equal(notGit.length, 2, "wiki.run.notGit must exist in zh and en");
|
|
232
|
+
for (const line of notGit) {
|
|
233
|
+
assert.ok(/git/.test(line), "the not-git copy must mention git");
|
|
234
|
+
assert.ok(/基线|baseline/.test(line), "the not-git copy must explain the missing baseline, i.e. why it is refused");
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// 契约 §3.5-2:面板顶栏两枚子开关 + T1 长任务。
|
|
239
|
+
// · T1 是 30–240s 起步的长任务,POST 只负责起跑,进度必须靠轮询;
|
|
240
|
+
// · 轮询依赖取 jobId 而不是整个 t1 对象,否则每拍 setState 都把定时器拆了重建;
|
|
241
|
+
// · stale 的仓库要在行内标出来,否则用户以为页面和代码同步。
|
|
242
|
+
test("choro page wires the two feature toggles, the T1 job and the stale marker", () => {
|
|
243
|
+
const fn = sliceComponent("WikiMainPanel");
|
|
244
|
+
assert.ok(fn.includes('toggleFlag("chorographyAutoRefresh")'), "the auto-refresh toggle must ride on chorographyAutoRefresh");
|
|
245
|
+
// 两枚都是「面板就地能改」的开关,不是只读回显:点一下要把同一个键 PUT 回 /features。
|
|
246
|
+
assert.ok(fn.includes('toggleFlag("chorographyT1FromPanel")'), "the T1 permission is its own toggle, not a read-only echo");
|
|
247
|
+
assert.ok(fn.includes('"/api/dsh-mneme/features"'), "toggling must write back through /features, not a local-only flag");
|
|
248
|
+
assert.ok(fn.includes('"/api/dsh-mneme/choro/t1"'), "the T1 button must POST /choro/t1");
|
|
249
|
+
assert.ok(fn.includes("/api/dsh-mneme/choro/t1/status?jobId="), "T1 progress must be polled from /choro/t1/status");
|
|
250
|
+
assert.ok(fn.includes("setInterval(tick, 2000)"), "the poll interval is 2s per contract §3.5");
|
|
251
|
+
assert.ok(fn.includes("clearInterval"), "the poller must be torn down when the job ends or the panel unmounts");
|
|
252
|
+
assert.ok(
|
|
253
|
+
fn.includes("mneme-badge--pending") && fn.includes('t("wiki.stale")'),
|
|
254
|
+
'stale repos need an inline "code changed" badge'
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// 知识库页(契约 §3.6)。这条锁的是「路由与就近文案都在」,不是布局:
|
|
259
|
+
// · 409/503 各有自己的空态,不把裸状态码丢屏幕上;
|
|
260
|
+
// · disabled 那句必须说清去哪儿开(#607 的教训:只说「未启用」等于没说);
|
|
261
|
+
// · 地志页导入要拼绝对路径,根路径只从 /choro/repos 取,拿不到就停手。
|
|
262
|
+
test("knowledge base page exposes the §3.6 routes and explains every empty state", () => {
|
|
263
|
+
const fn = sliceComponent("KbMainPanel");
|
|
264
|
+
for (const route of [
|
|
265
|
+
'"/api/dsh-mneme/kb/collections',
|
|
266
|
+
'"/api/dsh-mneme/kb/docs?collection=',
|
|
267
|
+
'"/api/dsh-mneme/kb/ingest"',
|
|
268
|
+
'"/api/dsh-mneme/kb/search"'
|
|
269
|
+
]) {
|
|
270
|
+
assert.ok(fn.includes(route), "the knowledge base page must call " + route);
|
|
271
|
+
}
|
|
272
|
+
assert.ok(fn.includes('t("kb.empty.disabled")'), "409 must render the disabled explanation");
|
|
273
|
+
assert.ok(fn.includes('t("kb.empty.unavailable")'), "503 must render the unavailable explanation");
|
|
274
|
+
assert.ok(fn.includes('t("kb.empty.collections")'), "an empty collection list must explain what to do next");
|
|
275
|
+
assert.ok(fn.includes('t("kb.repo.noPath")'), "a missing rootPath must stop before composing a guaranteed 404");
|
|
276
|
+
// 后端 /choro/modules 曾只回 snake_case 的 page_rel_path,面板读 mod.relPath 恒 undefined,
|
|
277
|
+
// 于是「选完模块点导入」永远弹「请先选模块」——把后端缺陷说成用户没点(#607 同型)。
|
|
278
|
+
// 现在后端补了 relPath,缺字段时也必须落到独立的解释上,不许复用 pickFirst。
|
|
279
|
+
assert.ok(fn.includes('t("kb.repo.noRelPath")'), "a module without relPath needs its own explanation");
|
|
280
|
+
assert.ok(
|
|
281
|
+
!/if \(!relPath\) \{ patch\(\{ toast: t\("kb\.repo\.pickFirst"\) \}\); return; \}/.test(fn),
|
|
282
|
+
"a missing relPath must not be reported as 'pick a module first'"
|
|
283
|
+
);
|
|
284
|
+
assert.ok(fn.includes("kb/collections?key="), "deleting a collection addresses it by key");
|
|
285
|
+
|
|
286
|
+
// 文案本身锁一次:说清这是 mneme 的功能、开关在哪儿。
|
|
287
|
+
assert.ok(
|
|
288
|
+
clientSource.includes("设置 → 记忆(mneme) → 功能 → 知识库"),
|
|
289
|
+
"the Chinese disabled copy must name the exact toggle path"
|
|
290
|
+
);
|
|
291
|
+
assert.ok(
|
|
292
|
+
clientSource.includes("Settings → Memory (mneme) → Features → Knowledge base"),
|
|
293
|
+
"the English disabled copy must name the same path"
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// 仓库卡片 + 一键整理(#607 反馈:希望「在这里选择整理知识」,照 Qoder Repo Wiki 那样)。
|
|
298
|
+
// 这条锁四件事,都是「假成功」的高发点:
|
|
299
|
+
// · /choro/repos 的 409/503 不能并进「还没有仓库」,否则地志没开被说成用户没生成;
|
|
300
|
+
// · 批量整理的失败必须逐条留名(toast 2.5 秒就没了,名字一多读不完);
|
|
301
|
+
// · 只有 relPath 存在的模块才去灌(没骨架页硬灌 = 一串 file-not-found);
|
|
302
|
+
// · 手动入库降级为可折叠的高级入口,卡片才是首屏路径。
|
|
303
|
+
test("knowledge base organises a whole repo from cards and never fakes a clean result", () => {
|
|
304
|
+
const fn = sliceComponent("KbMainPanel");
|
|
305
|
+
assert.ok(fn.includes('"/api/dsh-mneme/choro/repos"'), "the cards read the repo list (the only route with rootPath)");
|
|
306
|
+
assert.ok(fn.includes('"/api/dsh-mneme/choro/modules?repo="'), "organising needs each repo's page relative paths");
|
|
307
|
+
// 409/503 必须各走各的 reposPhase,不许落进空列表。
|
|
308
|
+
assert.ok(/status === 409[^}]*reposPhase: "disabled"/.test(fn), "409 must set reposPhase=disabled, not an empty list");
|
|
309
|
+
assert.ok(/status === 503[^}]*reposPhase: "unavailable"/.test(fn), "503 must set reposPhase=unavailable, not an empty list");
|
|
310
|
+
assert.ok(fn.includes('t("kb.repos.empty")'), "a genuinely empty repo list gets its own copy");
|
|
311
|
+
// 三档回执:新增 / 未变 / 失败,合并成一句「整理完成」就是假回声。
|
|
312
|
+
assert.ok(fn.includes('t("kb.repos.new")') && fn.includes('t("kb.repos.same")') && fn.includes('t("kb.repos.failedN")'),
|
|
313
|
+
"the batch receipt must report added, unchanged and failed separately");
|
|
314
|
+
assert.ok(fn.includes("organizeErrors"), "failed pages must stay on the page, not only in a 2.5s toast");
|
|
315
|
+
// 只灌有 relPath 的页。
|
|
316
|
+
assert.ok(/\.filter\(\(m\) => m\.relPath\)/.test(fn), "only modules with a relPath may be ingested");
|
|
317
|
+
// 高级入口:手动表单常驻,但折叠在按钮后面。
|
|
318
|
+
assert.ok(fn.includes("mneme-kbadvtoggle") && fn.includes('t("kb.advanced")'),
|
|
319
|
+
"manual ingest stays available behind one explicit toggle");
|
|
320
|
+
// 卡片上的「已整理 N/M」按当前集合的 relKey 前缀数,不额外打请求。
|
|
321
|
+
assert.ok(fn.includes("organizedCount") && fn.includes("state.docs"), "the organised count reads the loaded docs, not a new request");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// 知识库是第三个座位:id 与 main 的 key 必须逐字同名,否则宿主 selectPanel
|
|
325
|
+
// 直接抛 "main panel ... is not registered"(#562 在记忆座位上踩过)。
|
|
326
|
+
test("knowledge base entry registers its own seat pair in the same panellist block", () => {
|
|
327
|
+
assert.ok(clientSource.includes('id: "dsh-mneme-kb"'), "the kb sidebar seat must use its own id");
|
|
328
|
+
assert.ok(clientSource.includes('key: "dsh-mneme-kb"'), "the kb main seat key must match the sidebar id verbatim");
|
|
329
|
+
assert.ok(clientSource.includes("h(KbMainPanel, { t })"), "the kb main seat must render KbMainPanel");
|
|
330
|
+
assert.ok(
|
|
331
|
+
clientSource.includes("h(KbGlyph, { size: props && props.size })"),
|
|
332
|
+
"the kb glyph takes its size from the host"
|
|
333
|
+
);
|
|
334
|
+
assert.ok(
|
|
335
|
+
/return \(\) => \{\s*disposeGlyph\(\);\s*disposeMain\(\);[\s\S]*?disposeWikiMain\(\);\s*disposeKbGlyph\(\);\s*disposeKbMain\(\);/.test(clientSource),
|
|
336
|
+
"the panellist cleanup must dispose the remaining seat pairs (the wiki glyph moved to the feature gate)"
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
138
340
|
// v0.11.1 美化:文件清单的竖线等宽行换成真 table(用户实测反馈「文件名排列
|
|
139
341
|
// 太难看」)。锁渲染器三件事:真 <table>、分隔行(--- | ---:)按对齐元数据消费
|
|
140
342
|
// 而不是画成可见行、数字列右对齐。回归表现:改回竖线等宽行画法 → 红在缺 table。
|
|
@@ -189,8 +391,10 @@ test("panel seats: the fallback mounts and unmounts with the host's declaration"
|
|
|
189
391
|
// spec 存在时点火:一把全点会把新座位的注册塞进老宿主,测出来的就不是真行为。
|
|
190
392
|
const fire = (ctx, key) => ctx.injected.filter((i) => i.key === key).map((i) => i.cb());
|
|
191
393
|
|
|
192
|
-
// 新宿主:panellist 已声明。panellist + main
|
|
193
|
-
//
|
|
394
|
+
// 新宿主:panellist 已声明。panellist + main 各三份(记忆 + chorography +
|
|
395
|
+
// 知识库三个座位),每对各自 id/key 逐字同名;footer 回落不装。
|
|
396
|
+
// 日记本原有第四对座位,2026-11-09 按桉桉要求撤掉——它现在是记忆面板内的
|
|
397
|
+
// 一个标签页,与人格页并列,不再占侧边栏;人格页同理(面板内标签页,不是座位)。
|
|
194
398
|
{
|
|
195
399
|
const { ctx, regs } = makeCtx((key) => (key === "sidebar.panellist" ? { kind: "list" } : undefined));
|
|
196
400
|
const api = boot(ctx, (k) => k, () => null);
|
|
@@ -199,10 +403,10 @@ test("panel seats: the fallback mounts and unmounts with the host's declaration"
|
|
|
199
403
|
fire(ctx, "sidebar.footer.action");
|
|
200
404
|
assert.equal(disposes.length, 1);
|
|
201
405
|
const ids = (name) => regs.filter((r) => r.alive && r.opts.name === name).map((r) => r.opts.id ?? r.opts.key).sort();
|
|
202
|
-
assert.equal(alive(regs, "sidebar.panellist"), 2, "memory + wiki
|
|
203
|
-
assert.deepEqual(ids("sidebar.panellist"), ["dsh-mneme", "dsh-mneme-
|
|
204
|
-
assert.equal(alive(regs, "main"),
|
|
205
|
-
assert.deepEqual(ids("main"), ["dsh-mneme", "dsh-mneme-wiki"], "main key 与侧栏 id
|
|
406
|
+
assert.equal(alive(regs, "sidebar.panellist"), 2, "memory + kb glyphs — the wiki glyph is feature-gated (#598), not unconditional");
|
|
407
|
+
assert.deepEqual(ids("sidebar.panellist"), ["dsh-mneme", "dsh-mneme-kb"], "每对座位各自 id 逐字唯一;wiki glyph 走座位闸,不在无条件集合里");
|
|
408
|
+
assert.equal(alive(regs, "main"), 3, "memory + wiki + kb pages — wiki main stays unconditional (#562)");
|
|
409
|
+
assert.deepEqual(ids("main"), ["dsh-mneme", "dsh-mneme-kb", "dsh-mneme-wiki"], "main key 与侧栏 id 逐字同名(wiki 的 main 无条件保留,#562)");
|
|
206
410
|
assert.equal(alive(regs, "sidebar.footer.action"), 0, "the footer fallback must not mount on a host that has the seat");
|
|
207
411
|
}
|
|
208
412
|
|
|
@@ -336,6 +540,33 @@ test("memory library opens as a centered sheet with backdrop", () => {
|
|
|
336
540
|
);
|
|
337
541
|
});
|
|
338
542
|
|
|
543
|
+
// #597:分类栏与页头的数字源必须来自服务端(typeCounts / total)——浏览页被
|
|
544
|
+
// type 筛过又分了页,页内现数两头都数不出真分布:选中一类后其它类计数为 0 被
|
|
545
|
+
// filter 整条滤掉(分类栏塌剩两枚),页头停在已加载行数(滚动一次后恒 200)。
|
|
546
|
+
// 降级路径必须同时在:老服务端没有 typeCounts 时退回页内现数,功能不炸。
|
|
547
|
+
test("explorer counts read the server distribution with an in-page fallback (#597)", () => {
|
|
548
|
+
assert.ok(
|
|
549
|
+
clientSource.includes("setTypeCounts(d.typeCounts || null)"),
|
|
550
|
+
"the browse fetch must capture the server distribution"
|
|
551
|
+
);
|
|
552
|
+
assert.ok(
|
|
553
|
+
clientSource.includes("for (const k of EXPLORER_TYPES) counts[k] = typeCounts[k] || 0"),
|
|
554
|
+
"the type rail must read the server distribution (missing types are 0, never collapse)"
|
|
555
|
+
);
|
|
556
|
+
assert.ok(
|
|
557
|
+
clientSource.includes("for (const m of items) counts[m.type] = (counts[m.type] || 0) + 1"),
|
|
558
|
+
"the in-page fallback must stay for servers without typeCounts"
|
|
559
|
+
);
|
|
560
|
+
assert.ok(
|
|
561
|
+
clientSource.includes("String(allCount)"),
|
|
562
|
+
"the all chip must use the distribution sum, not the loaded page length"
|
|
563
|
+
);
|
|
564
|
+
assert.ok(
|
|
565
|
+
/String\(remoteItems \? visible\.length : total\)/.test(clientSource),
|
|
566
|
+
"the header count must use the server total in browse mode (not visible.length)"
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
|
|
339
570
|
// The sidebar hands each entry only its column state: a wide row (icon +
|
|
340
571
|
// label) when expanded, a bare rail icon when collapsed — for both the
|
|
341
572
|
// portalled top button and the footer fallback.
|
|
@@ -1304,14 +1535,23 @@ test("scope isolation ships panel switches and provenance surfaces (A4)", () =>
|
|
|
1304
1535
|
// 默认值),面板自判、不加后端字段;④分组与折叠键中英成对。验收口径:首屏
|
|
1305
1536
|
// 可点项 = 3 总开关 + autoInject 的 2 个子开关 + scope 2 开关 + 「全部设置」
|
|
1306
1537
|
// 按钮 = 8(≤ 8),由 ①② 的组成正则共同钉死,不另数 DOM。
|
|
1307
|
-
// v0.9.0 复验修订:首屏加 group.mo(M.O. 独立组),可点项预算 8→9
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1538
|
+
// v0.9.0 复验修订:首屏加 group.mo(M.O. 独立组),可点项预算 8→9——产品
|
|
1539
|
+
// 主人点名「别藏在里面」,M.O. 独立成组却仍折在「全部设置」后等于换个地方
|
|
1540
|
+
// 继续藏。**v0.13.x 反转**:主人决定把 M.O. 下架,那一组连同首屏位一起摘掉,
|
|
1541
|
+
// 预算回到 8,本锁随之收紧回两组。反转是产品决策、不是放宽,注释留档防下次
|
|
1542
|
+
// 有人照着旧注释把组加回来。
|
|
1310
1543
|
test("settings card folds everything beyond the first screen behind All settings (issue #427)", () => {
|
|
1311
|
-
// ①
|
|
1544
|
+
// ① 首屏层组成:两组都要在(basic 总开关 / scope 必答题),
|
|
1312
1545
|
// 且 basic 的成员是从 core/dream 移走的。
|
|
1313
|
-
assert.ok(/const FEATURE_FIRST_SCREEN = \["group\.basic", "group\.
|
|
1314
|
-
"the first screen must be exactly the basic
|
|
1546
|
+
assert.ok(/const FEATURE_FIRST_SCREEN = \["group\.basic", "group\.scope"\];/.test(clientSource),
|
|
1547
|
+
"the first screen must be exactly the basic and scope groups (M.O. was taken down in v0.13.x)");
|
|
1548
|
+
// M.O. 下架锁:组从 FEATURE_GROUPS 摘掉 ≠ 只把首屏位去掉。少了这一条,下次
|
|
1549
|
+
// 有人「顺手把组加回来」时会折进「全部设置」,测试照绿而 M.O. 又出现在面板上。
|
|
1550
|
+
assert.ok(!/\{ key: "group\.mo", items: \[/.test(clientSource),
|
|
1551
|
+
"M.O. must stay off the panel: group.mo is deliberately absent from FEATURE_GROUPS (taken down in v0.13.x)");
|
|
1552
|
+
// 但键本身不能删——config schema / settings 白名单里还在,恢复时零改动。
|
|
1553
|
+
assert.ok(clientSource.includes("actionPipelineEnabled"),
|
|
1554
|
+
"the M.O. flags must remain reachable (config + settings whitelist) so the takedown stays reversible");
|
|
1315
1555
|
assert.ok(/key: "group\.basic", items: \["autoInject", "autoSummarize", "autoDream"\]/.test(clientSource),
|
|
1316
1556
|
"group.basic must carry exactly the three master switches");
|
|
1317
1557
|
assert.ok(!/key: "group\.core", items: \["autoInject/.test(clientSource) && !/key: "group\.dream", items: \["autoDream/.test(clientSource),
|
|
@@ -1394,11 +1634,30 @@ test("conflict queue ships a central review surface on the status tab", () => {
|
|
|
1394
1634
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1395
1635
|
}
|
|
1396
1636
|
assert.ok(/function ConflictsQueue\(\{ t \}\)/.test(clientSource), "ConflictsQueue component must exist");
|
|
1397
|
-
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts"'), "queue must fetch the conflicts endpoint");
|
|
1637
|
+
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts?limit=200"'), "queue must fetch the conflicts endpoint");
|
|
1398
1638
|
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts/resolve"'), "queue must call the resolve endpoint");
|
|
1399
1639
|
assert.ok(clientSource.includes("h(ConflictsQueue, { t })"), "status tab must render the queue");
|
|
1400
1640
|
});
|
|
1401
1641
|
|
|
1642
|
+
// Issue #603 第 3 层:服务端一直返回 { items, total },面板此前既不带 limit 也不读
|
|
1643
|
+
// total——列 50 条而徽章 60,第 51–60 条翻不到、也没有任何提示。这条锁三件事:
|
|
1644
|
+
// 请求要取满服务端上限、total 要存下来、被截断时要说出来。
|
|
1645
|
+
test("#603 conflict queue: the panel asks for the server cap and admits truncation", () => {
|
|
1646
|
+
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts?limit=200"'),
|
|
1647
|
+
"the queue must request the server's 200-row cap, not the 50-row default");
|
|
1648
|
+
assert.ok(clientSource.includes("setTotal(typeof d.total === \"number\" ? d.total : null)"),
|
|
1649
|
+
"the response total must be kept (it was dropped before)");
|
|
1650
|
+
assert.ok(clientSource.includes("memory.status.conflictQueue.truncated"),
|
|
1651
|
+
"a truncated list must say so");
|
|
1652
|
+
for (const key of ["memory.status.conflictQueue.truncated"]) {
|
|
1653
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1654
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1655
|
+
}
|
|
1656
|
+
// 差额才提示:总数等于行数时不该多出一行噪声。
|
|
1657
|
+
assert.ok(clientSource.includes("total > shown ? total - shown : 0"),
|
|
1658
|
+
"the notice must only appear when rows are actually hidden");
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1402
1661
|
// --- issue #178 批次一:无障碍(aria-live 网络 / 语义标题 / 焦点管理 / 图摘要) ---
|
|
1403
1662
|
|
|
1404
1663
|
// 面板瞬时反馈(保存/裁决/刷新/复制)不能只靠纯视觉 span:announce() 单例
|
|
@@ -1446,10 +1705,12 @@ test("a11y: entity graph svg exposes a count summary", () => {
|
|
|
1446
1705
|
|
|
1447
1706
|
// --- issue #177:冲突队列交互与视觉重做 ---
|
|
1448
1707
|
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1708
|
+
// 相似度进度条:优先取服务端落列的 similarity(#603 第 2 层),老行才回落到
|
|
1709
|
+
// 「相似度 X / similarity X」锚定短语(防把年份当相似度);A/B 卡片带侧色类,
|
|
1710
|
+
// 冻结徽章替代预裁决阶段的「已归档」徽章。
|
|
1451
1711
|
test("conflict queue: similarity bar, side colors and frozen badge", () => {
|
|
1452
|
-
assert.ok(clientSource.includes("similarityOf(it
|
|
1712
|
+
assert.ok(clientSource.includes("similarityOf(it)"), "the whole item must be parsed, not just reason");
|
|
1713
|
+
assert.ok(clientSource.includes("typeof it.similarity === \"number\""), "the server-side column wins over text parsing");
|
|
1453
1714
|
assert.ok(clientSource.includes("/(?:相似度|similarity)\\s*([01](?:\\.\\d+)?)/i"), "parse must anchor on the similarity phrase");
|
|
1454
1715
|
for (const key of ["memory.status.conflictQueue.similarity", "memory.status.conflictQueue.frozenBadge"]) {
|
|
1455
1716
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
@@ -1650,6 +1911,43 @@ test("mirror health: card wired with fail-closed unknown rendering", () => {
|
|
|
1650
1911
|
}
|
|
1651
1912
|
});
|
|
1652
1913
|
|
|
1914
|
+
// --- issue #275:库体积卡(容量预警,空间维度)---
|
|
1915
|
+
// 预警卡的关健不变量同样是 fail-closed:fetch 失败、老宿主没有 capacity 块、后端
|
|
1916
|
+
// status:"unknown" 三种情况都渲染「未知」,绝不显示「正常」;过线的检查逐条列出
|
|
1917
|
+
// 且值/阈值都给全(报警要能对上数字)。这里锁死派生表达式与 i18n 双语键,
|
|
1918
|
+
// 防止未来被「简化」成把未知当正常、或把过线行折成一句空话。
|
|
1919
|
+
test("capacity warning: card wired with fail-closed unknown rendering", () => {
|
|
1920
|
+
assert.ok(clientSource.includes("function CapacityCard"), "库体积卡必须存在");
|
|
1921
|
+
assert.ok(clientSource.includes("h(CapacityCard, { t })"), "overview grid must render the capacity card");
|
|
1922
|
+
// 与镜像卡同一条 /health 路径读 capacity 块(「加一个库体积卡是同一条路径」)
|
|
1923
|
+
const fetches = clientSource.split('"/api/dsh-mneme/health"').length - 1;
|
|
1924
|
+
assert.ok(fetches >= 2, "capacity card must fetch the health endpoint (mirror card + capacity card)");
|
|
1925
|
+
const unknown = clientSource.match(/const unknown = !c \|\| c\.status === "unknown";/);
|
|
1926
|
+
assert.ok(unknown, "missing block / backend unknown must collapse to unknown, never ok");
|
|
1927
|
+
// 过线行必须带 warn 标记走预警色,大数字同样(class 由 StatusCard 拼接)
|
|
1928
|
+
assert.ok(clientSource.includes('"mneme-statrow mneme-statrow--warn"'), "tripped check rows must carry the warn class");
|
|
1929
|
+
assert.ok(clientSource.includes('"mneme-statusnum mneme-statusnum--warn"'), "warned big number must carry the warn class");
|
|
1930
|
+
// 三种检查的标签显式映射(不按键名拼 i18n key,缺键时会把键名漏给用户)
|
|
1931
|
+
assert.ok(clientSource.includes('"memory.status.capacity.check.dbSize"'));
|
|
1932
|
+
assert.ok(clientSource.includes('"memory.status.capacity.check.archivedRows"'));
|
|
1933
|
+
assert.ok(clientSource.includes('"memory.status.capacity.check.archivedRatio"'));
|
|
1934
|
+
for (const key of [
|
|
1935
|
+
"memory.status.capacity",
|
|
1936
|
+
"memory.status.capacity.unknown",
|
|
1937
|
+
"memory.status.capacity.unknownFoot",
|
|
1938
|
+
"memory.status.capacity.active",
|
|
1939
|
+
"memory.status.capacity.archived",
|
|
1940
|
+
"memory.status.capacity.reclaimable",
|
|
1941
|
+
"memory.status.capacity.check.dbSize",
|
|
1942
|
+
"memory.status.capacity.check.archivedRows",
|
|
1943
|
+
"memory.status.capacity.check.archivedRatio",
|
|
1944
|
+
"memory.status.capacity.foot"
|
|
1945
|
+
]) {
|
|
1946
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1947
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1948
|
+
}
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1653
1951
|
// --- issue #386 第 1 条:独立服务只读卡片 ---
|
|
1654
1952
|
// 卡片展示 dsh-mneme-serve 示例命令并支持复制。关键锁:命令不能写成
|
|
1655
1953
|
// `npx dsh-mneme-serve …` —— bin 名与包名不同,裸 npx 会尝试安装一个不存在
|
|
@@ -2024,10 +2322,17 @@ test("distill retention switches are reachable from the panel (#399)", () => {
|
|
|
2024
2322
|
coreGroup[1].includes('"distillRecords.enabled"'),
|
|
2025
2323
|
"distillRecords.enabled must be an item of the core group, not merely referenced elsewhere"
|
|
2026
2324
|
);
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2325
|
+
// 数值清单会随新键增长(v0.13 加了 diaryWriteEveryMinutes),所以钉「这三个都在、
|
|
2326
|
+
// 且顺序不变」,不钉整行字面量——否则每加一个数值键这条锁就假红一次。
|
|
2327
|
+
const numList = clientSource.match(/const FEATURE_NUMBERS = \[([\s\S]*?)\];/);
|
|
2328
|
+
assert.ok(numList, "the numeric key list must be declared");
|
|
2329
|
+
const numKeys = (numList[1].match(/"[^"]+"/g) || []).map((k) => k.slice(1, -1));
|
|
2330
|
+
assert.deepEqual(
|
|
2331
|
+
numKeys.slice(0, 3),
|
|
2332
|
+
["distillRecords.retentionDays", "dreamMaxTokens", "sleepMaxTokens"],
|
|
2333
|
+
"retentionDays must stay in the numeric key list, after dreamMaxTokens/sleepMaxTokens (#478)"
|
|
2030
2334
|
);
|
|
2335
|
+
assert.ok(numKeys.includes("distillRecords.retentionDays"), "retentionDays must be in the numeric key list");
|
|
2031
2336
|
assert.ok(
|
|
2032
2337
|
/for \(const key of FEATURE_NUMBERS\) numDraft\[key\] = String\(e\[key\] \?\? ""\);/.test(clientSource)
|
|
2033
2338
|
&& /setNums\(numDraft\);/.test(clientSource),
|
|
@@ -2231,6 +2536,171 @@ test("right-pane tab renders a named component instead of a bare hook callback",
|
|
|
2231
2536
|
// 「仓库里还没有工作流」与「一条运行记录都没有」是两句不同的话,都得中英齐全——
|
|
2232
2537
|
// i18n 掉一边,界面上就是一行 key 裸奔。作用域那三句尤其不能缺:清单行上「项目」
|
|
2233
2538
|
// 与「全局」是唯一的来源提示,缺了它,人分不清手里这条是本项目的还是跟着插件来的。
|
|
2539
|
+
// 日记本(v0.13)的四条形状锁。为什么值得单独立锁而不是靠上面的通用闸门:
|
|
2540
|
+
// ① 它的旗标闸必须**独立**——M.O. 那条 effect 读的是 actionPipelineEnabled,
|
|
2541
|
+
// 把日记塞进同一个 then 里,关掉 M.O. 会连带关掉日记,而两者毫无关系;
|
|
2542
|
+
// ② 右栏正文同样必须是具名组件(同 PipelinePane 的理由,hook 顺序只在运行时炸);
|
|
2543
|
+
// ③ 「写成 / 今天写过 / 没素材」是三种不同结果,混成一句「成功」就是假成功;
|
|
2544
|
+
// ④ 503(没装配)与 409(没开)是两种不同的「不能用」,合并文案用户会以为日记坏了。
|
|
2545
|
+
test("diary pane: own gate, named component, and distinct outcomes", () => {
|
|
2546
|
+
assert.match(clientSource, /function DiaryPane\(/, "DiaryPane must exist");
|
|
2547
|
+
// 回调必须把「这个会话是谁」一路传进正文:project 定位素材、agent 定位人格卡。
|
|
2548
|
+
// 少传 agent 就会退回 default 卡——自动轮是带 agentId 的,两个入口写同一天
|
|
2549
|
+
// 会变成两个声音,这正是「只以这个人格的口吻写日记」没做到的那一半。
|
|
2550
|
+
assert.match(
|
|
2551
|
+
clientSource,
|
|
2552
|
+
/\}, \(props\) => \{[\s\S]{0,400}?return h\(DiaryPane, \{[\s\S]{0,200}?project: sessionProject\(ctx, sid\),[\s\S]{0,200}?agent: sessionAgent\(ctx, sid\),[\s\S]{0,200}?session: sid \|\| null[\s\S]{0,40}?\}\);\s*\}\);/,
|
|
2553
|
+
"the diary tab callback must delegate to the named DiaryPane component and pass agent + session"
|
|
2554
|
+
);
|
|
2555
|
+
assert.match(clientSource, /function sessionAgent\(ctx, sessionId\)/, "sessionAgent must exist to resolve the persona card");
|
|
2556
|
+
|
|
2557
|
+
// 从日记那段注释起算、到自己的 effect 名止:不能退 1600 字符去数,
|
|
2558
|
+
// 那会把上面 M.O. 那段(它本来就写着 actionPipelineEnabled)一起框进来。
|
|
2559
|
+
const gateHead = clientSource.indexOf("// --- v0.13 日记本(beta):独立旗标门控 ---");
|
|
2560
|
+
const gateAt = clientSource.indexOf('"dsh-mneme: diary gate"');
|
|
2561
|
+
assert.ok(gateHead > -1, "the diary gate comment must exist");
|
|
2562
|
+
assert.ok(gateAt > gateHead, "the diary must carry its own flag gate, after that comment");
|
|
2563
|
+
// 只审代码、不审注释:那段注释本身就在解释「为什么不复用 M.O. 那条」,
|
|
2564
|
+
// 会把 actionPipelineEnabled 这个字放进来说明——按注释判会把这个正确的说明当成违规。
|
|
2565
|
+
const gateBody = clientSource
|
|
2566
|
+
.slice(gateHead, gateAt)
|
|
2567
|
+
.split("\n")
|
|
2568
|
+
.filter((line) => !line.trim().startsWith("//"))
|
|
2569
|
+
.join("\n");
|
|
2570
|
+
assert.ok(gateBody.includes("eff.diaryEnabled"), "the diary gate must read its own flag");
|
|
2571
|
+
assert.ok(
|
|
2572
|
+
!gateBody.includes("actionPipelineEnabled"),
|
|
2573
|
+
"the diary gate must not ride on actionPipelineEnabled — the two features are unrelated"
|
|
2574
|
+
);
|
|
2575
|
+
|
|
2576
|
+
// 三种结果必须各有自己的分支,不能都落进同一句「成功」
|
|
2577
|
+
for (const marker of ['b.status === "written"', '"already-written"', '"no-material"']) {
|
|
2578
|
+
assert.ok(clientSource.includes(marker), "diary write outcome must be handled explicitly: " + marker);
|
|
2579
|
+
}
|
|
2580
|
+
// 503 与 409 分开成两种 gate 文案
|
|
2581
|
+
assert.ok(
|
|
2582
|
+
clientSource.includes("r.status === 503") && clientSource.includes("r.status === 409"),
|
|
2583
|
+
"diary unavailable (503) and disabled (409) must be told apart"
|
|
2584
|
+
);
|
|
2585
|
+
});
|
|
2586
|
+
|
|
2587
|
+
// 日记本重做(2026-11-09,桉桉:「像本子一样的UI风格」/「导航应该是人格名」)。
|
|
2588
|
+
// 两件事都必须由测试锁住,因为它们都只错在「看起来还行」:
|
|
2589
|
+
// ①导航拿 frontmatter 括号里的 agent 名当人格名——本机旧数据里五个桶都写着
|
|
2590
|
+
// `default (ptc)`,于是导航栏出现一排项目键加一个 ptc。正确名字只能从人格卡取。
|
|
2591
|
+
// ②纸张只存内存或只存一处,两个入口各选各的,同一本日记看起来像两个东西。
|
|
2592
|
+
test("diary book: navigation names come from persona cards, never from the agent tag", () => {
|
|
2593
|
+
const start = clientSource.indexOf("function diaryBucketName(");
|
|
2594
|
+
assert.ok(start > -1, "diaryBucketName must exist");
|
|
2595
|
+
const end = clientSource.indexOf("function DiaryLibraryPanel(");
|
|
2596
|
+
assert.ok(end > start, "diaryBucketName must sit before the panel");
|
|
2597
|
+
// 注释里本来就要出现 `persona: default (ptc)` 这个反例,只审代码行。
|
|
2598
|
+
const body = clientSource.slice(start, end).split("\n")
|
|
2599
|
+
.filter((line) => !line.trim().startsWith("//")).join("\n");
|
|
2600
|
+
const fn = new Function(body + "\n return diaryBucketName;")();
|
|
2601
|
+
const store = {
|
|
2602
|
+
default: { name: "Amarre" },
|
|
2603
|
+
byAgent: { ptc: null, studio: { name: "Studio" } },
|
|
2604
|
+
bySession: {}
|
|
2605
|
+
};
|
|
2606
|
+
// 括号里是 agent 名:必须换成人格卡的名字,不是 agent 名,也不是桶键。
|
|
2607
|
+
assert.equal(fn({ key: "amarre-c836db6a", project: "amarre-c836db6a", persona: "default (ptc)" }, store), "Amarre",
|
|
2608
|
+
"an agent tag must resolve to the default card's name, not to 'ptc'");
|
|
2609
|
+
assert.equal(fn({ key: "x-1", project: "x-1", persona: "settings (studio)" }, store), "Studio",
|
|
2610
|
+
"a named card must win over the default card");
|
|
2611
|
+
// 还没挂卡(或卡被删了)时走默认卡,这是写日记时真实的回落口径。
|
|
2612
|
+
assert.equal(fn({ key: "y-2", project: "y-2", persona: "settings (gone)" }, store), "Amarre",
|
|
2613
|
+
"a missing agent card falls back to the default card, the same way writing does");
|
|
2614
|
+
// 没有 store(人格卡还没读到)时绝不把 agent 名顶上,退回项目名。
|
|
2615
|
+
assert.equal(fn({ key: "z-3", project: "z-3", persona: "default (ptc)" }, null), "z-3",
|
|
2616
|
+
"without a card store the name must fall back to the project, never leak the agent tag");
|
|
2617
|
+
// 完全没有 persona 行的老日记:项目名,再不行桶键。
|
|
2618
|
+
assert.equal(fn({ key: "old-4", project: "老项目", persona: "" }, store), "老项目");
|
|
2619
|
+
assert.equal(fn({ key: "only-5", project: "", persona: "" }, store), "only-5");
|
|
2620
|
+
});
|
|
2621
|
+
|
|
2622
|
+
test("diary book: paper choice is one shared setting across both entries", () => {
|
|
2623
|
+
// 一个 localStorage 键、一处读、一处写:两个入口看的是同一本日记。
|
|
2624
|
+
assert.ok(clientSource.includes('const DIARY_BOOK_KEY = "dsh-mneme-diary-book"'),
|
|
2625
|
+
"the paper choice needs a single storage key");
|
|
2626
|
+
assert.equal((clientSource.match(/localStorage\.getItem\(DIARY_BOOK_KEY\)/g) || []).length, 1,
|
|
2627
|
+
"reading the paper must go through readDiaryBook only");
|
|
2628
|
+
assert.ok(clientSource.includes("function readDiaryBook()") && clientSource.includes("function writeDiaryBook("),
|
|
2629
|
+
"read and write helpers must both exist");
|
|
2630
|
+
// 右栏 DiaryPane 也要套同一套纸:只改左面板等于两处观感打架。
|
|
2631
|
+
const pane = clientSource.slice(clientSource.indexOf("function DiaryPane("), clientSource.indexOf("function DiaryLibraryPanel("));
|
|
2632
|
+
assert.ok(pane.includes("readDiaryBook"), "the right-side pane must read the same paper choice");
|
|
2633
|
+
assert.ok(/mneme-diary-paper mneme-diary-paper--" \+ book/.test(pane),
|
|
2634
|
+
"the right-side pane must apply the selected paper texture");
|
|
2635
|
+
// 同页另一处入口收不到 storage 事件(那只在别的标签页触发),必须自己广播。
|
|
2636
|
+
assert.ok(clientSource.includes('new CustomEvent("mneme:diary-book-changed")'),
|
|
2637
|
+
"a same-page change must be broadcast, storage events never fire in the same document");
|
|
2638
|
+
assert.ok(clientSource.includes('addEventListener("mneme:diary-book-changed"'),
|
|
2639
|
+
"the other entry must listen for that broadcast");
|
|
2640
|
+
// 四种纸都要有文案,否则按钮上是裸 key。
|
|
2641
|
+
for (const key of ["memory.diary.bookLabel", "memory.diary.book.plain", "memory.diary.book.ruled",
|
|
2642
|
+
"memory.diary.book.grid", "memory.diary.book.kraft"]) {
|
|
2643
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2644
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2645
|
+
}
|
|
2646
|
+
// 本子观感:正文用衬线、行高与纸纹 26px 网格对齐,否则横线会穿字。
|
|
2647
|
+
// 源文件里这段 CSS 在 JS 字符串内,引号是转义的;这里只钉字体族的骨架。
|
|
2648
|
+
assert.ok(clientSource.includes("font-family:Georgia,") && clientSource.includes("Songti SC") &&
|
|
2649
|
+
clientSource.includes("Noto Serif SC") && clientSource.includes("serif"),
|
|
2650
|
+
"the diary body needs a serif typeface to read like a notebook");
|
|
2651
|
+
assert.ok(clientSource.includes("background-size:100% 26px"),
|
|
2652
|
+
"the ruled texture must be locked to the 26px line height");
|
|
2653
|
+
});
|
|
2654
|
+
|
|
2655
|
+
// 日记本不再占侧边栏座位(2026-11-09 桉桉:还保留旧版本的侧边栏打开,这个要去掉)。
|
|
2656
|
+
// 它现在是记忆面板内的一个标签页,和人格页并列;右栏那个入口是另一回事,保留。
|
|
2657
|
+
test("diary is a panel tab now, not a sidebar seat", () => {
|
|
2658
|
+
assert.ok(!clientSource.includes('id: "dsh-mneme-diary"') && !clientSource.includes('key: "dsh-mneme-diary"'),
|
|
2659
|
+
"the sidebar seat pair must be gone");
|
|
2660
|
+
assert.ok(!clientSource.includes("disposeDiaryGlyph") && !clientSource.includes("disposeDiaryMain"),
|
|
2661
|
+
"the seat disposers must be gone too, not just left dangling");
|
|
2662
|
+
// 面板内必须有 diary 这个标签页,否则入口只剩下右栏那一个。
|
|
2663
|
+
assert.match(clientSource, /\{ key: "diary", label: t\("memory\.diary\.title"\) \}/,
|
|
2664
|
+
"the memory panel must carry the diary tab");
|
|
2665
|
+
assert.match(clientSource, /view === "diary"[\s\S]{0,200}?DiaryLibraryPanel/,
|
|
2666
|
+
"the diary tab must render the library panel");
|
|
2667
|
+
// 右栏入口照旧保留:那是会话内的快速入口,不是侧边栏座位。
|
|
2668
|
+
assert.ok(clientSource.includes("function registerDiarySeats(ctx, t) {"),
|
|
2669
|
+
"the right-sidebar entry must stay");
|
|
2670
|
+
});
|
|
2671
|
+
|
|
2672
|
+
// v0.13.0 第一次发出去时右栏页签类型注册了、正文座位也注册了,却没有任何一个
|
|
2673
|
+
// 能调 openTab 的按钮——页签永远打不开。当时的修法是往会话头塞一个入口按钮;
|
|
2674
|
+
// 2026-11-09 桉桉又要求把它撤掉:日记已经进了记忆面板当标签页,会话头顶再挂一个
|
|
2675
|
+
// 指向右栏的按钮,就是同一件事有两个入口。所以现在锁的是「没有第二个入口」。
|
|
2676
|
+
test("diary has exactly one entry: the panel tab, not a session header button", () => {
|
|
2677
|
+
const start = clientSource.indexOf("function registerDiarySeats(ctx, t) {");
|
|
2678
|
+
const end = clientSource.indexOf("function apply(ctx) {", start);
|
|
2679
|
+
assert.ok(start > -1 && end > start, "registerDiarySeats must sit before apply()");
|
|
2680
|
+
const body = clientSource.slice(start, end);
|
|
2681
|
+
|
|
2682
|
+
// ① 会话头入口必须彻底消失:座位、按钮组件、打开动作、失败文案一个都不留。
|
|
2683
|
+
assert.ok(
|
|
2684
|
+
!body.includes("conversation.session.header.actions"),
|
|
2685
|
+
"the diary must not register a second entry in the session header"
|
|
2686
|
+
);
|
|
2687
|
+
assert.ok(!body.includes("openPane"), "the openPane helper only existed for the header button");
|
|
2688
|
+
assert.ok(!body.includes("hasPane"), "hasPane only gated the header button");
|
|
2689
|
+
assert.ok(
|
|
2690
|
+
!/function DiaryEntryButton\s*\(/.test(clientSource),
|
|
2691
|
+
"the header entry component must be gone, not just unregistered"
|
|
2692
|
+
);
|
|
2693
|
+
assert.ok(
|
|
2694
|
+
!clientSource.includes("memory.diary.openFailed"),
|
|
2695
|
+
"the \"cannot open\" copy is dead once nothing can fail to open it"
|
|
2696
|
+
);
|
|
2697
|
+
|
|
2698
|
+
// ② 右栏那套照旧:页签类型 + 正文 + 标题都还在(宿主若有自己的页签选择器仍可达)。
|
|
2699
|
+
assert.ok(body.includes('ctx.get("sidebarRightTabs")'), "tab type registration stays");
|
|
2700
|
+
assert.ok(body.includes('name: "sidebar.right.pane.tab"'), "pane body stays registered");
|
|
2701
|
+
assert.ok(body.includes('name: "sidebar.right.pane.tab.title"'), "pane title stays registered");
|
|
2702
|
+
});
|
|
2703
|
+
|
|
2234
2704
|
test("M.O. empty-state, list and scope copy exist in both languages", () => {
|
|
2235
2705
|
for (const key of [
|
|
2236
2706
|
"memory.actions.empty.title",
|
|
@@ -2395,7 +2865,7 @@ test("panel copy and icons must not silently vanish (missing dict lines, unregis
|
|
|
2395
2865
|
|
|
2396
2866
|
// 人格定义页(beta)面板面回归:形状沿用 A4 的「分组 + i18n 双语 occurrences ≥ 2
|
|
2397
2867
|
// + 关键接线」断言风格(只做源码断言,不挂 React 渲染环境)。
|
|
2398
|
-
test("persona page
|
|
2868
|
+
test("persona page is a panel tab, not a settings card (beta v2)", () => {
|
|
2399
2869
|
// 开关进 FEATURE_GROUPS 的 enhance 组(行为开关非座位闸门)。按组解析成员再逐个点名,
|
|
2400
2870
|
// 而不是把某一行写成字面量正则——那样往组里加第二个键就会把这条护栏弄红(实测踩过)。
|
|
2401
2871
|
const enhance = clientSource.match(/group\.enhance", items: \[((?:[^\]]|\n)*)\]/);
|
|
@@ -2407,82 +2877,91 @@ test("persona page ships settings card, master switch and i18n (beta v1)", () =>
|
|
|
2407
2877
|
for (const key of [
|
|
2408
2878
|
"memory.features.personaEnabled",
|
|
2409
2879
|
"memory.features.personaEnabled.hint",
|
|
2410
|
-
"memory.settings.persona",
|
|
2411
|
-
"memory.settings.personaHint",
|
|
2412
|
-
"memory.settings.personaName",
|
|
2413
|
-
"memory.settings.personaPersonality",
|
|
2414
|
-
"memory.settings.personaReplyStyle",
|
|
2415
|
-
"memory.settings.personaSave",
|
|
2416
|
-
"memory.settings.personaSaved",
|
|
2417
|
-
"memory.settings.personaEnabledToggle",
|
|
2418
|
-
"memory.settings.personaView",
|
|
2419
|
-
"memory.settings.personaViewHint",
|
|
2420
|
-
"memory.settings.personaViewEmpty",
|
|
2421
|
-
// 预览改由后端渲染之后新增的读数文案(缺一种语言就是半边界面裸奔键名)
|
|
2422
2880
|
"memory.features.personaStrictAddressing",
|
|
2423
2881
|
"memory.features.personaStrictAddressing.hint",
|
|
2424
|
-
"memory.
|
|
2425
|
-
"memory.
|
|
2426
|
-
"memory.
|
|
2427
|
-
"memory.
|
|
2428
|
-
"memory.
|
|
2429
|
-
"memory.
|
|
2430
|
-
"memory.
|
|
2882
|
+
"memory.explorer.tabPersona",
|
|
2883
|
+
"memory.persona.title",
|
|
2884
|
+
"memory.persona.hint",
|
|
2885
|
+
"memory.persona.cards",
|
|
2886
|
+
"memory.persona.defaultCard",
|
|
2887
|
+
"memory.persona.name",
|
|
2888
|
+
"memory.persona.personality",
|
|
2889
|
+
"memory.persona.replyStyle",
|
|
2890
|
+
"memory.persona.scenario",
|
|
2891
|
+
"memory.persona.mesExample",
|
|
2892
|
+
"memory.persona.save",
|
|
2893
|
+
"memory.persona.saved",
|
|
2894
|
+
"memory.persona.saveFailed",
|
|
2895
|
+
"memory.persona.enabled",
|
|
2896
|
+
"memory.persona.preview",
|
|
2897
|
+
"memory.persona.previewEmpty",
|
|
2898
|
+
"memory.persona.previewUnknown",
|
|
2899
|
+
"memory.persona.diary",
|
|
2900
|
+
"memory.persona.diaryHint",
|
|
2901
|
+
"memory.persona.loading"
|
|
2431
2902
|
]) {
|
|
2432
2903
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2433
2904
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2434
2905
|
}
|
|
2435
|
-
//
|
|
2436
|
-
assert.ok(clientSource.includes('
|
|
2437
|
-
|
|
2438
|
-
assert.ok(clientSource.includes("
|
|
2439
|
-
|
|
2440
|
-
assert.ok(
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
)
|
|
2444
|
-
|
|
2445
|
-
//
|
|
2446
|
-
assert.ok(clientSource.includes("
|
|
2906
|
+
// 形态:面板自己的第五个标签页,设置页那张卡搬走(不是并存)
|
|
2907
|
+
assert.ok(clientSource.includes('{ key: "persona", label: t("memory.explorer.tabPersona") }'),
|
|
2908
|
+
"the persona view must be registered in the panel's subviews");
|
|
2909
|
+
assert.ok(clientSource.includes('view === "persona" &&'), "the panel must route a persona view branch");
|
|
2910
|
+
assert.ok(clientSource.includes("h(PersonaMainPanel, { t })"), "the persona tab must render PersonaMainPanel");
|
|
2911
|
+
assert.ok(!clientSource.includes('t("memory.settings.persona")'),
|
|
2912
|
+
"the settings card must be gone, not duplicated next to the new page");
|
|
2913
|
+
// 只读卡「Agent 眼中的你」的职能并进本页预览:它自己的键与渲染都不许残留
|
|
2914
|
+
assert.ok(!clientSource.includes("memory.settings.personaView"),
|
|
2915
|
+
"the read-only injected-view card must be removed; this page's preview owns that job");
|
|
2916
|
+
// 保存走 PUT /persona,且必须三维全量(settings.setPersona 是全量替换)
|
|
2917
|
+
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/persona"'), "persona page must talk to /persona");
|
|
2918
|
+
assert.ok(clientSource.includes('method: "PUT"'), "persona save must PUT");
|
|
2919
|
+
assert.ok(clientSource.includes("bySession"), "the store shape must carry bySession through saves");
|
|
2920
|
+
// 预览必须由后端渲染:前端再拼一份就是第二张皮
|
|
2921
|
+
assert.ok(clientSource.includes("preview.text"), "the preview must render the backend-rendered block");
|
|
2447
2922
|
assert.ok(!clientSource.includes('"- " + profile.trim()'), "frontend hand-splicing of the injected block must be gone");
|
|
2448
|
-
|
|
2923
|
+
// 读不到就说读不到:失败不许停在上一张卡的旧文本
|
|
2924
|
+
assert.ok(clientSource.includes("setPreview(null)"), "a failed preview must fail closed to 未知, not to a stale block");
|
|
2449
2925
|
// 预览只能有一个所有者,且要丢掉过期响应:先切卡 A 再切卡 B 时,A 的 GET 可能后回来。
|
|
2450
|
-
// 这张卡的标题是「注入生效文本」,显示另一张卡的文本比不显示更糟。
|
|
2451
2926
|
assert.ok(clientSource.includes("if (seq !== previewSeq.current) return;"), "preview must drop stale responses");
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
assert.ok(clientSource.includes("injectedPreview.agent"), "the fallback label must separate a resolved-but-fallen-back identity from an unresolvable one");
|
|
2927
|
+
// #514:轻量档压制要有告示,且这一页自己得说(人是在这里开关人格的)
|
|
2928
|
+
assert.ok(clientSource.includes('t("memory.status.minimalPresetTitle")'), "the page must carry the minimal-preset notice");
|
|
2455
2929
|
});
|
|
2456
2930
|
|
|
2457
|
-
|
|
2458
|
-
// aria-label 看不见)、保存失败必须冒可见错误(v1 静默 catch 吞 404 教训)。
|
|
2459
|
-
test("persona card is multi-card with visible field labels and save-failure error", () => {
|
|
2931
|
+
test("persona page is multi-card with visible field labels and save-failure error", () => {
|
|
2460
2932
|
for (const key of [
|
|
2461
|
-
"memory.
|
|
2462
|
-
"memory.
|
|
2463
|
-
"memory.
|
|
2464
|
-
"memory.
|
|
2465
|
-
"memory.
|
|
2466
|
-
"memory.
|
|
2467
|
-
"memory.
|
|
2468
|
-
"memory.
|
|
2469
|
-
"memory.
|
|
2933
|
+
"memory.persona.cards",
|
|
2934
|
+
"memory.persona.defaultCard",
|
|
2935
|
+
"memory.persona.add",
|
|
2936
|
+
"memory.persona.newPlaceholder",
|
|
2937
|
+
"memory.persona.delete",
|
|
2938
|
+
"memory.persona.saveFailed",
|
|
2939
|
+
"memory.persona.saved",
|
|
2940
|
+
"memory.persona.enabledOn",
|
|
2941
|
+
"memory.persona.enabledOff"
|
|
2470
2942
|
]) {
|
|
2471
2943
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2472
2944
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2473
2945
|
}
|
|
2474
|
-
//
|
|
2475
|
-
for (const fn of ["
|
|
2946
|
+
// 多卡接线:加卡/删卡/改字段/保存全链函数存在
|
|
2947
|
+
for (const fn of ["addCard", "removeCard", "saveEditing", "setField"]) {
|
|
2476
2948
|
assert.ok(clientSource.includes(fn), `multi-card wiring must include ${fn}`);
|
|
2477
2949
|
}
|
|
2478
|
-
//
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2950
|
+
// 可见字段标签:五处字段都走同一个 field() helper,helper 里必须是真 <label>
|
|
2951
|
+
// (v1.1 反馈:aria-label 看不见)。分别锁「字段被渲染成 label」与「builder 用 label 元素」。
|
|
2952
|
+
for (const f of ["name", "personality", "replyStyle", "scenario", "mesExample"]) {
|
|
2953
|
+
assert.ok(
|
|
2954
|
+
new RegExp('field\\("' + f + '", t\\("memory\\.persona\\.' + f + '"\\)').test(clientSource),
|
|
2955
|
+
"persona field " + f + " must go through the labeled field builder"
|
|
2956
|
+
);
|
|
2957
|
+
}
|
|
2958
|
+
assert.ok(/h\("label", \{ className: "mneme-persona-label", htmlFor:/.test(clientSource),
|
|
2959
|
+
"the field builder must render a real label element, not an aria-only name");
|
|
2960
|
+
// 保存失败提示:error 状态有渲染路径(非仅 setState)
|
|
2961
|
+
assert.ok(clientSource.includes('t("memory.persona.saveFailed")'), "save failure must render an error line");
|
|
2483
2962
|
// 卡删除 × 常显回归锁 v2(#457 内联覆写被 #459 回滚的教训):× 不得复用
|
|
2484
2963
|
// mneme-set-del(opacity:0 仅在 .mneme-set-row:hover 解除,卡标签不在该容器
|
|
2485
|
-
// → 永远不可见),必须用独立类 mneme-persona-del 且该类常显(opacity
|
|
2964
|
+
// → 永远不可见),必须用独立类 mneme-persona-del 且该类常显(opacity 不得为 0)。
|
|
2486
2965
|
assert.ok(
|
|
2487
2966
|
/className: "mneme-persona-del"/.test(clientSource),
|
|
2488
2967
|
"persona card delete × must use the dedicated always-visible class"
|
|
@@ -2492,11 +2971,12 @@ test("persona card is multi-card with visible field labels and save-failure erro
|
|
|
2492
2971
|
assert.ok(!/opacity:0/.test(delStyle[0]), "mneme-persona-del must not be transparent");
|
|
2493
2972
|
// 不许再把 mneme-set-del 挂回卡删除按钮(防 #457 式修法回归);直接锁按钮
|
|
2494
2973
|
// 的 className 值(onClick 前最近的 className 属性,注释文字不参与)。
|
|
2495
|
-
const delBtn = clientSource.match(/className: "([^"]+)"[\s\S]{0,300}?onClick: \(\) =>
|
|
2974
|
+
const delBtn = clientSource.match(/className: "([^"]+)"[\s\S]{0,300}?onClick: \(\) => removeCard\(id\)/);
|
|
2496
2975
|
assert.ok(delBtn, "card delete button wiring must exist");
|
|
2497
2976
|
assert.strictEqual(delBtn[1], "mneme-persona-del", "card delete × must use mneme-persona-del, not mneme-set-del");
|
|
2498
2977
|
});
|
|
2499
2978
|
|
|
2979
|
+
|
|
2500
2980
|
// 回归锁(复验实测踩中):面板「检测更新」的状态名必须与后端 classify
|
|
2501
2981
|
// (src/version-check.js)逐字对齐。此前前端误写 "latest"/"up_to_date"(后端
|
|
2502
2982
|
// 从未返回过),检测成功也掉进 error 兜底——按钮永远报「检测失败」。
|
|
@@ -2532,6 +3012,28 @@ test("update-check status names match the backend classify contract verbatim", (
|
|
|
2532
3012
|
|
|
2533
3013
|
// 画像提取草稿(#497)面板面:路由对齐、四种结局各有自己的话、按钮坐在哪张卡上、
|
|
2534
3014
|
// 以及草稿**只出副本**(2026-11-04 改口径:不再把草稿写进画像字段)。
|
|
3015
|
+
// 严格寻址的「第一张卡」陷阱(Persis 复验):live 库 personaStrictAddressing=true,
|
|
3016
|
+
// 现在 byAgent/bySession 都空,走 #587 例外回落 default,所以注入正常。但只要在这个
|
|
3017
|
+
// 页面建出第一张卡,anyCard 就变 true,strict 立刻生效——所有没建卡的会话(含主会话)
|
|
3018
|
+
// 人格静默不再注入,界面一个字都不报。这种「改一个开关,别处的行为跟着变且无提示」
|
|
3019
|
+
// 必须在建卡之前就写在脸上,所以锁三件事:①两个阶段的文案都在;②按现有卡数选话;
|
|
3020
|
+
// ③文案确实渲染进人格页,而不是定义完没人读。
|
|
3021
|
+
test("strict addressing warns before the first card quietly changes everyone else", () => {
|
|
3022
|
+
for (const key of ["memory.persona.strictBefore", "memory.persona.strictAfter"]) {
|
|
3023
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
3024
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
3025
|
+
}
|
|
3026
|
+
// 生效值来自 /features 的真实快照,不许前端猜默认
|
|
3027
|
+
assert.ok(/personaStrictAddressing === true/.test(clientSource),
|
|
3028
|
+
"the guardrail must read the effective flag from /features, not assume a default");
|
|
3029
|
+
// 两条话按「现在有几张卡」分流:没有卡时说「建第一张会变严」,有了之后说「已经变严」
|
|
3030
|
+
assert.ok(clientSource.includes('t(cardCount > 0 ? "memory.persona.strictAfter" : "memory.persona.strictBefore")'),
|
|
3031
|
+
"the notice must pick its wording from the current card count");
|
|
3032
|
+
// 只有严格寻址真的开着才说,否则是假警报
|
|
3033
|
+
assert.ok(/strict \? h\("div"/.test(clientSource),
|
|
3034
|
+
"the notice must be gated on the effective strict flag");
|
|
3035
|
+
});
|
|
3036
|
+
|
|
2535
3037
|
test("profile extraction ships its button, distinct outcomes and correct save wiring", () => {
|
|
2536
3038
|
const apiSource = readFileSync(join(root, "src/api.js"), "utf8");
|
|
2537
3039
|
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/profile/extract"'),
|
|
@@ -2567,17 +3069,22 @@ test("profile extraction ships its button, distinct outcomes and correct save wi
|
|
|
2567
3069
|
assert.ok(/group\.enhance[\s\S]{0,700}"profileExtractEnabled"/.test(clientSource),
|
|
2568
3070
|
"FEATURE_GROUPS group.enhance must carry profileExtractEnabled");
|
|
2569
3071
|
|
|
2570
|
-
// 位置锁(2026-11-
|
|
2571
|
-
//
|
|
2572
|
-
//
|
|
2573
|
-
|
|
2574
|
-
const
|
|
2575
|
-
const
|
|
2576
|
-
assert.ok(
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
3072
|
+
// 位置锁(2026-11-09 再改口径):提取按钮属于「用户画像」卡,而画像卡本身
|
|
3073
|
+
// 已经从设置页搬进记忆面板的「人格」页(桉桉:mneme 设置里面的个性化还没移走)。
|
|
3074
|
+
// 两处都在同一份客户端里,光比先后会被设置页的规则卡骗过——必须先切出
|
|
3075
|
+
// 人格页那一段再比,并额外断言设置页里不再有画像卡(搬回去就红)。
|
|
3076
|
+
const personaStart = clientSource.indexOf("function PersonaMainPanel(");
|
|
3077
|
+
const settingsStart = clientSource.indexOf("function SettingsContent(");
|
|
3078
|
+
assert.ok(settingsStart > 0 && personaStart > settingsStart, "both panels must exist");
|
|
3079
|
+
const settingsSlice = clientSource.slice(settingsStart, personaStart);
|
|
3080
|
+
const personaSlice = clientSource.slice(personaStart);
|
|
3081
|
+
const profileTitle = 'h("div", { className: "mneme-set-title" }, t("memory.settings.profile"))';
|
|
3082
|
+
assert.ok(!settingsSlice.includes(profileTitle),
|
|
3083
|
+
"the profile card must be gone from the settings page (it moved into the persona page)");
|
|
3084
|
+
const profileCardIdx = personaSlice.indexOf(profileTitle);
|
|
3085
|
+
const extractButtonIdx = personaSlice.indexOf('t("memory.settings.profileExtract")');
|
|
3086
|
+
assert.ok(profileCardIdx > 0 && extractButtonIdx > profileCardIdx,
|
|
3087
|
+
"the extract button must render inside the user-profile card on the persona page");
|
|
2581
3088
|
// 只出副本(2026-11-04 桉桉定的口径):草稿不许再走落库路径。此前 applyProfileDraft 会
|
|
2582
3089
|
// 直接 PUT /profile —— 把用户手写的那份覆盖掉,且没有撤销。
|
|
2583
3090
|
assert.ok(clientSource.includes("function copyProfileDraft()"), "the draft action must be a copy, not a save");
|
|
@@ -2651,29 +3158,41 @@ test("profile facts card ships management UI, run outcomes and never touches the
|
|
|
2651
3158
|
assert.ok(/group\.enhance[\s\S]{0,1200}"profileFactsEnabled"/.test(clientSource),
|
|
2652
3159
|
"FEATURE_GROUPS group.enhance must carry profileFactsEnabled");
|
|
2653
3160
|
|
|
2654
|
-
//
|
|
2655
|
-
//
|
|
2656
|
-
const
|
|
2657
|
-
const
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
3161
|
+
// 位置锁(2026-11-09 改口径):画像卡与事实卡一起搬进了人格页,事实卡仍是画像卡
|
|
3162
|
+
// 之后的一张。切段比较,防止设置页的规则卡把顺序骗过去;设置页里两张卡都不许再有。
|
|
3163
|
+
const personaStart = clientSource.indexOf("function PersonaMainPanel(");
|
|
3164
|
+
const settingsStart = clientSource.indexOf("function SettingsContent(");
|
|
3165
|
+
assert.ok(settingsStart > 0 && personaStart > settingsStart, "both panels must exist");
|
|
3166
|
+
const settingsSlice = clientSource.slice(settingsStart, personaStart);
|
|
3167
|
+
const personaSlice = clientSource.slice(personaStart);
|
|
3168
|
+
assert.ok(!settingsSlice.includes('t("memory.settings.factsTitle")'),
|
|
3169
|
+
"the facts card must be gone from the settings page (it moved into the persona page)");
|
|
3170
|
+
assert.ok(!settingsSlice.includes('h("div", { className: "mneme-set-title" }, t("memory.settings.profile"))'),
|
|
3171
|
+
"the profile card must be gone from the settings page (it moved into the persona page)");
|
|
3172
|
+
const profileCardIdx = personaSlice.indexOf('h("div", { className: "mneme-set-title" }, t("memory.settings.profile"))');
|
|
3173
|
+
const factsCardIdx = personaSlice.indexOf('t("memory.settings.factsTitle")');
|
|
3174
|
+
assert.ok(profileCardIdx > 0 && factsCardIdx > profileCardIdx,
|
|
3175
|
+
"the facts card must sit after the user-profile card on the persona page");
|
|
2662
3176
|
// 手填画像神圣:事实域的全部面板代码(loadFacts 起到事实卡渲染止)里不许出现
|
|
2663
3177
|
// PUT /profile 的路径字面量。断言的是**带闭引号**的精确串——/profile-facts/* 的
|
|
2664
3178
|
// 路径都以 /profile 开头,不带闭引号的话这个断言会自己误伤自己。
|
|
2665
|
-
|
|
2666
|
-
|
|
3179
|
+
// 两个下标必须在同一个坐标系里比:factsCardIdx 是相对 personaSlice 的,
|
|
3180
|
+
// 而 handlersStart 相对整份源码——混用会切出空片,让下面每条断言都空跑。
|
|
3181
|
+
const handlersStart = personaSlice.indexOf("const loadFacts");
|
|
3182
|
+
const factsSlice = personaSlice.slice(handlersStart, factsCardIdx);
|
|
2667
3183
|
assert.ok(handlersStart > 0 && factsSlice.length > 0, "the facts handlers must exist");
|
|
2668
3184
|
assert.ok(!factsSlice.includes('"/api/dsh-mneme/profile"'),
|
|
2669
3185
|
"the facts domain must never write the hand-filled profile (PUT /profile is off-limits here)");
|
|
2670
3186
|
for (const fragment of ["/api/dsh-mneme/profile-facts?agent=", "/api/dsh-mneme/profile-facts/apply", "/api/dsh-mneme/profile-facts/run"]) {
|
|
2671
3187
|
assert.ok(factsSlice.includes(fragment), `the facts handlers must call ${fragment}`);
|
|
2672
3188
|
}
|
|
2673
|
-
// 人工 apply
|
|
2674
|
-
//
|
|
2675
|
-
|
|
2676
|
-
|
|
3189
|
+
// 人工 apply 成功后注入预览必须跟着刷新。2026-11-09 起画像卡与事实卡同在人格页,
|
|
3190
|
+
// 不再跨视图写别人的状态——事实卡仍然不直接调 loadPreview,刷新走人格页自己的
|
|
3191
|
+
// tick 哨兵;所以锁两条:①事实处理段不直接碰 loadPreview;②页面有 tick 刷新。
|
|
3192
|
+
assert.ok(!factsSlice.includes("loadPreview("),
|
|
3193
|
+
"the facts card must not write the persona view's preview state from another view");
|
|
3194
|
+
assert.ok(clientSource.includes("setPreviewTick((n) => n + 1)"),
|
|
3195
|
+
"the persona page must refetch its own preview after a save");
|
|
2677
3196
|
// 总闸快照要跟着功能开关走:FeaturesCard 即改即存并广播 mneme:features-changed,
|
|
2678
3197
|
// 事实卡监听重读——不然按提示开了「自动事实」,按钮还得关面板重开才解禁
|
|
2679
3198
|
// (评审 P2:快照 effect 是 [] 依赖,监听缺失等于回到「重开才生效」的老病)。
|
|
@@ -2905,6 +3424,52 @@ test("minimal preset notice reaches the settings page, not just the status tab (
|
|
|
2905
3424
|
assert.ok(!/minimalWarn\s*=\s*true/.test(clientSource), "the notice must never be hard-wired on");
|
|
2906
3425
|
});
|
|
2907
3426
|
|
|
3427
|
+
// 上面那条只证明"声明字符串存在",证明不了"每个消费点的作用域里有它"。
|
|
3428
|
+
// 人格页搬进 SettingsContent 之后,运行模式卡这一处读的是自由变量:渲染即 ReferenceError,
|
|
3429
|
+
// 而宿主只记一句 "import failed: client-modules: could not load @modusensus/dsh-mneme",
|
|
3430
|
+
// 看起来像整个插件加载不了,实际是一个 JSX 分支炸了(本机 crash-*-web-boot.log 实录)。
|
|
3431
|
+
// 不变量是"每个消费点的作用域里有声明",不是"声明数量等于消费点数量":
|
|
3432
|
+
// 同一个组件消费两次(SettingsContent 里两张卡各一处)只需要一份声明,按数量判会把正确代码钉红。
|
|
3433
|
+
test("each minimalWarn consumer resolves the declaration inside its own component", () => {
|
|
3434
|
+
const lines = clientSource.split("\n");
|
|
3435
|
+
const components = [];
|
|
3436
|
+
lines.forEach((line, i) => {
|
|
3437
|
+
const m = /^(?: | )(?:function |const )([A-Z]\w*)/.exec(line);
|
|
3438
|
+
if (m) components.push({ line: i + 1, name: m[1] });
|
|
3439
|
+
});
|
|
3440
|
+
const owner = function (at) {
|
|
3441
|
+
let found = null;
|
|
3442
|
+
for (const c of components) {
|
|
3443
|
+
if (c.line <= at) found = c;
|
|
3444
|
+
else break;
|
|
3445
|
+
}
|
|
3446
|
+
return found;
|
|
3447
|
+
};
|
|
3448
|
+
assert.ok(components.length > 0, "component scan matched nothing — bundle layout changed; fix this lock, don't delete it");
|
|
3449
|
+
|
|
3450
|
+
const decls = [];
|
|
3451
|
+
const uses = [];
|
|
3452
|
+
lines.forEach((line, i) => {
|
|
3453
|
+
if (!line.includes("minimalWarn") || line.trim().startsWith("//")) return;
|
|
3454
|
+
(/const minimalWarn\s*=/.test(line) ? decls : uses).push(i + 1);
|
|
3455
|
+
});
|
|
3456
|
+
assert.ok(uses.length >= 2, `expected the notice in both surfaces (got ${uses.length} use sites)`);
|
|
3457
|
+
|
|
3458
|
+
const byComponent = new Map();
|
|
3459
|
+
for (const d of decls) {
|
|
3460
|
+
const name = owner(d).name;
|
|
3461
|
+
if (!byComponent.has(name)) byComponent.set(name, []);
|
|
3462
|
+
byComponent.get(name).push(d);
|
|
3463
|
+
}
|
|
3464
|
+
for (const u of uses) {
|
|
3465
|
+
const c = owner(u);
|
|
3466
|
+
const inScope = (byComponent.get(c.name) || []).filter((d) => d < u);
|
|
3467
|
+
assert.ok(inScope.length > 0,
|
|
3468
|
+
`line ${u} reads minimalWarn inside ${c.name} (opens at ${c.line}) which has no declaration before it — ` +
|
|
3469
|
+
"renders as ReferenceError and the host reports only \"import failed\"");
|
|
3470
|
+
}
|
|
3471
|
+
});
|
|
3472
|
+
|
|
2908
3473
|
// --- 移动端适配(mobile-adapt 车道)---
|
|
2909
3474
|
// 面板在 320–430px 容器里原本是「骨架级失衡」而不是「略挤」:记忆页左栏固定
|
|
2910
3475
|
// 216px、卡片最小轨道 252+48=300px,要 ≥517px 才排得下;实体页 rail 224px +
|
|
@@ -3204,27 +3769,21 @@ test("panel exposes the bootstrap entry and the config-key coverage card (issue
|
|
|
3204
3769
|
);
|
|
3205
3770
|
// 菜单按钮的 onClick 里必须也关掉它,否则点「⋯」会留下上一个弹层。但它现在带一个
|
|
3206
3771
|
// 条件:引导请求在飞时不关(否则等于绕过弹层里那两道 busy 禁用的锁,评审
|
|
3207
|
-
// #4452245292
|
|
3208
|
-
|
|
3209
|
-
/setImportOpen\(false\);\s*\n\s*\/\/[\s\S]{0,200}?if \(!bootstrapBusy\) setBootstrapOpen\(false\);/.test(clientSource),
|
|
3210
|
-
"the overflow toggle must close the bootstrap dialog when it is not busy"
|
|
3211
|
-
);
|
|
3212
|
-
// 父组件必须真的把这个标志接下来,否则上面那句恒为真(undefined → !undefined)。
|
|
3772
|
+
// #4452245292)。#558 起关闭同时把任务态回 idle——用户看得见这次关闭,terminal
|
|
3773
|
+
// 状态不留在 store 里(重开弹层要干净,#558 只保留没人看过的那份)。
|
|
3213
3774
|
assert.ok(
|
|
3214
|
-
/
|
|
3215
|
-
"the
|
|
3775
|
+
/setImportOpen\(false\);\s*\n\s*\/\/[\s\S]{0,300}?if \(!bootstrapBusy\) \{ resetBootstrapJob\(\); setBootstrapOpen\(false\); \}/.test(clientSource),
|
|
3776
|
+
"the overflow toggle must close the bootstrap dialog (and reset the job) when it is not busy"
|
|
3216
3777
|
);
|
|
3778
|
+
// 父组件的 busy 守卫必须真的有来源:#558 起它订阅模块级 bootstrapJob 派生
|
|
3779
|
+
// (prop 钻孔链拆了——tab 注册的 explorer 没有 onBusyChange,原本就报不上来)。
|
|
3217
3780
|
assert.ok(
|
|
3218
|
-
/
|
|
3219
|
-
"the
|
|
3781
|
+
/const bootstrapBusy = bootstrapJob\.phase === "running"/.test(clientSource),
|
|
3782
|
+
"the parent derives bootstrapBusy from the module-level job, or the guard above means nothing"
|
|
3220
3783
|
);
|
|
3221
|
-
// 这个转发函数必须同步调外层、且身份稳定:走 useEffect 转报的话要等渲染提交后
|
|
3222
|
-
// 才跑,中间那一拍外层读到 false(#4452539705 的老毛病);而若每渲染新建一个,
|
|
3223
|
-
// BootstrapDialog 的卸载 effect(依赖 [onBusyChange])会在父组件每次重渲染时
|
|
3224
|
-
// 先跑 cleanup 调一次 onBusyChange(false)——而父组件正是在置 busy 之后重渲染的。
|
|
3225
3784
|
assert.ok(
|
|
3226
|
-
/
|
|
3227
|
-
"the
|
|
3785
|
+
/function BootstrapDialog\(\{ t, onClose, onImported \}\)/.test(clientSource),
|
|
3786
|
+
"the dialog no longer takes onBusyChange: busy lives in the store, not in prop drilling"
|
|
3228
3787
|
);
|
|
3229
3788
|
// 会挂弹层的菜单项在忙碌时必须禁用(评审 #4452539738):新建 / 导入 / 引导 / 分享 /
|
|
3230
3789
|
// 接收共五处,加上「⋯」按钮自己(见下),再加上四个视图标签(评审 #4453639221,
|
|
@@ -3267,12 +3826,13 @@ test("panel exposes the bootstrap entry and the config-key coverage card (issue
|
|
|
3267
3826
|
const bsEnd = clientSource.indexOf("// --- 分享包弹层 ---", bsIdx);
|
|
3268
3827
|
const bsBody = clientSource.slice(bsIdx, bsEnd > bsIdx ? bsEnd : bsIdx + 4200);
|
|
3269
3828
|
assert.ok(bsIdx > 0, "BootstrapDialog must exist");
|
|
3270
|
-
//
|
|
3271
|
-
//
|
|
3272
|
-
//
|
|
3829
|
+
// 任务态必须**同步**进 store(评审 #4452539705 的结构化解法):#558 起三处守卫
|
|
3830
|
+
// (explorer 的「⋯」、overlay 的 Esc/背板/✕)都订阅同一份 bootstrapJob,submit
|
|
3831
|
+
// 里 setBootstrapJob(running) 一落,监听者同步被唤醒——不再有「等渲染提交后才
|
|
3832
|
+
// 追上」的那一拍。
|
|
3273
3833
|
assert.ok(
|
|
3274
|
-
/
|
|
3275
|
-
"
|
|
3834
|
+
/setBootstrapJob\(\{ phase: "running"[\s\S]{0,200}?apiFetch\("\/api\/dsh-mneme\/bootstrap"/.test(bsBody),
|
|
3835
|
+
"running must hit the store synchronously before the fetch leaves"
|
|
3276
3836
|
);
|
|
3277
3837
|
assert.ok(
|
|
3278
3838
|
/apiFetch\("\/api\/dsh-mneme\/bootstrap"/.test(bsBody),
|
|
@@ -3289,14 +3849,16 @@ test("panel exposes the bootstrap entry and the config-key coverage card (issue
|
|
|
3289
3849
|
/type: "text",[\s\S]{0,400}?disabled: busy,[\s\S]{0,400}?onChange: \(e\) => \{ setDir/.test(bsBody),
|
|
3290
3850
|
"the dir input must be disabled while the request is in flight"
|
|
3291
3851
|
);
|
|
3292
|
-
//
|
|
3293
|
-
//
|
|
3294
|
-
//
|
|
3852
|
+
// 成功路径把 done 写进 store 并保持 900ms 阅读窗(fresh done 期间提交钮锁住),
|
|
3853
|
+
// 且定时器句柄要能清:否则第二次提交的结果会写进已经卸载的组件(评审
|
|
3854
|
+
// #4452245256)。断言窗口取「onImported…到 setTimeout」之间——再往后就是 catch
|
|
3855
|
+
// 的网络错误兜底,那一处写 failed 是对的。
|
|
3295
3856
|
const doneIdx = bsBody.indexOf("memory.explorer.bootstrapDone");
|
|
3296
3857
|
const timerIdx = bsBody.indexOf("timerRef.current = setTimeout(");
|
|
3297
3858
|
assert.ok(timerIdx > doneIdx, "the success branch must schedule its close through the stored ref");
|
|
3298
3859
|
const successTail = bsBody.slice(doneIdx, timerIdx);
|
|
3299
|
-
assert.ok(
|
|
3860
|
+
assert.ok(/phase: "done"/.test(successTail), "the success path must land done in the store (survives tab close)");
|
|
3861
|
+
assert.ok(!/resetBootstrapJob\(\)/.test(successTail), "the success path must not reset the job during the reading window");
|
|
3300
3862
|
assert.ok(/clearTimeout\(timerRef\.current\)/.test(bsBody), "the stored timer must actually be cleared on unmount");
|
|
3301
3863
|
// 刷新必须同步调、不能排进定时器(评审 #4452539711):切视图会卸载本组件并把
|
|
3302
3864
|
// timerRef 清掉,若刷新也挂在那 900ms 后面,它就静默丢了——库已经写进去了,
|
|
@@ -3314,8 +3876,8 @@ test("panel exposes the bootstrap entry and the config-key coverage card (issue
|
|
|
3314
3876
|
// 错误按服务端的码分开说(missing-dir / dir-not-found / not-a-directory /
|
|
3315
3877
|
// bootstrap-unavailable),不能一律「引导失败」——三种情况下用户该做的事不同。
|
|
3316
3878
|
assert.ok(
|
|
3317
|
-
/"memory\.explorer\.bootstrapErr\." \+ String\(
|
|
3318
|
-
"error codes must be mapped to distinct messages"
|
|
3879
|
+
/"memory\.explorer\.bootstrapErr\." \+ String\(job\.error/.test(bsBody),
|
|
3880
|
+
"error codes must be mapped to distinct messages (code lives in the store, derived at render)"
|
|
3319
3881
|
);
|
|
3320
3882
|
for (const key of [
|
|
3321
3883
|
"memory.explorer.bootstrap",
|
|
@@ -3335,6 +3897,7 @@ test("panel exposes the bootstrap entry and the config-key coverage card (issue
|
|
|
3335
3897
|
`i18n key ${key} must exist in both dictionaries`
|
|
3336
3898
|
);
|
|
3337
3899
|
}
|
|
3900
|
+
// #558 的存活规则锁(在原测试内追加):见文件尾的 issue #558 专项测试。
|
|
3338
3901
|
|
|
3339
3902
|
// 第 7 条:配置键覆盖面只读卡。
|
|
3340
3903
|
const ckIdx = clientSource.indexOf("function ConfigKeysCard");
|
|
@@ -3395,14 +3958,16 @@ test("the memory overlay refuses to close while a library request is in flight (
|
|
|
3395
3958
|
const ovEnd = clientSource.indexOf("const EXPLORER_TYPES", ovIdx);
|
|
3396
3959
|
const ovBody = clientSource.slice(ovIdx, ovEnd > ovIdx ? ovEnd : ovIdx + 2600);
|
|
3397
3960
|
|
|
3398
|
-
//
|
|
3961
|
+
// 忙碌标志必须与 Explorer 同源:#558 起两处各自订阅模块级 bootstrapJob——
|
|
3962
|
+
// overlay 不再依赖子组件回报(tab 注册的 explorer 没有 onBusyChange,原本就
|
|
3963
|
+
// 报不上来,守卫对那条路径是空的)。
|
|
3399
3964
|
assert.ok(
|
|
3400
|
-
/
|
|
3401
|
-
"the overlay
|
|
3965
|
+
/useBootstrapJob\(\)/.test(ovBody) && /explorerBusy = bootstrapJob\.phase === "running"/.test(ovBody),
|
|
3966
|
+
"the overlay derives its busy flag from the module-level job store"
|
|
3402
3967
|
);
|
|
3403
3968
|
assert.ok(
|
|
3404
|
-
/h\(MemoryExplorer, \{ t
|
|
3405
|
-
"the explorer
|
|
3969
|
+
/h\(MemoryExplorer, \{ t \}\)/.test(ovBody),
|
|
3970
|
+
"the explorer no longer reports busy up: both sides subscribe to the same store"
|
|
3406
3971
|
);
|
|
3407
3972
|
// 用 ref 读、不把 busy 列进那个 effect 的依赖:effect 每次重跑都会
|
|
3408
3973
|
// closeBtnRef.current.focus(),请求一起一落就把正在输目录的用户踢出输入框。
|
|
@@ -3557,3 +4122,63 @@ test("the entity rail counts the whole library, not the newest slice", () => {
|
|
|
3557
4122
|
"the notice needs its own dim style on the semantic colour layer"
|
|
3558
4123
|
);
|
|
3559
4124
|
});
|
|
4125
|
+
|
|
4126
|
+
// --- issue #622 可观测性:错峰时段串解析失败的面板提示 ---
|
|
4127
|
+
// parsePeakSpec 的失败语义是「整串视为未配置」(排程是省钱手段,不救错——这个契约
|
|
4128
|
+
// 不动)。但静默意味着用户写错格式的唯一症状是「功能悄悄没生效」。本批的可见性分
|
|
4129
|
+
// 工:校验在服务端(/features 的 invalidPeakHours,parsePeakSpec 只此一份实现),
|
|
4130
|
+
// 面板只渲染提示行——独立 bundle 复制一份解析器必然漂移,这里锁死这个分工不被
|
|
4131
|
+
// 「优化」成面板自己解析。
|
|
4132
|
+
test("peak hours observability: invalid-spec hint renders from server field, never parses locally", () => {
|
|
4133
|
+
// 两个键的提示行都接上了(蒸馏侧与巩固侧各一处)
|
|
4134
|
+
const uses = clientSource.split("peakInvalidHint(").length - 1; // 两处调用(定义处是 "= (key)",不匹配)
|
|
4135
|
+
assert.ok(uses >= 2, `peakInvalidHint must be wired for both peak rows (got ${uses})`);
|
|
4136
|
+
assert.ok(clientSource.includes('peakInvalidHint("summarizePeakHours")'), "distill-side row must carry the hint");
|
|
4137
|
+
assert.ok(clientSource.includes('peakInvalidHint("dreamPeakHours")'), "dream-side row must carry the hint");
|
|
4138
|
+
// 数据源是服务端字段,不是面板本地解析
|
|
4139
|
+
assert.ok(clientSource.includes("state?.invalidPeakHours"), "hint must read the server-computed field");
|
|
4140
|
+
// 反拷贝锁匹配**调用形态**而非名字——注释里提到解析器名字是允许的,调用不许有
|
|
4141
|
+
assert.ok(!/parsePeakSpec\(|invalidPeakSpecs\(/.test(clientSource), "panel must not copy the parser (single implementation stays in src/peak-hours.js)");
|
|
4142
|
+
// i18n 双语键成对出现
|
|
4143
|
+
for (const key of [
|
|
4144
|
+
"memory.features.summarizePeakHours.invalid",
|
|
4145
|
+
"memory.features.dreamPeakHours.invalid"
|
|
4146
|
+
]) {
|
|
4147
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
4148
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
4149
|
+
}
|
|
4150
|
+
});
|
|
4151
|
+
|
|
4152
|
+
// --- issue #558:引导任务在飞态活得比标签页体长 ---
|
|
4153
|
+
// better-sidebar 关 tab 会把 MemoryExplorer 与引导弹层整个卸载(keepMounted 覆盖
|
|
4154
|
+
// 隐藏与切视图、不含关闭)。POST 照样跑完、行照样落库,但旧实现的成功/失败提示
|
|
4155
|
+
// 写进已卸载组件的 setState(无处显示)、忙碌态也丢——重开还能再提交一次。锁四
|
|
4156
|
+
// 件事:状态住模块级 store;弹层是 store 的视图(busy/msg/err 派生而非本地 state);
|
|
4157
|
+
// 只有「人看得见的关闭」才把 terminal 态回 idle;卸载发生在 running 时的那一份
|
|
4158
|
+
// 留给重开回放(本 issue 修的就是它)。
|
|
4159
|
+
test("bootstrap job outlives the tab body: store in module scope, replay on remount (issue #558)", () => {
|
|
4160
|
+
// store 是模块级的(apply 作用域、所有面板组件共享),带订阅者集合与读写口
|
|
4161
|
+
assert.ok(/const bootstrapJob = \{ phase: "idle"/.test(clientSource), "the job store must be module-level, not component state");
|
|
4162
|
+
assert.ok(/function setBootstrapJob\(/.test(clientSource) && /function useBootstrapJob\(/.test(clientSource), "store needs its setter and subscription hook");
|
|
4163
|
+
// 弹层从 store 派生三态;重放挂载时输入框播种上次的目录(重试同目录是最常见的下一步)
|
|
4164
|
+
const bs558Idx = clientSource.indexOf("function BootstrapDialog");
|
|
4165
|
+
const bs558End = clientSource.indexOf("// --- 分享包弹层 ---", bs558Idx);
|
|
4166
|
+
const bs558Body = clientSource.slice(bs558Idx, bs558End > bs558Idx ? bs558End : bs558Idx + 5000);
|
|
4167
|
+
assert.ok(/const job = useBootstrapJob\(\)/.test(bs558Body), "the dialog must be a view over the job store");
|
|
4168
|
+
assert.ok(/const busy = job\.phase === "running"/.test(bs558Body), "busy derives from the store");
|
|
4169
|
+
assert.ok(/useState\(job\.dir \|\| ""\)/.test(bs558Body), "the dir input seeds from the last job's dir on replay");
|
|
4170
|
+
// 存活规则:卸载发生在 running 时 → 结果留给重开;非 running 卸载 → 回 idle
|
|
4171
|
+
// (保持「重开是干净弹层」的既有行为)。
|
|
4172
|
+
assert.ok(
|
|
4173
|
+
/useEffect\(\(\) => \(\) => \{\s*\n\s*if \(bootstrapJob\.phase !== "running"\) resetBootstrapJob\(\);/.test(clientSource),
|
|
4174
|
+
"unmount keeps a running job for replay and resets a seen terminal one"
|
|
4175
|
+
);
|
|
4176
|
+
// fresh done 的 900ms 阅读窗锁提交钮,但不锁取消(done 是 store 态、可能被重开
|
|
4177
|
+
// 回放——取消若也锁死,旧结果会把弹层永久焊住)。
|
|
4178
|
+
assert.ok(/const freshDone = job\.phase === "done" && timerRef\.current !== null/.test(bs558Body), "the reading window must be distinguished from a replayed result");
|
|
4179
|
+
assert.ok(/disabled: submitBlocked, onClick: submit/.test(bs558Body), "submit is locked during the fresh-done window");
|
|
4180
|
+
assert.ok(/disabled: busy, onClick: onClose/.test(bs558Body), "cancel stays available for a replayed result");
|
|
4181
|
+
// terminal 化只写 store(组件可能已被 tab 关闭卸载——那正是要修的路径)
|
|
4182
|
+
assert.ok(/setBootstrapJob\(\{ phase: "failed", error:/.test(bs558Body), "failure must land in the store, not in unmounted component state");
|
|
4183
|
+
assert.ok(/setBootstrapJob\(\{ phase: "done", result:/.test(bs558Body), "success must land in the store too");
|
|
4184
|
+
});
|