@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.1
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 +51 -20
- package/bin/dsh-mneme-choro.mjs +9 -1
- package/docs/CONFIGURATION.md +47 -6
- 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 +788 -30
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +4627 -1409
- package/lib/config.js +166 -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 +95 -6
- package/lib/dream/sleep.js +760 -19
- package/lib/dream/triage.js +139 -0
- package/lib/dream.js +168 -21
- 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 +22 -11
- package/lib/index.js +123 -3
- package/lib/inject.js +203 -20
- 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 +98 -4
- package/lib/peak-hours.js +57 -2
- package/lib/semantic.js +347 -60
- package/lib/service.js +268 -69
- package/lib/settings.js +128 -15
- package/lib/store.js +585 -26
- 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/claims.js +267 -0
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +282 -2
- package/lib/wiki/render.js +74 -0
- package/lib/wiki/t0.js +43 -6
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +145 -32
- package/package.json +39 -1
- package/scripts/release-docs.mjs +7 -1
- package/scripts/release-notes.mjs +4 -1
- package/src/api-standalone.js +4 -1
- package/src/api.js +788 -30
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +166 -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 +95 -6
- package/src/dream/sleep.js +760 -19
- package/src/dream/triage.js +139 -0
- package/src/dream.js +168 -21
- 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 +22 -11
- package/src/index.js +123 -3
- package/src/inject.js +203 -20
- 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 +98 -4
- package/src/peak-hours.js +57 -2
- package/src/semantic.js +347 -60
- package/src/service.js +268 -69
- package/src/settings.js +128 -15
- package/src/store.js +585 -26
- 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/claims.js +267 -0
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +282 -2
- package/src/wiki/render.js +74 -0
- package/src/wiki/t0.js +43 -6
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +145 -32
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +385 -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 +1051 -185
- package/test/conflict-freeze.test.js +45 -1
- package/test/conflict-ledger-enforce.test.js +452 -0
- package/test/conflict-ledger.test.js +242 -0
- package/test/continuity.test.js +8 -1
- package/test/contrast.test.js +124 -12
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/dream-baseline-clamp.test.js +174 -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/graph-api.test.js +124 -0
- package/test/hot-memory.test.js +48 -0
- package/test/image-memory.test.js +171 -0
- package/test/inject-parent-gate.test.js +6 -3
- package/test/inject.test.js +59 -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/panel-chart-scale.test.js +90 -0
- package/test/panel-tokens.test.js +245 -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/release-docs.test.js +20 -0
- package/test/release-notes.test.js +25 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/security-doc.test.js +10 -0
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +635 -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 +463 -1
- package/test/wiki-claims.test.js +194 -0
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-provenance.test.js +202 -0
- package/test/wiki-queries.test.js +128 -0
- package/test/wiki-t0.test.js +77 -1
- package/test/wiki-t1-jobs.test.js +125 -0
- package/test/wiki-t1.test.js +226 -1
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,265 @@ 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
|
+
// 证据复核(Grounded Claims):用 git diff 判「哪条结论被改动碰到了」。
|
|
259
|
+
// 这条锁的是三件在屏幕上看不见但一错就骗人的事:
|
|
260
|
+
// · 三种结局(干净 / 基线找不到 / 比对失败)必须走三条分支,不能并成一句;
|
|
261
|
+
// · 四种命中原因各自有文案,尤其 touched 与 shifted 不许混成一个「失效」;
|
|
262
|
+
// · 「没算」不许长得像「没事」——#607 的病根就是把没做成说成做成了。
|
|
263
|
+
test("claim evidence check reports three distinct outcomes instead of one reassuring blob", () => {
|
|
264
|
+
const fn = sliceComponent("WikiMainPanel");
|
|
265
|
+
assert.ok(fn.includes("/api/dsh-mneme/choro/claims?repo="), "the check must read the /choro/claims route");
|
|
266
|
+
// 手动触发,不进 useEffect:每次切页都跑 git diff 会把浏览拖慢,而且这个面板
|
|
267
|
+
// 第一次就自动跑的话,「没点过」和「点过没事」在屏幕上就长一个样了。
|
|
268
|
+
assert.ok(
|
|
269
|
+
!/useEffect\(\(\) => \{[^}]*checkDrift/.test(fn),
|
|
270
|
+
"the check must not run automatically on mount — an unasked check cannot be told apart from an unrun one"
|
|
271
|
+
);
|
|
272
|
+
for (const key of ["wiki.drift.clean", "wiki.drift.anchorStale", "wiki.drift.blocked", "wiki.drift.summary"]) {
|
|
273
|
+
assert.ok(fn.includes(`t("${key}")`), `each outcome needs its own sentence, missing ${key}`);
|
|
274
|
+
}
|
|
275
|
+
for (const key of ["wiki.drift.touched", "wiki.drift.shifted", "wiki.drift.deleted", "wiki.drift.unverifiable"]) {
|
|
276
|
+
assert.ok(fn.includes(`t("${key}")`), `each hit reason needs its own sentence, missing ${key}`);
|
|
277
|
+
}
|
|
278
|
+
// touched 与 shifted 分家:混成「失效」会让只是行号挪了的结论也被喊去重写。
|
|
279
|
+
assert.ok(
|
|
280
|
+
fn.includes('c.reason === "touched"') && fn.includes('c.reason === "shifted"'),
|
|
281
|
+
"touched and shifted must stay two verdicts"
|
|
282
|
+
);
|
|
283
|
+
// 正文有语义段、库里零条结论(T1 落盘非原子留下的残局)要单独说出来,
|
|
284
|
+
// 否则这页会永远显示成干净。
|
|
285
|
+
assert.ok(fn.includes('t("wiki.drift.inconsistent")'), "a page with prose but no claims must say so");
|
|
286
|
+
// 每页的条数挂在树里,用户不用先点开才知道哪页被改动碰到。
|
|
287
|
+
assert.ok(fn.includes("mneme-wikidriftn"), "the tree must carry a per-page count badge");
|
|
288
|
+
|
|
289
|
+
// 文案本身也要能区分「没算」与「没事」:干净那句必须提到 HEAD/基线,
|
|
290
|
+
// 失败那句必须提到失败——两者措辞相同就等于没区分。
|
|
291
|
+
const clean = [...clientSource.matchAll(/"wiki\.drift\.clean":\s*"((?:[^"\\]|\\.)+)"/g)].map((m) => m[1]);
|
|
292
|
+
const blocked = [...clientSource.matchAll(/"wiki\.drift\.blocked":\s*"((?:[^"\\]|\\.)+)"/g)].map((m) => m[1]);
|
|
293
|
+
assert.equal(clean.length, 2, "wiki.drift.clean must exist in both dictionaries");
|
|
294
|
+
assert.equal(blocked.length, 2, "wiki.drift.blocked must exist in both dictionaries");
|
|
295
|
+
for (const line of clean) {
|
|
296
|
+
assert.ok(/HEAD|基线|baseline/.test(line), "the clean copy must say what it compared against");
|
|
297
|
+
}
|
|
298
|
+
for (const line of blocked) {
|
|
299
|
+
assert.ok(/失败|failed/i.test(line), "the blocked copy must admit the comparison did not happen");
|
|
300
|
+
}
|
|
301
|
+
assert.notEqual(clean[0], blocked[0], "the two outcomes must not share copy");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// 自动刷新被主动跳过(仓库里有富化页)。这条锁的是「拒绝不许长得像失败」:
|
|
305
|
+
// 服务端已经把 autoRefreshSkipped 与 autoRefreshError 分成两个字段,面板必须
|
|
306
|
+
// 跟着分——合成一句「刷新失败」就是在报一个没发生过的故障,而真正的后果
|
|
307
|
+
// (语义段还在、快照还是旧的)反而没人说。
|
|
308
|
+
test("auto-refresh skip is shown as a standing reason, not as a failure toast", () => {
|
|
309
|
+
const fn = sliceComponent("WikiMainPanel");
|
|
310
|
+
assert.ok(fn.includes("autoRefreshSkipped"), "the panel must read the skip signal");
|
|
311
|
+
assert.ok(fn.includes("autoRefreshNote"), "the skip reason needs a visible home");
|
|
312
|
+
assert.ok(
|
|
313
|
+
!/autoRefreshError[^\n]*autoRefreshSkipped|autoRefreshSkipped[^\n]*autoRefreshError/.test(fn),
|
|
314
|
+
"skipped and error must not be merged into one branch"
|
|
315
|
+
);
|
|
316
|
+
// 提示条不能只在 toast 里:toast 2.5 秒就没了,而「为什么没刷」是一个
|
|
317
|
+
// 仍然成立的状态(库里还有富化页),用户回头还要能看到。
|
|
318
|
+
assert.ok(fn.includes("mneme-wikidriftbar") && fn.includes("state.autoRefreshNote"),
|
|
319
|
+
"the skip note must render as a bar, not a self-clearing toast");
|
|
320
|
+
const zh = [...clientSource.matchAll(/"wiki\.autoRefresh\.skipped":\s*"((?:[^"\\]|\\.)+)"/g)].map((m) => m[1]);
|
|
321
|
+
const en = zh.length;
|
|
322
|
+
assert.equal(en, 2, "wiki.autoRefresh.skipped must exist in both dictionaries");
|
|
323
|
+
assert.ok(/没.*做|没有做/.test(zh[0]), "the Chinese copy must say it did not happen");
|
|
324
|
+
assert.ok(!/失败/.test(zh[0]), "the skip copy must not claim a failure");
|
|
325
|
+
assert.ok(/skipped/i.test(zh[1]), "the English copy must say skipped");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// 知识库页(契约 §3.6)。这条锁的是「路由与就近文案都在」,不是布局:
|
|
329
|
+
// · 409/503 各有自己的空态,不把裸状态码丢屏幕上;
|
|
330
|
+
// · disabled 那句必须说清去哪儿开(#607 的教训:只说「未启用」等于没说);
|
|
331
|
+
// · 地志页导入要拼绝对路径,根路径只从 /choro/repos 取,拿不到就停手。
|
|
332
|
+
test("knowledge base page exposes the §3.6 routes and explains every empty state", () => {
|
|
333
|
+
const fn = sliceComponent("KbMainPanel");
|
|
334
|
+
for (const route of [
|
|
335
|
+
'"/api/dsh-mneme/kb/collections',
|
|
336
|
+
'"/api/dsh-mneme/kb/docs?collection=',
|
|
337
|
+
'"/api/dsh-mneme/kb/ingest"',
|
|
338
|
+
'"/api/dsh-mneme/kb/search"'
|
|
339
|
+
]) {
|
|
340
|
+
assert.ok(fn.includes(route), "the knowledge base page must call " + route);
|
|
341
|
+
}
|
|
342
|
+
assert.ok(fn.includes('t("kb.empty.disabled")'), "409 must render the disabled explanation");
|
|
343
|
+
assert.ok(fn.includes('t("kb.empty.unavailable")'), "503 must render the unavailable explanation");
|
|
344
|
+
assert.ok(fn.includes('t("kb.empty.collections")'), "an empty collection list must explain what to do next");
|
|
345
|
+
assert.ok(fn.includes('t("kb.repo.noPath")'), "a missing rootPath must stop before composing a guaranteed 404");
|
|
346
|
+
// 后端 /choro/modules 曾只回 snake_case 的 page_rel_path,面板读 mod.relPath 恒 undefined,
|
|
347
|
+
// 于是「选完模块点导入」永远弹「请先选模块」——把后端缺陷说成用户没点(#607 同型)。
|
|
348
|
+
// 现在后端补了 relPath,缺字段时也必须落到独立的解释上,不许复用 pickFirst。
|
|
349
|
+
assert.ok(fn.includes('t("kb.repo.noRelPath")'), "a module without relPath needs its own explanation");
|
|
350
|
+
assert.ok(
|
|
351
|
+
!/if \(!relPath\) \{ patch\(\{ toast: t\("kb\.repo\.pickFirst"\) \}\); return; \}/.test(fn),
|
|
352
|
+
"a missing relPath must not be reported as 'pick a module first'"
|
|
353
|
+
);
|
|
354
|
+
assert.ok(fn.includes("kb/collections?key="), "deleting a collection addresses it by key");
|
|
355
|
+
|
|
356
|
+
// 文案本身锁一次:说清这是 mneme 的功能、开关在哪儿。
|
|
357
|
+
assert.ok(
|
|
358
|
+
clientSource.includes("设置 → 记忆(mneme) → 功能 → 知识库"),
|
|
359
|
+
"the Chinese disabled copy must name the exact toggle path"
|
|
360
|
+
);
|
|
361
|
+
assert.ok(
|
|
362
|
+
clientSource.includes("Settings → Memory (mneme) → Features → Knowledge base"),
|
|
363
|
+
"the English disabled copy must name the same path"
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// 仓库卡片 + 一键整理(#607 反馈:希望「在这里选择整理知识」,照 Qoder Repo Wiki 那样)。
|
|
368
|
+
// 这条锁四件事,都是「假成功」的高发点:
|
|
369
|
+
// · /choro/repos 的 409/503 不能并进「还没有仓库」,否则地志没开被说成用户没生成;
|
|
370
|
+
// · 批量整理的失败必须逐条留名(toast 2.5 秒就没了,名字一多读不完);
|
|
371
|
+
// · 只有 relPath 存在的模块才去灌(没骨架页硬灌 = 一串 file-not-found);
|
|
372
|
+
// · 手动入库降级为可折叠的高级入口,卡片才是首屏路径。
|
|
373
|
+
test("knowledge base organises a whole repo from cards and never fakes a clean result", () => {
|
|
374
|
+
const fn = sliceComponent("KbMainPanel");
|
|
375
|
+
assert.ok(fn.includes('"/api/dsh-mneme/choro/repos"'), "the cards read the repo list (the only route with rootPath)");
|
|
376
|
+
assert.ok(fn.includes('"/api/dsh-mneme/choro/modules?repo="'), "organising needs each repo's page relative paths");
|
|
377
|
+
// 409/503 必须各走各的 reposPhase,不许落进空列表。
|
|
378
|
+
assert.ok(/status === 409[^}]*reposPhase: "disabled"/.test(fn), "409 must set reposPhase=disabled, not an empty list");
|
|
379
|
+
assert.ok(/status === 503[^}]*reposPhase: "unavailable"/.test(fn), "503 must set reposPhase=unavailable, not an empty list");
|
|
380
|
+
assert.ok(fn.includes('t("kb.repos.empty")'), "a genuinely empty repo list gets its own copy");
|
|
381
|
+
// 三档回执:新增 / 未变 / 失败,合并成一句「整理完成」就是假回声。
|
|
382
|
+
assert.ok(fn.includes('t("kb.repos.new")') && fn.includes('t("kb.repos.same")') && fn.includes('t("kb.repos.failedN")'),
|
|
383
|
+
"the batch receipt must report added, unchanged and failed separately");
|
|
384
|
+
assert.ok(fn.includes("organizeErrors"), "failed pages must stay on the page, not only in a 2.5s toast");
|
|
385
|
+
// 只灌有 relPath 的页。
|
|
386
|
+
assert.ok(/\.filter\(\(m\) => m\.relPath\)/.test(fn), "only modules with a relPath may be ingested");
|
|
387
|
+
// 高级入口:手动表单常驻,但折叠在按钮后面。
|
|
388
|
+
assert.ok(fn.includes("mneme-kbadvtoggle") && fn.includes('t("kb.advanced")'),
|
|
389
|
+
"manual ingest stays available behind one explicit toggle");
|
|
390
|
+
// 卡片上的「已整理 N/M」按当前集合的 relKey 前缀数,不额外打请求。
|
|
391
|
+
assert.ok(fn.includes("organizedCount") && fn.includes("state.docs"), "the organised count reads the loaded docs, not a new request");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// 知识库是第三个座位:id 与 main 的 key 必须逐字同名,否则宿主 selectPanel
|
|
395
|
+
// 直接抛 "main panel ... is not registered"(#562 在记忆座位上踩过)。
|
|
396
|
+
test("knowledge base entry registers its own seat pair in the same panellist block", () => {
|
|
397
|
+
assert.ok(clientSource.includes('id: "dsh-mneme-kb"'), "the kb sidebar seat must use its own id");
|
|
398
|
+
assert.ok(clientSource.includes('key: "dsh-mneme-kb"'), "the kb main seat key must match the sidebar id verbatim");
|
|
399
|
+
assert.ok(clientSource.includes("h(KbMainPanel, { t })"), "the kb main seat must render KbMainPanel");
|
|
400
|
+
assert.ok(
|
|
401
|
+
clientSource.includes("h(KbGlyph, { size: props && props.size })"),
|
|
402
|
+
"the kb glyph takes its size from the host"
|
|
403
|
+
);
|
|
404
|
+
assert.ok(
|
|
405
|
+
/return \(\) => \{\s*disposeGlyph\(\);\s*disposeMain\(\);[\s\S]*?disposeWikiMain\(\);\s*disposeKbGlyph\(\);\s*disposeKbMain\(\);/.test(clientSource),
|
|
406
|
+
"the panellist cleanup must dispose the remaining seat pairs (the wiki glyph moved to the feature gate)"
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
138
410
|
// v0.11.1 美化:文件清单的竖线等宽行换成真 table(用户实测反馈「文件名排列
|
|
139
411
|
// 太难看」)。锁渲染器三件事:真 <table>、分隔行(--- | ---:)按对齐元数据消费
|
|
140
412
|
// 而不是画成可见行、数字列右对齐。回归表现:改回竖线等宽行画法 → 红在缺 table。
|
|
@@ -189,8 +461,10 @@ test("panel seats: the fallback mounts and unmounts with the host's declaration"
|
|
|
189
461
|
// spec 存在时点火:一把全点会把新座位的注册塞进老宿主,测出来的就不是真行为。
|
|
190
462
|
const fire = (ctx, key) => ctx.injected.filter((i) => i.key === key).map((i) => i.cb());
|
|
191
463
|
|
|
192
|
-
// 新宿主:panellist 已声明。panellist + main
|
|
193
|
-
//
|
|
464
|
+
// 新宿主:panellist 已声明。panellist + main 各三份(记忆 + chorography +
|
|
465
|
+
// 知识库三个座位),每对各自 id/key 逐字同名;footer 回落不装。
|
|
466
|
+
// 日记本原有第四对座位,2026-11-09 按桉桉要求撤掉——它现在是记忆面板内的
|
|
467
|
+
// 一个标签页,与人格页并列,不再占侧边栏;人格页同理(面板内标签页,不是座位)。
|
|
194
468
|
{
|
|
195
469
|
const { ctx, regs } = makeCtx((key) => (key === "sidebar.panellist" ? { kind: "list" } : undefined));
|
|
196
470
|
const api = boot(ctx, (k) => k, () => null);
|
|
@@ -199,10 +473,10 @@ test("panel seats: the fallback mounts and unmounts with the host's declaration"
|
|
|
199
473
|
fire(ctx, "sidebar.footer.action");
|
|
200
474
|
assert.equal(disposes.length, 1);
|
|
201
475
|
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
|
|
476
|
+
assert.equal(alive(regs, "sidebar.panellist"), 2, "memory + kb glyphs — the wiki glyph is feature-gated (#598), not unconditional");
|
|
477
|
+
assert.deepEqual(ids("sidebar.panellist"), ["dsh-mneme", "dsh-mneme-kb"], "每对座位各自 id 逐字唯一;wiki glyph 走座位闸,不在无条件集合里");
|
|
478
|
+
assert.equal(alive(regs, "main"), 3, "memory + wiki + kb pages — wiki main stays unconditional (#562)");
|
|
479
|
+
assert.deepEqual(ids("main"), ["dsh-mneme", "dsh-mneme-kb", "dsh-mneme-wiki"], "main key 与侧栏 id 逐字同名(wiki 的 main 无条件保留,#562)");
|
|
206
480
|
assert.equal(alive(regs, "sidebar.footer.action"), 0, "the footer fallback must not mount on a host that has the seat");
|
|
207
481
|
}
|
|
208
482
|
|
|
@@ -336,6 +610,33 @@ test("memory library opens as a centered sheet with backdrop", () => {
|
|
|
336
610
|
);
|
|
337
611
|
});
|
|
338
612
|
|
|
613
|
+
// #597:分类栏与页头的数字源必须来自服务端(typeCounts / total)——浏览页被
|
|
614
|
+
// type 筛过又分了页,页内现数两头都数不出真分布:选中一类后其它类计数为 0 被
|
|
615
|
+
// filter 整条滤掉(分类栏塌剩两枚),页头停在已加载行数(滚动一次后恒 200)。
|
|
616
|
+
// 降级路径必须同时在:老服务端没有 typeCounts 时退回页内现数,功能不炸。
|
|
617
|
+
test("explorer counts read the server distribution with an in-page fallback (#597)", () => {
|
|
618
|
+
assert.ok(
|
|
619
|
+
clientSource.includes("setTypeCounts(d.typeCounts || null)"),
|
|
620
|
+
"the browse fetch must capture the server distribution"
|
|
621
|
+
);
|
|
622
|
+
assert.ok(
|
|
623
|
+
clientSource.includes("for (const k of EXPLORER_TYPES) counts[k] = typeCounts[k] || 0"),
|
|
624
|
+
"the type rail must read the server distribution (missing types are 0, never collapse)"
|
|
625
|
+
);
|
|
626
|
+
assert.ok(
|
|
627
|
+
clientSource.includes("for (const m of items) counts[m.type] = (counts[m.type] || 0) + 1"),
|
|
628
|
+
"the in-page fallback must stay for servers without typeCounts"
|
|
629
|
+
);
|
|
630
|
+
assert.ok(
|
|
631
|
+
clientSource.includes("String(allCount)"),
|
|
632
|
+
"the all chip must use the distribution sum, not the loaded page length"
|
|
633
|
+
);
|
|
634
|
+
assert.ok(
|
|
635
|
+
/String\(remoteItems \? visible\.length : total\)/.test(clientSource),
|
|
636
|
+
"the header count must use the server total in browse mode (not visible.length)"
|
|
637
|
+
);
|
|
638
|
+
});
|
|
639
|
+
|
|
339
640
|
// The sidebar hands each entry only its column state: a wide row (icon +
|
|
340
641
|
// label) when expanded, a bare rail icon when collapsed — for both the
|
|
341
642
|
// portalled top button and the footer fallback.
|
|
@@ -989,8 +1290,8 @@ test("consolidation chart: own 56-day window, null-vs-empty split, and a narrow-
|
|
|
989
1290
|
"two columns must collapse to one in a narrow container"
|
|
990
1291
|
);
|
|
991
1292
|
// 两根柱的语义色必须走语义层,不能直接拿宿主 label token 当填充色(#178)。
|
|
992
|
-
assert.ok(clientSource.includes(".mneme-sinkbar--added{background:var(--mneme-
|
|
993
|
-
assert.ok(clientSource.includes(".mneme-sinkbar--merged{background:var(--mneme-
|
|
1293
|
+
assert.ok(clientSource.includes(".mneme-sinkbar--added{background:var(--mneme-sink-added)}"));
|
|
1294
|
+
assert.ok(clientSource.includes(".mneme-sinkbar--merged{background:var(--mneme-sink-merged)}"));
|
|
994
1295
|
});
|
|
995
1296
|
|
|
996
1297
|
// 巩固/睡眠模型路由 UI:下拉数据来自宿主侧已注册适配器(/llm-providers,
|
|
@@ -1304,18 +1605,57 @@ test("scope isolation ships panel switches and provenance surfaces (A4)", () =>
|
|
|
1304
1605
|
// 默认值),面板自判、不加后端字段;④分组与折叠键中英成对。验收口径:首屏
|
|
1305
1606
|
// 可点项 = 3 总开关 + autoInject 的 2 个子开关 + scope 2 开关 + 「全部设置」
|
|
1306
1607
|
// 按钮 = 8(≤ 8),由 ①② 的组成正则共同钉死,不另数 DOM。
|
|
1307
|
-
// v0.9.0 复验修订:首屏加 group.mo(M.O. 独立组),可点项预算 8→9
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1608
|
+
// v0.9.0 复验修订:首屏加 group.mo(M.O. 独立组),可点项预算 8→9——产品
|
|
1609
|
+
// 主人点名「别藏在里面」,M.O. 独立成组却仍折在「全部设置」后等于换个地方
|
|
1610
|
+
// 继续藏。**v0.13.x 反转**:主人决定把 M.O. 下架,那一组连同首屏位一起摘掉,
|
|
1611
|
+
// 预算回到 8,本锁随之收紧回两组。反转是产品决策、不是放宽,注释留档防下次
|
|
1612
|
+
// 有人照着旧注释把组加回来。
|
|
1613
|
+
// **v0.13.x 第二次产品豁免(#607 后续)**:首屏加 group.ext(仓库地志 / 知识库 /
|
|
1614
|
+
// 日记本三个扩展子系统的总闸),可点项预算 8→11(3 总开关 + autoInject 2 子 +
|
|
1615
|
+
// 扩展 3 闸 + scope 2 + 「全部设置」按钮)。理由:这三个子系统此前分别散在
|
|
1616
|
+
// enhance 组尾(choro/diary)或压根没进 FEATURE_GROUPS(kb),用户「开了才
|
|
1617
|
+
// 找得到、找不到就没法开」——#607 报的「知识库没成型」一半是入口问题。它们
|
|
1618
|
+
// 是「装不装这个子系统」而非「每轮付多少」,与 enhance 的行为开关不同级,
|
|
1619
|
+
// 所以单列一组并提到首屏。**再往首屏塞东西前先问:它是不是同级子系统?**
|
|
1310
1620
|
test("settings card folds everything beyond the first screen behind All settings (issue #427)", () => {
|
|
1311
|
-
// ① 首屏层组成:三组都要在(basic 总开关 /
|
|
1621
|
+
// ① 首屏层组成:三组都要在(basic 总开关 / ext 子系统总闸 / scope 必答题),
|
|
1312
1622
|
// 且 basic 的成员是从 core/dream 移走的。
|
|
1313
|
-
assert.ok(/const FEATURE_FIRST_SCREEN = \["group\.basic", "group\.
|
|
1314
|
-
"the first screen must be
|
|
1623
|
+
assert.ok(/const FEATURE_FIRST_SCREEN = \["group\.basic", "group\.ext", "group\.scope"\];/.test(clientSource),
|
|
1624
|
+
"the first screen must be the basic, ext and scope groups (M.O. taken down in v0.13.x; ext added as the second product exemption)");
|
|
1625
|
+
// M.O. 下架锁:组从 FEATURE_GROUPS 摘掉 ≠ 只把首屏位去掉。少了这一条,下次
|
|
1626
|
+
// 有人「顺手把组加回来」时会折进「全部设置」,测试照绿而 M.O. 又出现在面板上。
|
|
1627
|
+
assert.ok(!/\{ key: "group\.mo", items: \[/.test(clientSource),
|
|
1628
|
+
"M.O. must stay off the panel: group.mo is deliberately absent from FEATURE_GROUPS (taken down in v0.13.x)");
|
|
1629
|
+
// 但键本身不能删——config schema / settings 白名单里还在,恢复时零改动。
|
|
1630
|
+
assert.ok(clientSource.includes("actionPipelineEnabled"),
|
|
1631
|
+
"the M.O. flags must remain reachable (config + settings whitelist) so the takedown stays reversible");
|
|
1315
1632
|
assert.ok(/key: "group\.basic", items: \["autoInject", "autoSummarize", "autoDream"\]/.test(clientSource),
|
|
1316
1633
|
"group.basic must carry exactly the three master switches");
|
|
1317
|
-
|
|
1318
|
-
|
|
1634
|
+
// 扩展组必须同时收三个子系统的总闸,且一个都不能漏:漏掉哪个,那个子系统就
|
|
1635
|
+
// 退回 #607 的状态——后端接好了、面板上没有入口,用户只能靠改配置文件打开。
|
|
1636
|
+
assert.ok(/key: "group\.ext", items: \["chorographyEnabled", "kbEnabled", "diaryEnabled"\]/.test(clientSource),
|
|
1637
|
+
"group.ext must carry the three subsystem master switches (chorography / kb / diary)");
|
|
1638
|
+
// 迁移锁:choro/diary 从 enhance 组移出(render-once,同一项只渲染一次)。
|
|
1639
|
+
// 只把首屏位加上而不移成员,会让两个开关同时出现在两组里,状态还可能不同步。
|
|
1640
|
+
// #427 revamp(2026-11-11):group.enhance 进一步拆成 safety/pipeline/retrieve/
|
|
1641
|
+
// persona——它曾是设置页「难找难看」的重灾区(14 个异质开关混装),拆完不许
|
|
1642
|
+
// 有人顺手把旧组名加回来。
|
|
1643
|
+
assert.ok(!/group\.enhance", items: \[/.test(clientSource),
|
|
1644
|
+
"group.enhance must stay split into safety/pipeline/retrieve/persona (the revamp's core move)");
|
|
1645
|
+
assert.ok(!/group\.core", items: \[/.test(clientSource),
|
|
1646
|
+
"group.core must stay split too (its audit/defense rows moved to group.safety)");
|
|
1647
|
+
for (const moved of ["chorographyEnabled", "diaryEnabled"]) {
|
|
1648
|
+
const extItems = clientSource.match(/key: "group\.ext", items: \[([^\]]*)\]/)?.[1] ?? "";
|
|
1649
|
+
assert.ok(extItems.includes(`"${moved}"`), `${moved} must stay in group.ext`);
|
|
1650
|
+
const occurrencesInGroups = [...clientSource.matchAll(/items: \[([^\]]*)\]/g)]
|
|
1651
|
+
.filter((m) => m[1].includes(`"${moved}"`)).length;
|
|
1652
|
+
assert.equal(occurrencesInGroups, 1, `${moved} must render exactly once across all groups (render-once)`);
|
|
1653
|
+
}
|
|
1654
|
+
// 四个新组都在:少了哪个,那批开关就退回「没座位」状态。
|
|
1655
|
+
for (const groupKey of ["group.safety", "group.pipeline", "group.retrieve", "group.persona"]) {
|
|
1656
|
+
assert.ok(clientSource.includes(`key: "${groupKey}", items: [`),
|
|
1657
|
+
`${groupKey} must exist in FEATURE_GROUPS (the revamp's regrouping)`);
|
|
1658
|
+
}
|
|
1319
1659
|
// ② 两层折叠的接线:restGroups 只在 showAll 展开后渲染;「高级」按钮出现在
|
|
1320
1660
|
// 「全部设置」之后(同一渲染分支内),showAdv 展开的条目走第二层。
|
|
1321
1661
|
assert.ok(/showAll && h\(react\.Fragment, null,\s*restGroups\.map\(renderGroup\)/.test(clientSource),
|
|
@@ -1323,16 +1663,24 @@ test("settings card folds everything beyond the first screen behind All settings
|
|
|
1323
1663
|
const allBtn = clientSource.indexOf('t("memory.features.allToggle")');
|
|
1324
1664
|
const advBtn = clientSource.indexOf('t("memory.features.advancedToggle")');
|
|
1325
1665
|
assert.ok(allBtn >= 0 && advBtn > allBtn, "the advanced fold must be nested after All settings");
|
|
1326
|
-
assert.equal(clientSource.match(/const FEATURE_ADVANCED_BOOLS = \[([^\]]*)\];/)[1].split(",").filter((s) => s.trim()).length,
|
|
1327
|
-
"the advanced second tier must hold exactly the
|
|
1666
|
+
assert.equal(clientSource.match(/const FEATURE_ADVANCED_BOOLS = \[([^\]]*)\];/)[1].split(",").filter((s) => s.trim()).length, 9,
|
|
1667
|
+
"the advanced second tier must hold exactly the 9 rarer switches the label promises (entityRecall + graphAnchoring joined in the revamp)");
|
|
1328
1668
|
// ③ 徽标:两类各一套中英文案;判定读 overrides 本地判,不引入后端字段。
|
|
1329
|
-
|
|
1669
|
+
// #427 revamp:tagDefault 的徽标不再渲染(默认态由开关位置本身表达),i18n
|
|
1670
|
+
// 键随之下线——这里锁的是「不再渲染」这件事本身,防止有人把灰字墙加回来。
|
|
1671
|
+
for (const key of ["memory.features.tagChanged", "memory.features.tagPending",
|
|
1330
1672
|
"memory.features.group.basic", "memory.features.allToggle", "memory.features.advancedToggle"]) {
|
|
1331
1673
|
const occurrences = clientSource.split("\"" + key + "\"").length - 1;
|
|
1332
1674
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1333
1675
|
}
|
|
1334
1676
|
assert.ok(clientSource.includes("Object.prototype.hasOwnProperty.call(ovr, k)"),
|
|
1335
|
-
"the
|
|
1677
|
+
"the changed badge must be decided from overrides, not a new backend field");
|
|
1678
|
+
// 「默认」徽标的下线锁:flagTag 对默认态必须 return null(CSS 类本身保留——
|
|
1679
|
+
// 事实卡的 actor 徽标还用它),禁的是功能开关行再挂灰字墙。
|
|
1680
|
+
assert.ok(/if \(!Object\.prototype\.hasOwnProperty\.call\(ovr, k\)\) return null;/.test(clientSource),
|
|
1681
|
+
"flagTag must return null on default rows (no more always-grey 「默认」 chips)");
|
|
1682
|
+
assert.ok(!clientSource.includes("memory.features.tagDefault"),
|
|
1683
|
+
"the tagDefault copy must stay deleted along with its render site");
|
|
1336
1684
|
});
|
|
1337
1685
|
|
|
1338
1686
|
// #481:面板开关「改了不生效」的体验坑。开关在宿主启动时合并进冻结 cfg,
|
|
@@ -1394,11 +1742,30 @@ test("conflict queue ships a central review surface on the status tab", () => {
|
|
|
1394
1742
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1395
1743
|
}
|
|
1396
1744
|
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");
|
|
1745
|
+
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts?limit=200"'), "queue must fetch the conflicts endpoint");
|
|
1398
1746
|
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts/resolve"'), "queue must call the resolve endpoint");
|
|
1399
1747
|
assert.ok(clientSource.includes("h(ConflictsQueue, { t })"), "status tab must render the queue");
|
|
1400
1748
|
});
|
|
1401
1749
|
|
|
1750
|
+
// Issue #603 第 3 层:服务端一直返回 { items, total },面板此前既不带 limit 也不读
|
|
1751
|
+
// total——列 50 条而徽章 60,第 51–60 条翻不到、也没有任何提示。这条锁三件事:
|
|
1752
|
+
// 请求要取满服务端上限、total 要存下来、被截断时要说出来。
|
|
1753
|
+
test("#603 conflict queue: the panel asks for the server cap and admits truncation", () => {
|
|
1754
|
+
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts?limit=200"'),
|
|
1755
|
+
"the queue must request the server's 200-row cap, not the 50-row default");
|
|
1756
|
+
assert.ok(clientSource.includes("setTotal(typeof d.total === \"number\" ? d.total : null)"),
|
|
1757
|
+
"the response total must be kept (it was dropped before)");
|
|
1758
|
+
assert.ok(clientSource.includes("memory.status.conflictQueue.truncated"),
|
|
1759
|
+
"a truncated list must say so");
|
|
1760
|
+
for (const key of ["memory.status.conflictQueue.truncated"]) {
|
|
1761
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1762
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1763
|
+
}
|
|
1764
|
+
// 差额才提示:总数等于行数时不该多出一行噪声。
|
|
1765
|
+
assert.ok(clientSource.includes("total > shown ? total - shown : 0"),
|
|
1766
|
+
"the notice must only appear when rows are actually hidden");
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1402
1769
|
// --- issue #178 批次一:无障碍(aria-live 网络 / 语义标题 / 焦点管理 / 图摘要) ---
|
|
1403
1770
|
|
|
1404
1771
|
// 面板瞬时反馈(保存/裁决/刷新/复制)不能只靠纯视觉 span:announce() 单例
|
|
@@ -1446,10 +1813,12 @@ test("a11y: entity graph svg exposes a count summary", () => {
|
|
|
1446
1813
|
|
|
1447
1814
|
// --- issue #177:冲突队列交互与视觉重做 ---
|
|
1448
1815
|
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1816
|
+
// 相似度进度条:优先取服务端落列的 similarity(#603 第 2 层),老行才回落到
|
|
1817
|
+
// 「相似度 X / similarity X」锚定短语(防把年份当相似度);A/B 卡片带侧色类,
|
|
1818
|
+
// 冻结徽章替代预裁决阶段的「已归档」徽章。
|
|
1451
1819
|
test("conflict queue: similarity bar, side colors and frozen badge", () => {
|
|
1452
|
-
assert.ok(clientSource.includes("similarityOf(it
|
|
1820
|
+
assert.ok(clientSource.includes("similarityOf(it)"), "the whole item must be parsed, not just reason");
|
|
1821
|
+
assert.ok(clientSource.includes("typeof it.similarity === \"number\""), "the server-side column wins over text parsing");
|
|
1453
1822
|
assert.ok(clientSource.includes("/(?:相似度|similarity)\\s*([01](?:\\.\\d+)?)/i"), "parse must anchor on the similarity phrase");
|
|
1454
1823
|
for (const key of ["memory.status.conflictQueue.similarity", "memory.status.conflictQueue.frozenBadge"]) {
|
|
1455
1824
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
@@ -1650,6 +2019,43 @@ test("mirror health: card wired with fail-closed unknown rendering", () => {
|
|
|
1650
2019
|
}
|
|
1651
2020
|
});
|
|
1652
2021
|
|
|
2022
|
+
// --- issue #275:库体积卡(容量预警,空间维度)---
|
|
2023
|
+
// 预警卡的关健不变量同样是 fail-closed:fetch 失败、老宿主没有 capacity 块、后端
|
|
2024
|
+
// status:"unknown" 三种情况都渲染「未知」,绝不显示「正常」;过线的检查逐条列出
|
|
2025
|
+
// 且值/阈值都给全(报警要能对上数字)。这里锁死派生表达式与 i18n 双语键,
|
|
2026
|
+
// 防止未来被「简化」成把未知当正常、或把过线行折成一句空话。
|
|
2027
|
+
test("capacity warning: card wired with fail-closed unknown rendering", () => {
|
|
2028
|
+
assert.ok(clientSource.includes("function CapacityCard"), "库体积卡必须存在");
|
|
2029
|
+
assert.ok(clientSource.includes("h(CapacityCard, { t })"), "overview grid must render the capacity card");
|
|
2030
|
+
// 与镜像卡同一条 /health 路径读 capacity 块(「加一个库体积卡是同一条路径」)
|
|
2031
|
+
const fetches = clientSource.split('"/api/dsh-mneme/health"').length - 1;
|
|
2032
|
+
assert.ok(fetches >= 2, "capacity card must fetch the health endpoint (mirror card + capacity card)");
|
|
2033
|
+
const unknown = clientSource.match(/const unknown = !c \|\| c\.status === "unknown";/);
|
|
2034
|
+
assert.ok(unknown, "missing block / backend unknown must collapse to unknown, never ok");
|
|
2035
|
+
// 过线行必须带 warn 标记走预警色,大数字同样(class 由 StatusCard 拼接)
|
|
2036
|
+
assert.ok(clientSource.includes('"mneme-statrow mneme-statrow--warn"'), "tripped check rows must carry the warn class");
|
|
2037
|
+
assert.ok(clientSource.includes('"mneme-statusnum mneme-statusnum--warn"'), "warned big number must carry the warn class");
|
|
2038
|
+
// 三种检查的标签显式映射(不按键名拼 i18n key,缺键时会把键名漏给用户)
|
|
2039
|
+
assert.ok(clientSource.includes('"memory.status.capacity.check.dbSize"'));
|
|
2040
|
+
assert.ok(clientSource.includes('"memory.status.capacity.check.archivedRows"'));
|
|
2041
|
+
assert.ok(clientSource.includes('"memory.status.capacity.check.archivedRatio"'));
|
|
2042
|
+
for (const key of [
|
|
2043
|
+
"memory.status.capacity",
|
|
2044
|
+
"memory.status.capacity.unknown",
|
|
2045
|
+
"memory.status.capacity.unknownFoot",
|
|
2046
|
+
"memory.status.capacity.active",
|
|
2047
|
+
"memory.status.capacity.archived",
|
|
2048
|
+
"memory.status.capacity.reclaimable",
|
|
2049
|
+
"memory.status.capacity.check.dbSize",
|
|
2050
|
+
"memory.status.capacity.check.archivedRows",
|
|
2051
|
+
"memory.status.capacity.check.archivedRatio",
|
|
2052
|
+
"memory.status.capacity.foot"
|
|
2053
|
+
]) {
|
|
2054
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2055
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2056
|
+
}
|
|
2057
|
+
});
|
|
2058
|
+
|
|
1653
2059
|
// --- issue #386 第 1 条:独立服务只读卡片 ---
|
|
1654
2060
|
// 卡片展示 dsh-mneme-serve 示例命令并支持复制。关键锁:命令不能写成
|
|
1655
2061
|
// `npx dsh-mneme-serve …` —— bin 名与包名不同,裸 npx 会尝试安装一个不存在
|
|
@@ -2017,17 +2423,24 @@ test("client bundle parses: 面板产物必须是合法 JS(无重复声明等
|
|
|
2017
2423
|
test("distill retention switches are reachable from the panel (#399)", () => {
|
|
2018
2424
|
// 认「组里有没有」不要用 `"key"]` 这种子串——`eff["distillRecords.enabled"]`
|
|
2019
2425
|
// 也长这样,把组里那一项删掉它照样命中(变异验证实测:删项后仍全绿)。
|
|
2020
|
-
//
|
|
2021
|
-
const
|
|
2022
|
-
assert.ok(
|
|
2426
|
+
// 必须把所属组的 items 数组抠出来单独看。#427 revamp 后归属 pipeline 组。
|
|
2427
|
+
const pipelineGroup = clientSource.match(/key: "group\.pipeline", items: \[([\s\S]*?)\]/);
|
|
2428
|
+
assert.ok(pipelineGroup, "the pipeline group must be declared");
|
|
2023
2429
|
assert.ok(
|
|
2024
|
-
|
|
2025
|
-
"distillRecords.enabled must be an item of the
|
|
2430
|
+
pipelineGroup[1].includes('"distillRecords.enabled"'),
|
|
2431
|
+
"distillRecords.enabled must be an item of the pipeline group, not merely referenced elsewhere"
|
|
2026
2432
|
);
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2433
|
+
// 数值清单会随新键增长(v0.13 加了 diaryWriteEveryMinutes),所以钉「这三个都在、
|
|
2434
|
+
// 且顺序不变」,不钉整行字面量——否则每加一个数值键这条锁就假红一次。
|
|
2435
|
+
const numList = clientSource.match(/const FEATURE_NUMBERS = \[([\s\S]*?)\];/);
|
|
2436
|
+
assert.ok(numList, "the numeric key list must be declared");
|
|
2437
|
+
const numKeys = (numList[1].match(/"[^"]+"/g) || []).map((k) => k.slice(1, -1));
|
|
2438
|
+
assert.deepEqual(
|
|
2439
|
+
numKeys.slice(0, 3),
|
|
2440
|
+
["distillRecords.retentionDays", "dreamMaxTokens", "sleepMaxTokens"],
|
|
2441
|
+
"retentionDays must stay in the numeric key list, after dreamMaxTokens/sleepMaxTokens (#478)"
|
|
2030
2442
|
);
|
|
2443
|
+
assert.ok(numKeys.includes("distillRecords.retentionDays"), "retentionDays must be in the numeric key list");
|
|
2031
2444
|
assert.ok(
|
|
2032
2445
|
/for \(const key of FEATURE_NUMBERS\) numDraft\[key\] = String\(e\[key\] \?\? ""\);/.test(clientSource)
|
|
2033
2446
|
&& /setNums\(numDraft\);/.test(clientSource),
|
|
@@ -2037,15 +2450,19 @@ test("distill retention switches are reachable from the panel (#399)", () => {
|
|
|
2037
2450
|
/const distillSub = eff\["distillRecords\.enabled"\] && h\("div", \{ className: "mneme-featsub" \},\s*\n\s*numRow\("distillRecords\.retentionDays"\)/.test(clientSource),
|
|
2038
2451
|
"the retention-days row must be gated on the parent switch and live in its sub-block"
|
|
2039
2452
|
);
|
|
2040
|
-
// 定义了不等于接线了:把 distillSub
|
|
2041
|
-
//
|
|
2042
|
-
//
|
|
2043
|
-
const
|
|
2044
|
-
assert.ok(
|
|
2453
|
+
// 定义了不等于接线了:把 distillSub 从 subAfter 里摘掉,上面每条断言照样全绿
|
|
2454
|
+
// (变异验证实测:摘掉后仍全绿)。#427 revamp 后子块一律走 subAfter 紧贴自己的
|
|
2455
|
+
// 总闸(core 组已拆、组尾挂法退役),所以锁的是 subAfter 这条接线本身。
|
|
2456
|
+
const subAfterMap = clientSource.match(/const subAfter = \{([^}]*)\}/);
|
|
2457
|
+
assert.ok(subAfterMap, "the subAfter map must exist (sub-blocks ride next to their master switches)");
|
|
2045
2458
|
assert.ok(
|
|
2046
|
-
/\bdistillSub\b/.test(
|
|
2047
|
-
"distillSub must be
|
|
2459
|
+
/\bdistillSub\b/.test(subAfterMap[1]) && subAfterMap[1].includes('"distillRecords.enabled"'),
|
|
2460
|
+
"distillSub must be wired to distillRecords.enabled via subAfter, not merely declared above"
|
|
2048
2461
|
);
|
|
2462
|
+
for (const wired of ["entitySub", "summarizeSub", "choroSub", "diarySub"]) {
|
|
2463
|
+
assert.ok(new RegExp(`\\b${wired}\\b`).test(subAfterMap[1]),
|
|
2464
|
+
`${wired} must ride next to its master switch via subAfter (detached sub-blocks are the old mess)`);
|
|
2465
|
+
}
|
|
2049
2466
|
assert.ok(
|
|
2050
2467
|
/if \(!\/\^\\d\+\$\/\.test\(raw\)\) \{\s*\n\s*setNums\(\(c\) => \(\{ \.\.\.c, \[key\]: String\(eff\[key\] \?\? ""\) \}\)\);\s*\n\s*return;\s*\n\s*\}/.test(clientSource),
|
|
2051
2468
|
"a non-integer draft must be rejected locally and reverted to the effective value"
|
|
@@ -2231,6 +2648,217 @@ test("right-pane tab renders a named component instead of a bare hook callback",
|
|
|
2231
2648
|
// 「仓库里还没有工作流」与「一条运行记录都没有」是两句不同的话,都得中英齐全——
|
|
2232
2649
|
// i18n 掉一边,界面上就是一行 key 裸奔。作用域那三句尤其不能缺:清单行上「项目」
|
|
2233
2650
|
// 与「全局」是唯一的来源提示,缺了它,人分不清手里这条是本项目的还是跟着插件来的。
|
|
2651
|
+
// 日记本(v0.13)的四条形状锁。为什么值得单独立锁而不是靠上面的通用闸门:
|
|
2652
|
+
// ① 它的旗标闸必须**独立**——M.O. 那条 effect 读的是 actionPipelineEnabled,
|
|
2653
|
+
// 把日记塞进同一个 then 里,关掉 M.O. 会连带关掉日记,而两者毫无关系;
|
|
2654
|
+
// ② 右栏正文同样必须是具名组件(同 PipelinePane 的理由,hook 顺序只在运行时炸);
|
|
2655
|
+
// ③ 「写成 / 今天写过 / 没素材」是三种不同结果,混成一句「成功」就是假成功;
|
|
2656
|
+
// ④ 503(没装配)与 409(没开)是两种不同的「不能用」,合并文案用户会以为日记坏了。
|
|
2657
|
+
test("diary pane: own gate, named component, and distinct outcomes", () => {
|
|
2658
|
+
assert.match(clientSource, /function DiaryPane\(/, "DiaryPane must exist");
|
|
2659
|
+
// 回调必须把「这个会话是谁」一路传进正文:project 定位素材、agent 定位人格卡。
|
|
2660
|
+
// 少传 agent 就会退回 default 卡——自动轮是带 agentId 的,两个入口写同一天
|
|
2661
|
+
// 会变成两个声音,这正是「只以这个人格的口吻写日记」没做到的那一半。
|
|
2662
|
+
assert.match(
|
|
2663
|
+
clientSource,
|
|
2664
|
+
/\}, \(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*\}\);/,
|
|
2665
|
+
"the diary tab callback must delegate to the named DiaryPane component and pass agent + session"
|
|
2666
|
+
);
|
|
2667
|
+
assert.match(clientSource, /function sessionAgent\(ctx, sessionId\)/, "sessionAgent must exist to resolve the persona card");
|
|
2668
|
+
|
|
2669
|
+
// 从日记那段注释起算、到自己的 effect 名止:不能退 1600 字符去数,
|
|
2670
|
+
// 那会把上面 M.O. 那段(它本来就写着 actionPipelineEnabled)一起框进来。
|
|
2671
|
+
const gateHead = clientSource.indexOf("// --- v0.13 日记本(beta):独立旗标门控 ---");
|
|
2672
|
+
const gateAt = clientSource.indexOf('"dsh-mneme: diary gate"');
|
|
2673
|
+
assert.ok(gateHead > -1, "the diary gate comment must exist");
|
|
2674
|
+
assert.ok(gateAt > gateHead, "the diary must carry its own flag gate, after that comment");
|
|
2675
|
+
// 只审代码、不审注释:那段注释本身就在解释「为什么不复用 M.O. 那条」,
|
|
2676
|
+
// 会把 actionPipelineEnabled 这个字放进来说明——按注释判会把这个正确的说明当成违规。
|
|
2677
|
+
const gateBody = clientSource
|
|
2678
|
+
.slice(gateHead, gateAt)
|
|
2679
|
+
.split("\n")
|
|
2680
|
+
.filter((line) => !line.trim().startsWith("//"))
|
|
2681
|
+
.join("\n");
|
|
2682
|
+
assert.ok(gateBody.includes("eff.diaryEnabled"), "the diary gate must read its own flag");
|
|
2683
|
+
assert.ok(
|
|
2684
|
+
!gateBody.includes("actionPipelineEnabled"),
|
|
2685
|
+
"the diary gate must not ride on actionPipelineEnabled — the two features are unrelated"
|
|
2686
|
+
);
|
|
2687
|
+
|
|
2688
|
+
// 三种结果必须各有自己的分支,不能都落进同一句「成功」
|
|
2689
|
+
for (const marker of ['b.status === "written"', '"already-written"', '"no-material"']) {
|
|
2690
|
+
assert.ok(clientSource.includes(marker), "diary write outcome must be handled explicitly: " + marker);
|
|
2691
|
+
}
|
|
2692
|
+
// 503 与 409 分开成两种 gate 文案
|
|
2693
|
+
assert.ok(
|
|
2694
|
+
clientSource.includes("r.status === 503") && clientSource.includes("r.status === 409"),
|
|
2695
|
+
"diary unavailable (503) and disabled (409) must be told apart"
|
|
2696
|
+
);
|
|
2697
|
+
});
|
|
2698
|
+
|
|
2699
|
+
// 日记本重做(2026-11-09,桉桉:「像本子一样的UI风格」/「导航应该是人格名」)。
|
|
2700
|
+
// 两件事都必须由测试锁住,因为它们都只错在「看起来还行」:
|
|
2701
|
+
// ①导航拿 frontmatter 括号里的 agent 名当人格名——本机旧数据里五个桶都写着
|
|
2702
|
+
// `default (ptc)`,于是导航栏出现一排项目键加一个 ptc。正确名字只能从人格卡取。
|
|
2703
|
+
// ②纸张只存内存或只存一处,两个入口各选各的,同一本日记看起来像两个东西。
|
|
2704
|
+
test("diary book: navigation names come from persona cards, never from the agent tag", () => {
|
|
2705
|
+
const start = clientSource.indexOf("function diaryBucketName(");
|
|
2706
|
+
assert.ok(start > -1, "diaryBucketName must exist");
|
|
2707
|
+
const end = clientSource.indexOf("function DiaryLibraryPanel(");
|
|
2708
|
+
assert.ok(end > start, "diaryBucketName must sit before the panel");
|
|
2709
|
+
// 注释里本来就要出现 `persona: default (ptc)` 这个反例,只审代码行。
|
|
2710
|
+
const body = clientSource.slice(start, end).split("\n")
|
|
2711
|
+
.filter((line) => !line.trim().startsWith("//")).join("\n");
|
|
2712
|
+
const fn = new Function(body + "\n return diaryBucketName;")();
|
|
2713
|
+
const store = {
|
|
2714
|
+
default: { name: "Amarre" },
|
|
2715
|
+
byAgent: { ptc: null, studio: { name: "Studio" } },
|
|
2716
|
+
bySession: {}
|
|
2717
|
+
};
|
|
2718
|
+
// 括号里是 agent 名:必须换成人格卡的名字,不是 agent 名,也不是桶键。
|
|
2719
|
+
assert.equal(fn({ key: "amarre-c836db6a", project: "amarre-c836db6a", persona: "default (ptc)" }, store), "Amarre",
|
|
2720
|
+
"an agent tag must resolve to the default card's name, not to 'ptc'");
|
|
2721
|
+
assert.equal(fn({ key: "x-1", project: "x-1", persona: "settings (studio)" }, store), "Studio",
|
|
2722
|
+
"a named card must win over the default card");
|
|
2723
|
+
// 还没挂卡(或卡被删了)时走默认卡,这是写日记时真实的回落口径。
|
|
2724
|
+
assert.equal(fn({ key: "y-2", project: "y-2", persona: "settings (gone)" }, store), "Amarre",
|
|
2725
|
+
"a missing agent card falls back to the default card, the same way writing does");
|
|
2726
|
+
// 没有 store(人格卡还没读到)时绝不把 agent 名顶上,退回项目名。
|
|
2727
|
+
assert.equal(fn({ key: "z-3", project: "z-3", persona: "default (ptc)" }, null), "z-3",
|
|
2728
|
+
"without a card store the name must fall back to the project, never leak the agent tag");
|
|
2729
|
+
// 完全没有 persona 行的老日记:项目名,再不行桶键。
|
|
2730
|
+
assert.equal(fn({ key: "old-4", project: "老项目", persona: "" }, store), "老项目");
|
|
2731
|
+
assert.equal(fn({ key: "only-5", project: "", persona: "" }, store), "only-5");
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
test("diary book: paper choice is one shared setting across both entries", () => {
|
|
2735
|
+
// 一个 localStorage 键、一处读、一处写:两个入口看的是同一本日记。
|
|
2736
|
+
assert.ok(clientSource.includes('const DIARY_BOOK_KEY = "dsh-mneme-diary-book"'),
|
|
2737
|
+
"the paper choice needs a single storage key");
|
|
2738
|
+
assert.equal((clientSource.match(/localStorage\.getItem\(DIARY_BOOK_KEY\)/g) || []).length, 1,
|
|
2739
|
+
"reading the paper must go through readDiaryBook only");
|
|
2740
|
+
assert.ok(clientSource.includes("function readDiaryBook()") && clientSource.includes("function writeDiaryBook("),
|
|
2741
|
+
"read and write helpers must both exist");
|
|
2742
|
+
// 右栏 DiaryPane 也要套同一套纸:只改左面板等于两处观感打架。
|
|
2743
|
+
const pane = clientSource.slice(clientSource.indexOf("function DiaryPane("), clientSource.indexOf("function DiaryLibraryPanel("));
|
|
2744
|
+
assert.ok(pane.includes("readDiaryBook"), "the right-side pane must read the same paper choice");
|
|
2745
|
+
assert.ok(/mneme-diary-paper mneme-diary-paper--" \+ book/.test(pane),
|
|
2746
|
+
"the right-side pane must apply the selected paper texture");
|
|
2747
|
+
// 同页另一处入口收不到 storage 事件(那只在别的标签页触发),必须自己广播。
|
|
2748
|
+
assert.ok(clientSource.includes('new CustomEvent("mneme:diary-book-changed")'),
|
|
2749
|
+
"a same-page change must be broadcast, storage events never fire in the same document");
|
|
2750
|
+
assert.ok(clientSource.includes('addEventListener("mneme:diary-book-changed"'),
|
|
2751
|
+
"the other entry must listen for that broadcast");
|
|
2752
|
+
// 三种纸都要有文案,否则按钮上是裸 key(牛皮已删:它只是同一层上色的换色相,
|
|
2753
|
+
// 与素纸差 1.01:1,且是这块唯一硬编码 hex)。
|
|
2754
|
+
for (const key of ["memory.diary.bookLabel", "memory.diary.book.plain", "memory.diary.book.ruled",
|
|
2755
|
+
"memory.diary.book.grid"]) {
|
|
2756
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2757
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2758
|
+
}
|
|
2759
|
+
// 本子观感:正文用衬线、行高与纸纹 26px 网格对齐,否则横线会穿字。
|
|
2760
|
+
// 源文件里这段 CSS 在 JS 字符串内,引号是转义的;这里只钉字体族的骨架。
|
|
2761
|
+
assert.ok(clientSource.includes("font-family:Georgia,") && clientSource.includes("Songti SC") &&
|
|
2762
|
+
clientSource.includes("Noto Serif SC") && clientSource.includes("serif"),
|
|
2763
|
+
"the diary body needs a serif typeface to read like a notebook");
|
|
2764
|
+
assert.ok(clientSource.includes("background-size:100% 26px"),
|
|
2765
|
+
"the ruled texture must be locked to the 26px line height");
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
// 2026-11-11 查出来的真 bug:四条纸面风格(--ruled/--grid/--kraft/--plain)与基础规则
|
|
2769
|
+
// `.mneme-diary-paper` 同为 (0,1,0)、基础规则在数组里更靠后、且用 background 简写——
|
|
2770
|
+
// 前四条设的 background-image/color 被整条重置,四种纸实测全是 background-image:none,
|
|
2771
|
+
// 所以「几种风格毫无变化」不是没做完。修法两层:纹理挪到 .mneme-diary-text(后代选择器,
|
|
2772
|
+
// 结构上不再和基础规则抢同一个元素),基础规则只用 background-color。
|
|
2773
|
+
test("diary paper: 纹理挂在正文元素上,基础规则不许退回 background 简写", () => {
|
|
2774
|
+
const at = clientSource.indexOf('".mneme-diary-paper{');
|
|
2775
|
+
assert.ok(at > -1, "the base paper rule must exist");
|
|
2776
|
+
const rule = clientSource.slice(at + 1, clientSource.indexOf('",', at));
|
|
2777
|
+
assert.ok(rule.includes("background-color:"),
|
|
2778
|
+
"the base rule must paint its tint with background-color");
|
|
2779
|
+
assert.ok(!/(^|;|\s)background:/.test(rule),
|
|
2780
|
+
"the base rule must not use the background shorthand — it resets every texture above it");
|
|
2781
|
+
for (const key of ["--ruled", "--grid"]) {
|
|
2782
|
+
assert.ok(clientSource.includes(".mneme-diary-paper" + key + " .mneme-diary-text{"),
|
|
2783
|
+
"the " + key + " texture must hang off .mneme-diary-text, not off the base class");
|
|
2784
|
+
}
|
|
2785
|
+
assert.ok(clientSource.includes("--mneme-rule:"),
|
|
2786
|
+
"the rule colour needs its own token (border-l1 is 1.09:1 on paper — invisible)");
|
|
2787
|
+
assert.ok(!clientSource.includes("memory.diary.book.kraft"),
|
|
2788
|
+
"kraft was removed; it only differed from plain by hue (1.01:1) and carried the block's only hardcoded hex");
|
|
2789
|
+
});
|
|
2790
|
+
|
|
2791
|
+
// 走查发现:savedAt 只写不清 —— 保存成功后再改字段仍显示「已保存」,先成功后失败会
|
|
2792
|
+
// 「已保存」+「保存失败」两行并存。三处清零缺一处就会复现,所以按处数锁。
|
|
2793
|
+
test("persona save: 改任何字段都把「已保存」收回去(假成功回归锁)", () => {
|
|
2794
|
+
const zeros = (clientSource.match(/setSavedAt\(0\)/g) || []).length;
|
|
2795
|
+
assert.ok(zeros >= 3, "save 开头 / setField / toggleEnabled 三处都要清零(现有 " + zeros + " 处)");
|
|
2796
|
+
assert.ok(clientSource.includes("setSavedAt(Date.now())"), "the success path still stamps the time");
|
|
2797
|
+
});
|
|
2798
|
+
|
|
2799
|
+
// 窄侧栏(约 400px)下日记与人格原来是横向分栏:正文纸被挤到 250px、人格编辑区
|
|
2800
|
+
// 只剩 180px;六个页签还会各自折成两行。这两条都在 480 档容器查询里修,锁住防回退。
|
|
2801
|
+
test("窄容器:日记与人格上下堆叠,页签不折行", () => {
|
|
2802
|
+
const at = clientSource.indexOf('"@container (max-width: 480px){');
|
|
2803
|
+
assert.ok(at > -1, "the 480 container query must exist");
|
|
2804
|
+
const block = clientSource.slice(at, clientSource.indexOf('"', at + 2));
|
|
2805
|
+
assert.ok(block.includes(".mneme-diary-body{flex-direction:column}"),
|
|
2806
|
+
"the diary list + paper must stack in a narrow panel");
|
|
2807
|
+
assert.ok(block.includes(".mneme-persona-body{flex-direction:column}"),
|
|
2808
|
+
"the persona card rail + editor must stack in a narrow panel");
|
|
2809
|
+
assert.ok(block.includes("white-space:nowrap}"),
|
|
2810
|
+
"panel tabs must not wrap their label onto two lines");
|
|
2811
|
+
});
|
|
2812
|
+
|
|
2813
|
+
// 日记本不再占侧边栏座位(2026-11-09 桉桉:还保留旧版本的侧边栏打开,这个要去掉)。
|
|
2814
|
+
// 它现在是记忆面板内的一个标签页,和人格页并列;右栏那个入口是另一回事,保留。
|
|
2815
|
+
test("diary is a panel tab now, not a sidebar seat", () => {
|
|
2816
|
+
assert.ok(!clientSource.includes('id: "dsh-mneme-diary"') && !clientSource.includes('key: "dsh-mneme-diary"'),
|
|
2817
|
+
"the sidebar seat pair must be gone");
|
|
2818
|
+
assert.ok(!clientSource.includes("disposeDiaryGlyph") && !clientSource.includes("disposeDiaryMain"),
|
|
2819
|
+
"the seat disposers must be gone too, not just left dangling");
|
|
2820
|
+
// 面板内必须有 diary 这个标签页,否则入口只剩下右栏那一个。
|
|
2821
|
+
assert.match(clientSource, /\{ key: "diary", label: t\("memory\.diary\.title"\) \}/,
|
|
2822
|
+
"the memory panel must carry the diary tab");
|
|
2823
|
+
assert.match(clientSource, /view === "diary"[\s\S]{0,200}?DiaryLibraryPanel/,
|
|
2824
|
+
"the diary tab must render the library panel");
|
|
2825
|
+
// 右栏入口照旧保留:那是会话内的快速入口,不是侧边栏座位。
|
|
2826
|
+
assert.ok(clientSource.includes("function registerDiarySeats(ctx, t) {"),
|
|
2827
|
+
"the right-sidebar entry must stay");
|
|
2828
|
+
});
|
|
2829
|
+
|
|
2830
|
+
// v0.13.0 第一次发出去时右栏页签类型注册了、正文座位也注册了,却没有任何一个
|
|
2831
|
+
// 能调 openTab 的按钮——页签永远打不开。当时的修法是往会话头塞一个入口按钮;
|
|
2832
|
+
// 2026-11-09 桉桉又要求把它撤掉:日记已经进了记忆面板当标签页,会话头顶再挂一个
|
|
2833
|
+
// 指向右栏的按钮,就是同一件事有两个入口。所以现在锁的是「没有第二个入口」。
|
|
2834
|
+
test("diary has exactly one entry: the panel tab, not a session header button", () => {
|
|
2835
|
+
const start = clientSource.indexOf("function registerDiarySeats(ctx, t) {");
|
|
2836
|
+
const end = clientSource.indexOf("function apply(ctx) {", start);
|
|
2837
|
+
assert.ok(start > -1 && end > start, "registerDiarySeats must sit before apply()");
|
|
2838
|
+
const body = clientSource.slice(start, end);
|
|
2839
|
+
|
|
2840
|
+
// ① 会话头入口必须彻底消失:座位、按钮组件、打开动作、失败文案一个都不留。
|
|
2841
|
+
assert.ok(
|
|
2842
|
+
!body.includes("conversation.session.header.actions"),
|
|
2843
|
+
"the diary must not register a second entry in the session header"
|
|
2844
|
+
);
|
|
2845
|
+
assert.ok(!body.includes("openPane"), "the openPane helper only existed for the header button");
|
|
2846
|
+
assert.ok(!body.includes("hasPane"), "hasPane only gated the header button");
|
|
2847
|
+
assert.ok(
|
|
2848
|
+
!/function DiaryEntryButton\s*\(/.test(clientSource),
|
|
2849
|
+
"the header entry component must be gone, not just unregistered"
|
|
2850
|
+
);
|
|
2851
|
+
assert.ok(
|
|
2852
|
+
!clientSource.includes("memory.diary.openFailed"),
|
|
2853
|
+
"the \"cannot open\" copy is dead once nothing can fail to open it"
|
|
2854
|
+
);
|
|
2855
|
+
|
|
2856
|
+
// ② 右栏那套照旧:页签类型 + 正文 + 标题都还在(宿主若有自己的页签选择器仍可达)。
|
|
2857
|
+
assert.ok(body.includes('ctx.get("sidebarRightTabs")'), "tab type registration stays");
|
|
2858
|
+
assert.ok(body.includes('name: "sidebar.right.pane.tab"'), "pane body stays registered");
|
|
2859
|
+
assert.ok(body.includes('name: "sidebar.right.pane.tab.title"'), "pane title stays registered");
|
|
2860
|
+
});
|
|
2861
|
+
|
|
2234
2862
|
test("M.O. empty-state, list and scope copy exist in both languages", () => {
|
|
2235
2863
|
for (const key of [
|
|
2236
2864
|
"memory.actions.empty.title",
|
|
@@ -2388,6 +3016,21 @@ test("panel copy and icons must not silently vanish (missing dict lines, unregis
|
|
|
2388
3016
|
for (const b of expr.matchAll(/[?:]\s*"([a-zA-Z]+)"/g)) iconNames.add(b[1]);
|
|
2389
3017
|
}
|
|
2390
3018
|
for (const m of clientSource.matchAll(/feedRow\(\s*"([a-zA-Z]+)"/g)) iconNames.add(m[1]);
|
|
3019
|
+
// 页签图标走 TAB_ICONS 查表(子视图 key -> 图标名),不是 h(Icon, ...) 字面量,
|
|
3020
|
+
// 上面两条提取器看不见它。这里把表里的值按 ICON_PATHS 的同形状收进来——否则
|
|
3021
|
+
// 改表时把某个 key 指向未注册名字,闸门照绿而屏幕上那格图标是空的。
|
|
3022
|
+
const tabTable = clientSource.match(/const TAB_ICONS = \{((?:[^}]|\n)*?)\}/);
|
|
3023
|
+
assert.ok(tabTable, "TAB_ICONS must be parseable (subview key -> icon name)");
|
|
3024
|
+
const tabEntries = [...tabTable[1].matchAll(/([a-zA-Z]+):\s*"([a-zA-Z]+)"/g)];
|
|
3025
|
+
assert.ok(tabEntries.length >= 7, `TAB_ICONS must cover every subview (saw ${tabEntries.length})`);
|
|
3026
|
+
for (const m of tabEntries) iconNames.add(m[2]);
|
|
3027
|
+
// 反向:每个 subviews 的 key 都得在表里有一行,否则那一格掉到 ?? "settings" 上。
|
|
3028
|
+
const subviewsBlock = clientSource.match(/const subviews = \[((?:[^\]]|\n)*?)\]/);
|
|
3029
|
+
assert.ok(subviewsBlock, "subviews must be parseable");
|
|
3030
|
+
const subviewKeys = [...subviewsBlock[1].matchAll(/key: "([a-zA-Z]+)"/g)].map((m) => m[1]);
|
|
3031
|
+
assert.ok(subviewKeys.length >= 7, `subviews must be parseable (saw ${subviewKeys.length})`);
|
|
3032
|
+
const unmapped = subviewKeys.filter((k) => !tabEntries.some((m) => m[1] === k));
|
|
3033
|
+
assert.deepEqual(unmapped, [], `页签没有图标映射(会退化成设置齿轮):${unmapped.join(", ")}`);
|
|
2391
3034
|
assert.ok(iconNames.size >= 8, `icon usage must be parseable (saw ${iconNames.size})`);
|
|
2392
3035
|
const noIcon = [...iconNames].filter((n) => !registered.has(n)).sort();
|
|
2393
3036
|
assert.deepEqual(noIcon, [], `Icon 静默 return null 的未注册名:${noIcon.join(", ")}`);
|
|
@@ -2395,94 +3038,104 @@ test("panel copy and icons must not silently vanish (missing dict lines, unregis
|
|
|
2395
3038
|
|
|
2396
3039
|
// 人格定义页(beta)面板面回归:形状沿用 A4 的「分组 + i18n 双语 occurrences ≥ 2
|
|
2397
3040
|
// + 关键接线」断言风格(只做源码断言,不挂 React 渲染环境)。
|
|
2398
|
-
test("persona page
|
|
2399
|
-
// 开关进 FEATURE_GROUPS 的 enhance
|
|
2400
|
-
//
|
|
2401
|
-
|
|
2402
|
-
|
|
3041
|
+
test("persona page is a panel tab, not a settings card (beta v2)", () => {
|
|
3042
|
+
// 开关进 FEATURE_GROUPS 的 persona 组(#427 revamp:从 enhance 拆出)。按组解析
|
|
3043
|
+
// 成员再逐个点名,而不是把某一行写成字面量正则——那样往组里加第二个键就会把
|
|
3044
|
+
// 这条护栏弄红(实测踩过)。
|
|
3045
|
+
const personaGroup = clientSource.match(/group\.persona", items: \[((?:[^\]]|\n)*)\]/);
|
|
3046
|
+
assert.ok(personaGroup, "FEATURE_GROUPS group.persona must be parseable");
|
|
2403
3047
|
for (const flag of ["personaEnabled", "personaStrictAddressing"]) {
|
|
2404
|
-
assert.ok(
|
|
3048
|
+
assert.ok(personaGroup[1].includes(`"${flag}"`), `FEATURE_GROUPS group.persona must carry ${flag}`);
|
|
2405
3049
|
}
|
|
2406
3050
|
// i18n 键中英双语齐备(occurrences ≥ 2 模式;label + .hint 两路由字典锁统一覆盖)
|
|
2407
3051
|
for (const key of [
|
|
2408
3052
|
"memory.features.personaEnabled",
|
|
2409
3053
|
"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
3054
|
"memory.features.personaStrictAddressing",
|
|
2423
3055
|
"memory.features.personaStrictAddressing.hint",
|
|
2424
|
-
"memory.
|
|
2425
|
-
"memory.
|
|
2426
|
-
"memory.
|
|
2427
|
-
"memory.
|
|
2428
|
-
"memory.
|
|
2429
|
-
"memory.
|
|
2430
|
-
"memory.
|
|
3056
|
+
"memory.explorer.tabPersona",
|
|
3057
|
+
"memory.persona.title",
|
|
3058
|
+
"memory.persona.hint",
|
|
3059
|
+
"memory.persona.cards",
|
|
3060
|
+
"memory.persona.defaultCard",
|
|
3061
|
+
"memory.persona.name",
|
|
3062
|
+
"memory.persona.personality",
|
|
3063
|
+
"memory.persona.replyStyle",
|
|
3064
|
+
"memory.persona.scenario",
|
|
3065
|
+
"memory.persona.mesExample",
|
|
3066
|
+
"memory.persona.save",
|
|
3067
|
+
"memory.persona.saved",
|
|
3068
|
+
"memory.persona.saveFailed",
|
|
3069
|
+
"memory.persona.enabled",
|
|
3070
|
+
"memory.persona.preview",
|
|
3071
|
+
"memory.persona.previewEmpty",
|
|
3072
|
+
"memory.persona.previewUnknown",
|
|
3073
|
+
"memory.persona.diary",
|
|
3074
|
+
"memory.persona.diaryHint",
|
|
3075
|
+
"memory.persona.loading"
|
|
2431
3076
|
]) {
|
|
2432
3077
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2433
3078
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2434
3079
|
}
|
|
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("
|
|
3080
|
+
// 形态:面板自己的第五个标签页,设置页那张卡搬走(不是并存)
|
|
3081
|
+
assert.ok(clientSource.includes('{ key: "persona", label: t("memory.explorer.tabPersona") }'),
|
|
3082
|
+
"the persona view must be registered in the panel's subviews");
|
|
3083
|
+
assert.ok(clientSource.includes('view === "persona" &&'), "the panel must route a persona view branch");
|
|
3084
|
+
assert.ok(clientSource.includes("h(PersonaMainPanel, { t })"), "the persona tab must render PersonaMainPanel");
|
|
3085
|
+
assert.ok(!clientSource.includes('t("memory.settings.persona")'),
|
|
3086
|
+
"the settings card must be gone, not duplicated next to the new page");
|
|
3087
|
+
// 只读卡「Agent 眼中的你」的职能并进本页预览:它自己的键与渲染都不许残留
|
|
3088
|
+
assert.ok(!clientSource.includes("memory.settings.personaView"),
|
|
3089
|
+
"the read-only injected-view card must be removed; this page's preview owns that job");
|
|
3090
|
+
// 保存走 PUT /persona,且必须三维全量(settings.setPersona 是全量替换)
|
|
3091
|
+
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/persona"'), "persona page must talk to /persona");
|
|
3092
|
+
assert.ok(clientSource.includes('method: "PUT"'), "persona save must PUT");
|
|
3093
|
+
assert.ok(clientSource.includes("bySession"), "the store shape must carry bySession through saves");
|
|
3094
|
+
// 预览必须由后端渲染:前端再拼一份就是第二张皮
|
|
3095
|
+
assert.ok(clientSource.includes("preview.text"), "the preview must render the backend-rendered block");
|
|
2447
3096
|
assert.ok(!clientSource.includes('"- " + profile.trim()'), "frontend hand-splicing of the injected block must be gone");
|
|
2448
|
-
|
|
3097
|
+
// 读不到就说读不到:失败不许停在上一张卡的旧文本
|
|
3098
|
+
assert.ok(clientSource.includes("setPreview(null)"), "a failed preview must fail closed to 未知, not to a stale block");
|
|
2449
3099
|
// 预览只能有一个所有者,且要丢掉过期响应:先切卡 A 再切卡 B 时,A 的 GET 可能后回来。
|
|
2450
|
-
// 这张卡的标题是「注入生效文本」,显示另一张卡的文本比不显示更糟。
|
|
2451
3100
|
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");
|
|
3101
|
+
// #514:轻量档压制要有告示,且这一页自己得说(人是在这里开关人格的)
|
|
3102
|
+
assert.ok(clientSource.includes('t("memory.status.minimalPresetTitle")'), "the page must carry the minimal-preset notice");
|
|
2455
3103
|
});
|
|
2456
3104
|
|
|
2457
|
-
|
|
2458
|
-
// aria-label 看不见)、保存失败必须冒可见错误(v1 静默 catch 吞 404 教训)。
|
|
2459
|
-
test("persona card is multi-card with visible field labels and save-failure error", () => {
|
|
3105
|
+
test("persona page is multi-card with visible field labels and save-failure error", () => {
|
|
2460
3106
|
for (const key of [
|
|
2461
|
-
"memory.
|
|
2462
|
-
"memory.
|
|
2463
|
-
"memory.
|
|
2464
|
-
"memory.
|
|
2465
|
-
"memory.
|
|
2466
|
-
"memory.
|
|
2467
|
-
"memory.
|
|
2468
|
-
"memory.
|
|
2469
|
-
"memory.
|
|
3107
|
+
"memory.persona.cards",
|
|
3108
|
+
"memory.persona.defaultCard",
|
|
3109
|
+
"memory.persona.add",
|
|
3110
|
+
"memory.persona.newPlaceholder",
|
|
3111
|
+
"memory.persona.delete",
|
|
3112
|
+
"memory.persona.saveFailed",
|
|
3113
|
+
"memory.persona.saved",
|
|
3114
|
+
"memory.persona.enabledOn",
|
|
3115
|
+
"memory.persona.enabledOff"
|
|
2470
3116
|
]) {
|
|
2471
3117
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2472
3118
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2473
3119
|
}
|
|
2474
|
-
//
|
|
2475
|
-
for (const fn of ["
|
|
3120
|
+
// 多卡接线:加卡/删卡/改字段/保存全链函数存在
|
|
3121
|
+
for (const fn of ["addCard", "removeCard", "saveEditing", "setField"]) {
|
|
2476
3122
|
assert.ok(clientSource.includes(fn), `multi-card wiring must include ${fn}`);
|
|
2477
3123
|
}
|
|
2478
|
-
//
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
3124
|
+
// 可见字段标签:五处字段都走同一个 field() helper,helper 里必须是真 <label>
|
|
3125
|
+
// (v1.1 反馈:aria-label 看不见)。分别锁「字段被渲染成 label」与「builder 用 label 元素」。
|
|
3126
|
+
for (const f of ["name", "personality", "replyStyle", "scenario", "mesExample"]) {
|
|
3127
|
+
assert.ok(
|
|
3128
|
+
new RegExp('field\\("' + f + '", t\\("memory\\.persona\\.' + f + '"\\)').test(clientSource),
|
|
3129
|
+
"persona field " + f + " must go through the labeled field builder"
|
|
3130
|
+
);
|
|
3131
|
+
}
|
|
3132
|
+
assert.ok(/h\("label", \{ className: "mneme-persona-label", htmlFor:/.test(clientSource),
|
|
3133
|
+
"the field builder must render a real label element, not an aria-only name");
|
|
3134
|
+
// 保存失败提示:error 状态有渲染路径(非仅 setState)
|
|
3135
|
+
assert.ok(clientSource.includes('t("memory.persona.saveFailed")'), "save failure must render an error line");
|
|
2483
3136
|
// 卡删除 × 常显回归锁 v2(#457 内联覆写被 #459 回滚的教训):× 不得复用
|
|
2484
3137
|
// mneme-set-del(opacity:0 仅在 .mneme-set-row:hover 解除,卡标签不在该容器
|
|
2485
|
-
// → 永远不可见),必须用独立类 mneme-persona-del 且该类常显(opacity
|
|
3138
|
+
// → 永远不可见),必须用独立类 mneme-persona-del 且该类常显(opacity 不得为 0)。
|
|
2486
3139
|
assert.ok(
|
|
2487
3140
|
/className: "mneme-persona-del"/.test(clientSource),
|
|
2488
3141
|
"persona card delete × must use the dedicated always-visible class"
|
|
@@ -2492,11 +3145,12 @@ test("persona card is multi-card with visible field labels and save-failure erro
|
|
|
2492
3145
|
assert.ok(!/opacity:0/.test(delStyle[0]), "mneme-persona-del must not be transparent");
|
|
2493
3146
|
// 不许再把 mneme-set-del 挂回卡删除按钮(防 #457 式修法回归);直接锁按钮
|
|
2494
3147
|
// 的 className 值(onClick 前最近的 className 属性,注释文字不参与)。
|
|
2495
|
-
const delBtn = clientSource.match(/className: "([^"]+)"[\s\S]{0,300}?onClick: \(\) =>
|
|
3148
|
+
const delBtn = clientSource.match(/className: "([^"]+)"[\s\S]{0,300}?onClick: \(\) => removeCard\(id\)/);
|
|
2496
3149
|
assert.ok(delBtn, "card delete button wiring must exist");
|
|
2497
3150
|
assert.strictEqual(delBtn[1], "mneme-persona-del", "card delete × must use mneme-persona-del, not mneme-set-del");
|
|
2498
3151
|
});
|
|
2499
3152
|
|
|
3153
|
+
|
|
2500
3154
|
// 回归锁(复验实测踩中):面板「检测更新」的状态名必须与后端 classify
|
|
2501
3155
|
// (src/version-check.js)逐字对齐。此前前端误写 "latest"/"up_to_date"(后端
|
|
2502
3156
|
// 从未返回过),检测成功也掉进 error 兜底——按钮永远报「检测失败」。
|
|
@@ -2532,6 +3186,28 @@ test("update-check status names match the backend classify contract verbatim", (
|
|
|
2532
3186
|
|
|
2533
3187
|
// 画像提取草稿(#497)面板面:路由对齐、四种结局各有自己的话、按钮坐在哪张卡上、
|
|
2534
3188
|
// 以及草稿**只出副本**(2026-11-04 改口径:不再把草稿写进画像字段)。
|
|
3189
|
+
// 严格寻址的「第一张卡」陷阱(Persis 复验):live 库 personaStrictAddressing=true,
|
|
3190
|
+
// 现在 byAgent/bySession 都空,走 #587 例外回落 default,所以注入正常。但只要在这个
|
|
3191
|
+
// 页面建出第一张卡,anyCard 就变 true,strict 立刻生效——所有没建卡的会话(含主会话)
|
|
3192
|
+
// 人格静默不再注入,界面一个字都不报。这种「改一个开关,别处的行为跟着变且无提示」
|
|
3193
|
+
// 必须在建卡之前就写在脸上,所以锁三件事:①两个阶段的文案都在;②按现有卡数选话;
|
|
3194
|
+
// ③文案确实渲染进人格页,而不是定义完没人读。
|
|
3195
|
+
test("strict addressing warns before the first card quietly changes everyone else", () => {
|
|
3196
|
+
for (const key of ["memory.persona.strictBefore", "memory.persona.strictAfter"]) {
|
|
3197
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
3198
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
3199
|
+
}
|
|
3200
|
+
// 生效值来自 /features 的真实快照,不许前端猜默认
|
|
3201
|
+
assert.ok(/personaStrictAddressing === true/.test(clientSource),
|
|
3202
|
+
"the guardrail must read the effective flag from /features, not assume a default");
|
|
3203
|
+
// 两条话按「现在有几张卡」分流:没有卡时说「建第一张会变严」,有了之后说「已经变严」
|
|
3204
|
+
assert.ok(clientSource.includes('t(cardCount > 0 ? "memory.persona.strictAfter" : "memory.persona.strictBefore")'),
|
|
3205
|
+
"the notice must pick its wording from the current card count");
|
|
3206
|
+
// 只有严格寻址真的开着才说,否则是假警报
|
|
3207
|
+
assert.ok(/strict \? h\("div"/.test(clientSource),
|
|
3208
|
+
"the notice must be gated on the effective strict flag");
|
|
3209
|
+
});
|
|
3210
|
+
|
|
2535
3211
|
test("profile extraction ships its button, distinct outcomes and correct save wiring", () => {
|
|
2536
3212
|
const apiSource = readFileSync(join(root, "src/api.js"), "utf8");
|
|
2537
3213
|
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/profile/extract"'),
|
|
@@ -2564,20 +3240,25 @@ test("profile extraction ships its button, distinct outcomes and correct save wi
|
|
|
2564
3240
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2565
3241
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2566
3242
|
}
|
|
2567
|
-
assert.ok(/group\.
|
|
2568
|
-
"FEATURE_GROUPS group.
|
|
2569
|
-
|
|
2570
|
-
// 位置锁(2026-11-
|
|
2571
|
-
//
|
|
2572
|
-
//
|
|
2573
|
-
|
|
2574
|
-
const
|
|
2575
|
-
const
|
|
2576
|
-
assert.ok(
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
3243
|
+
assert.ok(/group\.persona[\s\S]{0,700}"profileExtractEnabled"/.test(clientSource),
|
|
3244
|
+
"FEATURE_GROUPS group.persona must carry profileExtractEnabled");
|
|
3245
|
+
|
|
3246
|
+
// 位置锁(2026-11-09 再改口径):提取按钮属于「用户画像」卡,而画像卡本身
|
|
3247
|
+
// 已经从设置页搬进记忆面板的「人格」页(桉桉:mneme 设置里面的个性化还没移走)。
|
|
3248
|
+
// 两处都在同一份客户端里,光比先后会被设置页的规则卡骗过——必须先切出
|
|
3249
|
+
// 人格页那一段再比,并额外断言设置页里不再有画像卡(搬回去就红)。
|
|
3250
|
+
const personaStart = clientSource.indexOf("function PersonaMainPanel(");
|
|
3251
|
+
const settingsStart = clientSource.indexOf("function SettingsContent(");
|
|
3252
|
+
assert.ok(settingsStart > 0 && personaStart > settingsStart, "both panels must exist");
|
|
3253
|
+
const settingsSlice = clientSource.slice(settingsStart, personaStart);
|
|
3254
|
+
const personaSlice = clientSource.slice(personaStart);
|
|
3255
|
+
const profileTitle = 'h("div", { className: "mneme-set-title" }, t("memory.settings.profile"))';
|
|
3256
|
+
assert.ok(!settingsSlice.includes(profileTitle),
|
|
3257
|
+
"the profile card must be gone from the settings page (it moved into the persona page)");
|
|
3258
|
+
const profileCardIdx = personaSlice.indexOf(profileTitle);
|
|
3259
|
+
const extractButtonIdx = personaSlice.indexOf('t("memory.settings.profileExtract")');
|
|
3260
|
+
assert.ok(profileCardIdx > 0 && extractButtonIdx > profileCardIdx,
|
|
3261
|
+
"the extract button must render inside the user-profile card on the persona page");
|
|
2581
3262
|
// 只出副本(2026-11-04 桉桉定的口径):草稿不许再走落库路径。此前 applyProfileDraft 会
|
|
2582
3263
|
// 直接 PUT /profile —— 把用户手写的那份覆盖掉,且没有撤销。
|
|
2583
3264
|
assert.ok(clientSource.includes("function copyProfileDraft()"), "the draft action must be a copy, not a save");
|
|
@@ -2648,32 +3329,44 @@ test("profile facts card ships management UI, run outcomes and never touches the
|
|
|
2648
3329
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2649
3330
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2650
3331
|
}
|
|
2651
|
-
assert.ok(/group\.
|
|
2652
|
-
"FEATURE_GROUPS group.
|
|
2653
|
-
|
|
2654
|
-
//
|
|
2655
|
-
//
|
|
2656
|
-
const
|
|
2657
|
-
const
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
3332
|
+
assert.ok(/group\.persona[\s\S]{0,1200}"profileFactsEnabled"/.test(clientSource),
|
|
3333
|
+
"FEATURE_GROUPS group.persona must carry profileFactsEnabled");
|
|
3334
|
+
|
|
3335
|
+
// 位置锁(2026-11-09 改口径):画像卡与事实卡一起搬进了人格页,事实卡仍是画像卡
|
|
3336
|
+
// 之后的一张。切段比较,防止设置页的规则卡把顺序骗过去;设置页里两张卡都不许再有。
|
|
3337
|
+
const personaStart = clientSource.indexOf("function PersonaMainPanel(");
|
|
3338
|
+
const settingsStart = clientSource.indexOf("function SettingsContent(");
|
|
3339
|
+
assert.ok(settingsStart > 0 && personaStart > settingsStart, "both panels must exist");
|
|
3340
|
+
const settingsSlice = clientSource.slice(settingsStart, personaStart);
|
|
3341
|
+
const personaSlice = clientSource.slice(personaStart);
|
|
3342
|
+
assert.ok(!settingsSlice.includes('t("memory.settings.factsTitle")'),
|
|
3343
|
+
"the facts card must be gone from the settings page (it moved into the persona page)");
|
|
3344
|
+
assert.ok(!settingsSlice.includes('h("div", { className: "mneme-set-title" }, t("memory.settings.profile"))'),
|
|
3345
|
+
"the profile card must be gone from the settings page (it moved into the persona page)");
|
|
3346
|
+
const profileCardIdx = personaSlice.indexOf('h("div", { className: "mneme-set-title" }, t("memory.settings.profile"))');
|
|
3347
|
+
const factsCardIdx = personaSlice.indexOf('t("memory.settings.factsTitle")');
|
|
3348
|
+
assert.ok(profileCardIdx > 0 && factsCardIdx > profileCardIdx,
|
|
3349
|
+
"the facts card must sit after the user-profile card on the persona page");
|
|
2662
3350
|
// 手填画像神圣:事实域的全部面板代码(loadFacts 起到事实卡渲染止)里不许出现
|
|
2663
3351
|
// PUT /profile 的路径字面量。断言的是**带闭引号**的精确串——/profile-facts/* 的
|
|
2664
3352
|
// 路径都以 /profile 开头,不带闭引号的话这个断言会自己误伤自己。
|
|
2665
|
-
|
|
2666
|
-
|
|
3353
|
+
// 两个下标必须在同一个坐标系里比:factsCardIdx 是相对 personaSlice 的,
|
|
3354
|
+
// 而 handlersStart 相对整份源码——混用会切出空片,让下面每条断言都空跑。
|
|
3355
|
+
const handlersStart = personaSlice.indexOf("const loadFacts");
|
|
3356
|
+
const factsSlice = personaSlice.slice(handlersStart, factsCardIdx);
|
|
2667
3357
|
assert.ok(handlersStart > 0 && factsSlice.length > 0, "the facts handlers must exist");
|
|
2668
3358
|
assert.ok(!factsSlice.includes('"/api/dsh-mneme/profile"'),
|
|
2669
3359
|
"the facts domain must never write the hand-filled profile (PUT /profile is off-limits here)");
|
|
2670
3360
|
for (const fragment of ["/api/dsh-mneme/profile-facts?agent=", "/api/dsh-mneme/profile-facts/apply", "/api/dsh-mneme/profile-facts/run"]) {
|
|
2671
3361
|
assert.ok(factsSlice.includes(fragment), `the facts handlers must call ${fragment}`);
|
|
2672
3362
|
}
|
|
2673
|
-
// 人工 apply
|
|
2674
|
-
//
|
|
2675
|
-
|
|
2676
|
-
|
|
3363
|
+
// 人工 apply 成功后注入预览必须跟着刷新。2026-11-09 起画像卡与事实卡同在人格页,
|
|
3364
|
+
// 不再跨视图写别人的状态——事实卡仍然不直接调 loadPreview,刷新走人格页自己的
|
|
3365
|
+
// tick 哨兵;所以锁两条:①事实处理段不直接碰 loadPreview;②页面有 tick 刷新。
|
|
3366
|
+
assert.ok(!factsSlice.includes("loadPreview("),
|
|
3367
|
+
"the facts card must not write the persona view's preview state from another view");
|
|
3368
|
+
assert.ok(clientSource.includes("setPreviewTick((n) => n + 1)"),
|
|
3369
|
+
"the persona page must refetch its own preview after a save");
|
|
2677
3370
|
// 总闸快照要跟着功能开关走:FeaturesCard 即改即存并广播 mneme:features-changed,
|
|
2678
3371
|
// 事实卡监听重读——不然按提示开了「自动事实」,按钮还得关面板重开才解禁
|
|
2679
3372
|
// (评审 P2:快照 effect 是 [] 依赖,监听缺失等于回到「重开才生效」的老病)。
|
|
@@ -2905,6 +3598,52 @@ test("minimal preset notice reaches the settings page, not just the status tab (
|
|
|
2905
3598
|
assert.ok(!/minimalWarn\s*=\s*true/.test(clientSource), "the notice must never be hard-wired on");
|
|
2906
3599
|
});
|
|
2907
3600
|
|
|
3601
|
+
// 上面那条只证明"声明字符串存在",证明不了"每个消费点的作用域里有它"。
|
|
3602
|
+
// 人格页搬进 SettingsContent 之后,运行模式卡这一处读的是自由变量:渲染即 ReferenceError,
|
|
3603
|
+
// 而宿主只记一句 "import failed: client-modules: could not load @modusensus/dsh-mneme",
|
|
3604
|
+
// 看起来像整个插件加载不了,实际是一个 JSX 分支炸了(本机 crash-*-web-boot.log 实录)。
|
|
3605
|
+
// 不变量是"每个消费点的作用域里有声明",不是"声明数量等于消费点数量":
|
|
3606
|
+
// 同一个组件消费两次(SettingsContent 里两张卡各一处)只需要一份声明,按数量判会把正确代码钉红。
|
|
3607
|
+
test("each minimalWarn consumer resolves the declaration inside its own component", () => {
|
|
3608
|
+
const lines = clientSource.split("\n");
|
|
3609
|
+
const components = [];
|
|
3610
|
+
lines.forEach((line, i) => {
|
|
3611
|
+
const m = /^(?: | )(?:function |const )([A-Z]\w*)/.exec(line);
|
|
3612
|
+
if (m) components.push({ line: i + 1, name: m[1] });
|
|
3613
|
+
});
|
|
3614
|
+
const owner = function (at) {
|
|
3615
|
+
let found = null;
|
|
3616
|
+
for (const c of components) {
|
|
3617
|
+
if (c.line <= at) found = c;
|
|
3618
|
+
else break;
|
|
3619
|
+
}
|
|
3620
|
+
return found;
|
|
3621
|
+
};
|
|
3622
|
+
assert.ok(components.length > 0, "component scan matched nothing — bundle layout changed; fix this lock, don't delete it");
|
|
3623
|
+
|
|
3624
|
+
const decls = [];
|
|
3625
|
+
const uses = [];
|
|
3626
|
+
lines.forEach((line, i) => {
|
|
3627
|
+
if (!line.includes("minimalWarn") || line.trim().startsWith("//")) return;
|
|
3628
|
+
(/const minimalWarn\s*=/.test(line) ? decls : uses).push(i + 1);
|
|
3629
|
+
});
|
|
3630
|
+
assert.ok(uses.length >= 2, `expected the notice in both surfaces (got ${uses.length} use sites)`);
|
|
3631
|
+
|
|
3632
|
+
const byComponent = new Map();
|
|
3633
|
+
for (const d of decls) {
|
|
3634
|
+
const name = owner(d).name;
|
|
3635
|
+
if (!byComponent.has(name)) byComponent.set(name, []);
|
|
3636
|
+
byComponent.get(name).push(d);
|
|
3637
|
+
}
|
|
3638
|
+
for (const u of uses) {
|
|
3639
|
+
const c = owner(u);
|
|
3640
|
+
const inScope = (byComponent.get(c.name) || []).filter((d) => d < u);
|
|
3641
|
+
assert.ok(inScope.length > 0,
|
|
3642
|
+
`line ${u} reads minimalWarn inside ${c.name} (opens at ${c.line}) which has no declaration before it — ` +
|
|
3643
|
+
"renders as ReferenceError and the host reports only \"import failed\"");
|
|
3644
|
+
}
|
|
3645
|
+
});
|
|
3646
|
+
|
|
2908
3647
|
// --- 移动端适配(mobile-adapt 车道)---
|
|
2909
3648
|
// 面板在 320–430px 容器里原本是「骨架级失衡」而不是「略挤」:记忆页左栏固定
|
|
2910
3649
|
// 216px、卡片最小轨道 252+48=300px,要 ≥517px 才排得下;实体页 rail 224px +
|
|
@@ -3204,27 +3943,21 @@ test("panel exposes the bootstrap entry and the config-key coverage card (issue
|
|
|
3204
3943
|
);
|
|
3205
3944
|
// 菜单按钮的 onClick 里必须也关掉它,否则点「⋯」会留下上一个弹层。但它现在带一个
|
|
3206
3945
|
// 条件:引导请求在飞时不关(否则等于绕过弹层里那两道 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)。
|
|
3946
|
+
// #4452245292)。#558 起关闭同时把任务态回 idle——用户看得见这次关闭,terminal
|
|
3947
|
+
// 状态不留在 store 里(重开弹层要干净,#558 只保留没人看过的那份)。
|
|
3213
3948
|
assert.ok(
|
|
3214
|
-
/
|
|
3215
|
-
"the
|
|
3949
|
+
/setImportOpen\(false\);\s*\n\s*\/\/[\s\S]{0,300}?if \(!bootstrapBusy\) \{ resetBootstrapJob\(\); setBootstrapOpen\(false\); \}/.test(clientSource),
|
|
3950
|
+
"the overflow toggle must close the bootstrap dialog (and reset the job) when it is not busy"
|
|
3216
3951
|
);
|
|
3952
|
+
// 父组件的 busy 守卫必须真的有来源:#558 起它订阅模块级 bootstrapJob 派生
|
|
3953
|
+
// (prop 钻孔链拆了——tab 注册的 explorer 没有 onBusyChange,原本就报不上来)。
|
|
3217
3954
|
assert.ok(
|
|
3218
|
-
/
|
|
3219
|
-
"the
|
|
3955
|
+
/const bootstrapBusy = bootstrapJob\.phase === "running"/.test(clientSource),
|
|
3956
|
+
"the parent derives bootstrapBusy from the module-level job, or the guard above means nothing"
|
|
3220
3957
|
);
|
|
3221
|
-
// 这个转发函数必须同步调外层、且身份稳定:走 useEffect 转报的话要等渲染提交后
|
|
3222
|
-
// 才跑,中间那一拍外层读到 false(#4452539705 的老毛病);而若每渲染新建一个,
|
|
3223
|
-
// BootstrapDialog 的卸载 effect(依赖 [onBusyChange])会在父组件每次重渲染时
|
|
3224
|
-
// 先跑 cleanup 调一次 onBusyChange(false)——而父组件正是在置 busy 之后重渲染的。
|
|
3225
3958
|
assert.ok(
|
|
3226
|
-
/
|
|
3227
|
-
"the
|
|
3959
|
+
/function BootstrapDialog\(\{ t, onClose, onImported \}\)/.test(clientSource),
|
|
3960
|
+
"the dialog no longer takes onBusyChange: busy lives in the store, not in prop drilling"
|
|
3228
3961
|
);
|
|
3229
3962
|
// 会挂弹层的菜单项在忙碌时必须禁用(评审 #4452539738):新建 / 导入 / 引导 / 分享 /
|
|
3230
3963
|
// 接收共五处,加上「⋯」按钮自己(见下),再加上四个视图标签(评审 #4453639221,
|
|
@@ -3267,12 +4000,13 @@ test("panel exposes the bootstrap entry and the config-key coverage card (issue
|
|
|
3267
4000
|
const bsEnd = clientSource.indexOf("// --- 分享包弹层 ---", bsIdx);
|
|
3268
4001
|
const bsBody = clientSource.slice(bsIdx, bsEnd > bsIdx ? bsEnd : bsIdx + 4200);
|
|
3269
4002
|
assert.ok(bsIdx > 0, "BootstrapDialog must exist");
|
|
3270
|
-
//
|
|
3271
|
-
//
|
|
3272
|
-
//
|
|
4003
|
+
// 任务态必须**同步**进 store(评审 #4452539705 的结构化解法):#558 起三处守卫
|
|
4004
|
+
// (explorer 的「⋯」、overlay 的 Esc/背板/✕)都订阅同一份 bootstrapJob,submit
|
|
4005
|
+
// 里 setBootstrapJob(running) 一落,监听者同步被唤醒——不再有「等渲染提交后才
|
|
4006
|
+
// 追上」的那一拍。
|
|
3273
4007
|
assert.ok(
|
|
3274
|
-
/
|
|
3275
|
-
"
|
|
4008
|
+
/setBootstrapJob\(\{ phase: "running"[\s\S]{0,200}?apiFetch\("\/api\/dsh-mneme\/bootstrap"/.test(bsBody),
|
|
4009
|
+
"running must hit the store synchronously before the fetch leaves"
|
|
3276
4010
|
);
|
|
3277
4011
|
assert.ok(
|
|
3278
4012
|
/apiFetch\("\/api\/dsh-mneme\/bootstrap"/.test(bsBody),
|
|
@@ -3289,14 +4023,16 @@ test("panel exposes the bootstrap entry and the config-key coverage card (issue
|
|
|
3289
4023
|
/type: "text",[\s\S]{0,400}?disabled: busy,[\s\S]{0,400}?onChange: \(e\) => \{ setDir/.test(bsBody),
|
|
3290
4024
|
"the dir input must be disabled while the request is in flight"
|
|
3291
4025
|
);
|
|
3292
|
-
//
|
|
3293
|
-
//
|
|
3294
|
-
//
|
|
4026
|
+
// 成功路径把 done 写进 store 并保持 900ms 阅读窗(fresh done 期间提交钮锁住),
|
|
4027
|
+
// 且定时器句柄要能清:否则第二次提交的结果会写进已经卸载的组件(评审
|
|
4028
|
+
// #4452245256)。断言窗口取「onImported…到 setTimeout」之间——再往后就是 catch
|
|
4029
|
+
// 的网络错误兜底,那一处写 failed 是对的。
|
|
3295
4030
|
const doneIdx = bsBody.indexOf("memory.explorer.bootstrapDone");
|
|
3296
4031
|
const timerIdx = bsBody.indexOf("timerRef.current = setTimeout(");
|
|
3297
4032
|
assert.ok(timerIdx > doneIdx, "the success branch must schedule its close through the stored ref");
|
|
3298
4033
|
const successTail = bsBody.slice(doneIdx, timerIdx);
|
|
3299
|
-
assert.ok(
|
|
4034
|
+
assert.ok(/phase: "done"/.test(successTail), "the success path must land done in the store (survives tab close)");
|
|
4035
|
+
assert.ok(!/resetBootstrapJob\(\)/.test(successTail), "the success path must not reset the job during the reading window");
|
|
3300
4036
|
assert.ok(/clearTimeout\(timerRef\.current\)/.test(bsBody), "the stored timer must actually be cleared on unmount");
|
|
3301
4037
|
// 刷新必须同步调、不能排进定时器(评审 #4452539711):切视图会卸载本组件并把
|
|
3302
4038
|
// timerRef 清掉,若刷新也挂在那 900ms 后面,它就静默丢了——库已经写进去了,
|
|
@@ -3314,8 +4050,8 @@ test("panel exposes the bootstrap entry and the config-key coverage card (issue
|
|
|
3314
4050
|
// 错误按服务端的码分开说(missing-dir / dir-not-found / not-a-directory /
|
|
3315
4051
|
// bootstrap-unavailable),不能一律「引导失败」——三种情况下用户该做的事不同。
|
|
3316
4052
|
assert.ok(
|
|
3317
|
-
/"memory\.explorer\.bootstrapErr\." \+ String\(
|
|
3318
|
-
"error codes must be mapped to distinct messages"
|
|
4053
|
+
/"memory\.explorer\.bootstrapErr\." \+ String\(job\.error/.test(bsBody),
|
|
4054
|
+
"error codes must be mapped to distinct messages (code lives in the store, derived at render)"
|
|
3319
4055
|
);
|
|
3320
4056
|
for (const key of [
|
|
3321
4057
|
"memory.explorer.bootstrap",
|
|
@@ -3335,6 +4071,7 @@ test("panel exposes the bootstrap entry and the config-key coverage card (issue
|
|
|
3335
4071
|
`i18n key ${key} must exist in both dictionaries`
|
|
3336
4072
|
);
|
|
3337
4073
|
}
|
|
4074
|
+
// #558 的存活规则锁(在原测试内追加):见文件尾的 issue #558 专项测试。
|
|
3338
4075
|
|
|
3339
4076
|
// 第 7 条:配置键覆盖面只读卡。
|
|
3340
4077
|
const ckIdx = clientSource.indexOf("function ConfigKeysCard");
|
|
@@ -3395,14 +4132,16 @@ test("the memory overlay refuses to close while a library request is in flight (
|
|
|
3395
4132
|
const ovEnd = clientSource.indexOf("const EXPLORER_TYPES", ovIdx);
|
|
3396
4133
|
const ovBody = clientSource.slice(ovIdx, ovEnd > ovIdx ? ovEnd : ovIdx + 2600);
|
|
3397
4134
|
|
|
3398
|
-
//
|
|
4135
|
+
// 忙碌标志必须与 Explorer 同源:#558 起两处各自订阅模块级 bootstrapJob——
|
|
4136
|
+
// overlay 不再依赖子组件回报(tab 注册的 explorer 没有 onBusyChange,原本就
|
|
4137
|
+
// 报不上来,守卫对那条路径是空的)。
|
|
3399
4138
|
assert.ok(
|
|
3400
|
-
/
|
|
3401
|
-
"the overlay
|
|
4139
|
+
/useBootstrapJob\(\)/.test(ovBody) && /explorerBusy = bootstrapJob\.phase === "running"/.test(ovBody),
|
|
4140
|
+
"the overlay derives its busy flag from the module-level job store"
|
|
3402
4141
|
);
|
|
3403
4142
|
assert.ok(
|
|
3404
|
-
/h\(MemoryExplorer, \{ t
|
|
3405
|
-
"the explorer
|
|
4143
|
+
/h\(MemoryExplorer, \{ t \}\)/.test(ovBody),
|
|
4144
|
+
"the explorer no longer reports busy up: both sides subscribe to the same store"
|
|
3406
4145
|
);
|
|
3407
4146
|
// 用 ref 读、不把 busy 列进那个 effect 的依赖:effect 每次重跑都会
|
|
3408
4147
|
// closeBtnRef.current.focus(),请求一起一落就把正在输目录的用户踢出输入框。
|
|
@@ -3557,3 +4296,130 @@ test("the entity rail counts the whole library, not the newest slice", () => {
|
|
|
3557
4296
|
"the notice needs its own dim style on the semantic colour layer"
|
|
3558
4297
|
);
|
|
3559
4298
|
});
|
|
4299
|
+
|
|
4300
|
+
// --- issue #622 可观测性:错峰时段串解析失败的面板提示 ---
|
|
4301
|
+
// parsePeakSpec 的失败语义是「整串视为未配置」(排程是省钱手段,不救错——这个契约
|
|
4302
|
+
// 不动)。但静默意味着用户写错格式的唯一症状是「功能悄悄没生效」。本批的可见性分
|
|
4303
|
+
// 工:校验在服务端(/features 的 invalidPeakHours,parsePeakSpec 只此一份实现),
|
|
4304
|
+
// 面板只渲染提示行——独立 bundle 复制一份解析器必然漂移,这里锁死这个分工不被
|
|
4305
|
+
// 「优化」成面板自己解析。
|
|
4306
|
+
test("peak hours observability: invalid-spec hint renders from server field, never parses locally", () => {
|
|
4307
|
+
// 两个键的提示行都接上了(蒸馏侧与巩固侧各一处)
|
|
4308
|
+
const uses = clientSource.split("peakInvalidHint(").length - 1; // 两处调用(定义处是 "= (key)",不匹配)
|
|
4309
|
+
assert.ok(uses >= 2, `peakInvalidHint must be wired for both peak rows (got ${uses})`);
|
|
4310
|
+
assert.ok(clientSource.includes('peakInvalidHint("summarizePeakHours")'), "distill-side row must carry the hint");
|
|
4311
|
+
assert.ok(clientSource.includes('peakInvalidHint("dreamPeakHours")'), "dream-side row must carry the hint");
|
|
4312
|
+
// 数据源是服务端字段,不是面板本地解析
|
|
4313
|
+
assert.ok(clientSource.includes("state?.invalidPeakHours"), "hint must read the server-computed field");
|
|
4314
|
+
// 反拷贝锁匹配**调用形态**而非名字——注释里提到解析器名字是允许的,调用不许有
|
|
4315
|
+
assert.ok(!/parsePeakSpec\(|invalidPeakSpecs\(/.test(clientSource), "panel must not copy the parser (single implementation stays in src/peak-hours.js)");
|
|
4316
|
+
// i18n 双语键成对出现
|
|
4317
|
+
for (const key of [
|
|
4318
|
+
"memory.features.summarizePeakHours.invalid",
|
|
4319
|
+
"memory.features.dreamPeakHours.invalid"
|
|
4320
|
+
]) {
|
|
4321
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
4322
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
4323
|
+
}
|
|
4324
|
+
});
|
|
4325
|
+
|
|
4326
|
+
// --- issue #558:引导任务在飞态活得比标签页体长 ---
|
|
4327
|
+
// better-sidebar 关 tab 会把 MemoryExplorer 与引导弹层整个卸载(keepMounted 覆盖
|
|
4328
|
+
// 隐藏与切视图、不含关闭)。POST 照样跑完、行照样落库,但旧实现的成功/失败提示
|
|
4329
|
+
// 写进已卸载组件的 setState(无处显示)、忙碌态也丢——重开还能再提交一次。锁四
|
|
4330
|
+
// 件事:状态住模块级 store;弹层是 store 的视图(busy/msg/err 派生而非本地 state);
|
|
4331
|
+
// 只有「人看得见的关闭」才把 terminal 态回 idle;卸载发生在 running 时的那一份
|
|
4332
|
+
// 留给重开回放(本 issue 修的就是它)。
|
|
4333
|
+
test("bootstrap job outlives the tab body: store in module scope, replay on remount (issue #558)", () => {
|
|
4334
|
+
// store 是模块级的(apply 作用域、所有面板组件共享),带订阅者集合与读写口
|
|
4335
|
+
assert.ok(/const bootstrapJob = \{ phase: "idle"/.test(clientSource), "the job store must be module-level, not component state");
|
|
4336
|
+
assert.ok(/function setBootstrapJob\(/.test(clientSource) && /function useBootstrapJob\(/.test(clientSource), "store needs its setter and subscription hook");
|
|
4337
|
+
// 弹层从 store 派生三态;重放挂载时输入框播种上次的目录(重试同目录是最常见的下一步)
|
|
4338
|
+
const bs558Idx = clientSource.indexOf("function BootstrapDialog");
|
|
4339
|
+
const bs558End = clientSource.indexOf("// --- 分享包弹层 ---", bs558Idx);
|
|
4340
|
+
const bs558Body = clientSource.slice(bs558Idx, bs558End > bs558Idx ? bs558End : bs558Idx + 5000);
|
|
4341
|
+
assert.ok(/const job = useBootstrapJob\(\)/.test(bs558Body), "the dialog must be a view over the job store");
|
|
4342
|
+
assert.ok(/const busy = job\.phase === "running"/.test(bs558Body), "busy derives from the store");
|
|
4343
|
+
assert.ok(/useState\(job\.dir \|\| ""\)/.test(bs558Body), "the dir input seeds from the last job's dir on replay");
|
|
4344
|
+
// 存活规则:卸载发生在 running 时 → 结果留给重开;非 running 卸载 → 回 idle
|
|
4345
|
+
// (保持「重开是干净弹层」的既有行为)。
|
|
4346
|
+
assert.ok(
|
|
4347
|
+
/useEffect\(\(\) => \(\) => \{\s*\n\s*if \(bootstrapJob\.phase !== "running"\) resetBootstrapJob\(\);/.test(clientSource),
|
|
4348
|
+
"unmount keeps a running job for replay and resets a seen terminal one"
|
|
4349
|
+
);
|
|
4350
|
+
// fresh done 的 900ms 阅读窗锁提交钮,但不锁取消(done 是 store 态、可能被重开
|
|
4351
|
+
// 回放——取消若也锁死,旧结果会把弹层永久焊住)。
|
|
4352
|
+
assert.ok(/const freshDone = job\.phase === "done" && timerRef\.current !== null/.test(bs558Body), "the reading window must be distinguished from a replayed result");
|
|
4353
|
+
assert.ok(/disabled: submitBlocked, onClick: submit/.test(bs558Body), "submit is locked during the fresh-done window");
|
|
4354
|
+
assert.ok(/disabled: busy, onClick: onClose/.test(bs558Body), "cancel stays available for a replayed result");
|
|
4355
|
+
// terminal 化只写 store(组件可能已被 tab 关闭卸载——那正是要修的路径)
|
|
4356
|
+
assert.ok(/setBootstrapJob\(\{ phase: "failed", error:/.test(bs558Body), "failure must land in the store, not in unmounted component state");
|
|
4357
|
+
assert.ok(/setBootstrapJob\(\{ phase: "done", result:/.test(bs558Body), "success must land in the store too");
|
|
4358
|
+
});
|
|
4359
|
+
|
|
4360
|
+
// #619 可见化 C:情绪一栏的「三种状态不许互相冒充」。
|
|
4361
|
+
// 界面在这里最容易犯的错是把「请求失败」渲染成「没有数据」——两者对用户的
|
|
4362
|
+
// 含义完全相反(前者是系统坏了,后者是功能没用),而空列表长得一模一样。
|
|
4363
|
+
test("affective panel keeps loading, error and empty apart (#619)", () => {
|
|
4364
|
+
const start = clientSource.indexOf("function AffectivePanel({ t })");
|
|
4365
|
+
assert.ok(start > 0, "AffectivePanel must exist");
|
|
4366
|
+
const end = clientSource.indexOf("function StatusPanel({ t, onBrowse })", start);
|
|
4367
|
+
assert.ok(end > start, "panel body must be bounded");
|
|
4368
|
+
const body = clientSource.slice(start, end);
|
|
4369
|
+
|
|
4370
|
+
// 失败分支必须 role=alert 且用 error 文案,不能掉进空态文案。
|
|
4371
|
+
assert.ok(/state\.error\)\s*\{[\s\S]*?memory\.affective\.error/.test(body),
|
|
4372
|
+
"失败时要显式说「这是取失败」,不能用空态文案顶替");
|
|
4373
|
+
assert.ok(/memory\.affective\.empty/.test(body), "真的一条都没有时才有空态文案");
|
|
4374
|
+
|
|
4375
|
+
// catch 里不许把 items 落在 [],否则「上游挂了」与「还没记过情绪」同形。
|
|
4376
|
+
const catchBlock = body.slice(body.indexOf(".catch("));
|
|
4377
|
+
assert.ok(/error:\s*true/.test(catchBlock), "catch 必须立 error 标记");
|
|
4378
|
+
assert.ok(!/items:\s*\[\]/.test(catchBlock.split("\n").slice(0, 3).join("\n")) ||
|
|
4379
|
+
/error:\s*true/.test(catchBlock),
|
|
4380
|
+
"catch 分支不能只留空列表而丢掉失败");
|
|
4381
|
+
|
|
4382
|
+
// 截断与跳过都要显式说出来,不能静默截断。
|
|
4383
|
+
assert.ok(/memory\.affective\.truncated/.test(body), "截断要说出来");
|
|
4384
|
+
assert.ok(/memory\.affective\.skipped/.test(body), "没情绪名的边要报出来");
|
|
4385
|
+
|
|
4386
|
+
// trait 与 occasion 必须用不同文案,否则「这次」被读成「一贯」。
|
|
4387
|
+
assert.ok(/memory\.affective\.trait/.test(body) && /memory\.affective\.occasion/.test(body),
|
|
4388
|
+
"trait 与 occasion 两档文案都要在");
|
|
4389
|
+
|
|
4390
|
+
// 页签要注册,不然这一栏根本没有入口。
|
|
4391
|
+
assert.ok(clientSource.includes('{ key: "affective", label: t("memory.explorer.tabAffective") }'),
|
|
4392
|
+
"情绪页签必须注册");
|
|
4393
|
+
assert.ok(clientSource.includes('view === "affective"'), "视图分支必须存在");
|
|
4394
|
+
});
|
|
4395
|
+
|
|
4396
|
+
// 回归锁:情绪行必须说出「谁」,页签图标必须各自认领(v0.13.0 复验轮)。
|
|
4397
|
+
// 这两处都是「数据在、界面没显示」的假绿形状:服务端一直带 person、ICON_PATHS
|
|
4398
|
+
// 之外的页签则静默落到 else 分支,闸门全绿而屏幕上少一块信息。
|
|
4399
|
+
test("affective rows name the person, and every tab owns its icon (#619)", () => {
|
|
4400
|
+
const start = clientSource.indexOf("function AffectivePanel({ t })");
|
|
4401
|
+
const end = clientSource.indexOf("function StatusPanel({ t, onBrowse })", start);
|
|
4402
|
+
const body = clientSource.slice(start, end);
|
|
4403
|
+
|
|
4404
|
+
// 谁:person 必须进渲染,缺名给占位,不许把 undefined 打到界面上。
|
|
4405
|
+
assert.ok(/it\.person/.test(body), "情绪行要渲染 person(服务端 from_name 一直都在)");
|
|
4406
|
+
assert.ok(/it\.person\s*\?\?\s*"/.test(body), "person 缺失时要给占位符,不能渲染出 undefined");
|
|
4407
|
+
// 顺序:谁 -> 情绪 -> 对什么。少了前半句「不安」就不知道是谁对谁。
|
|
4408
|
+
const order = [body.indexOf('className: "mneme-affective-person"'), body.indexOf('className: "mneme-affective-emotion"'), body.indexOf('className: "mneme-affective-target"')];
|
|
4409
|
+
assert.ok(order.every((i) => i > 0) && order[0] < order[1] && order[1] < order[2],
|
|
4410
|
+
`情绪行的字段顺序必须是 谁 · 情绪 · 对什么(实际 ${order.join(",")})`);
|
|
4411
|
+
|
|
4412
|
+
// 页签图标:affective 与 diary 各自有名字,且都在 ICON_PATHS 注册表里。
|
|
4413
|
+
const tabTable = clientSource.match(/const TAB_ICONS = \{((?:[^}]|\n)*?)\}/);
|
|
4414
|
+
assert.ok(tabTable, "TAB_ICONS must exist");
|
|
4415
|
+
const map = Object.fromEntries([...tabTable[1].matchAll(/([a-zA-Z]+):\s*"([a-zA-Z]+)"/g)].map((m) => [m[1], m[2]]));
|
|
4416
|
+
assert.equal(map.affective, "heart", "情绪页签要用 heart");
|
|
4417
|
+
assert.equal(map.diary, "book", "日记页签要用 book");
|
|
4418
|
+
for (const [key, icon] of Object.entries(map)) {
|
|
4419
|
+
assert.ok(new RegExp(`^\\s{6}${icon}: \\[\\[`, "m").test(clientSource),
|
|
4420
|
+
`页签 ${key} 指向的图标 ${icon} 没在 ICON_PATHS 注册(会静默渲染成空)`);
|
|
4421
|
+
}
|
|
4422
|
+
// 页签渲染只许走查表;再长出嵌套三元就又会把新页签塞到 else 分支。
|
|
4423
|
+
assert.ok(/h\(Icon, \{ name: TAB_ICONS\[s\.key\]/.test(clientSource),
|
|
4424
|
+
"页签图标必须走 TAB_ICONS 查表,不许回到嵌套三元");
|
|
4425
|
+
});
|