@modusensus/dsh-mneme 0.9.3 → 0.9.5
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 +56 -10
- package/bin/cli.mjs +215 -2
- package/docs/CONFIGURATION.md +10 -2
- package/docs/DAEMON.md +9 -2
- package/docs/DISTILL.md +66 -4
- package/docs/SHARE.md +552 -0
- package/docs/SLEEP.md +1 -1
- package/docs/STORAGE.md +1 -1
- package/lib/api-standalone.js +286 -8
- package/lib/api.js +254 -3
- package/lib/client.js +1000 -50
- package/lib/config.js +50 -1
- package/lib/document.js +15 -2
- package/lib/dream/decisions.js +88 -3
- package/lib/dream/sleep.js +63 -13
- package/lib/dream.js +66 -10
- package/lib/index.js +6 -1
- package/lib/inject.js +10 -25
- package/lib/lang.js +48 -6
- package/lib/memory-source.js +9 -0
- package/lib/organize.js +9 -3
- package/lib/pipeline.js +62 -1
- package/lib/profile-extract.js +161 -0
- package/lib/provenance-http.js +126 -0
- package/lib/serve.js +13 -0
- package/lib/service.js +70 -6
- package/lib/settings.js +17 -13
- package/lib/share-http.js +281 -0
- package/lib/share-import.js +234 -0
- package/lib/share.js +335 -0
- package/lib/store.js +272 -11
- package/lib/summarize.js +118 -8
- package/lib/tools.js +240 -1
- package/lib/user-settings.js +78 -0
- package/package.json +1 -1
- package/src/api-standalone.js +286 -8
- package/src/api.js +254 -3
- package/src/config.js +50 -1
- package/src/document.js +15 -2
- package/src/dream/decisions.js +88 -3
- package/src/dream/sleep.js +63 -13
- package/src/dream.js +66 -10
- package/src/index.js +6 -1
- package/src/inject.js +10 -25
- package/src/lang.js +48 -6
- package/src/memory-source.js +9 -0
- package/src/organize.js +9 -3
- package/src/pipeline.js +62 -1
- package/src/profile-extract.js +161 -0
- package/src/provenance-http.js +126 -0
- package/src/serve.js +13 -0
- package/src/service.js +70 -6
- package/src/settings.js +17 -13
- package/src/share-http.js +281 -0
- package/src/share-import.js +234 -0
- package/src/share.js +335 -0
- package/src/store.js +272 -11
- package/src/summarize.js +118 -8
- package/src/tools.js +240 -1
- package/src/user-settings.js +78 -0
- package/test/api.test.js +384 -3
- package/test/audit.test.js +2 -2
- package/test/cli-receive.test.js +390 -0
- package/test/cli-share.test.js +437 -0
- package/test/client-share.test.js +225 -0
- package/test/client.test.js +215 -6
- package/test/dedupe-candidate-window.test.js +180 -0
- package/test/dream-candidate-hybrid.test.js +2 -2
- package/test/dream-fence.test.js +164 -0
- package/test/dream.test.js +87 -5
- package/test/helpers/dream-mock.js +6 -2
- package/test/lang.test.js +11 -0
- package/test/llm-message-source.test.js +1 -1
- package/test/mint-source.test.js +1 -1
- package/test/organize-tool.test.js +232 -0
- package/test/persona-addressing.test.js +83 -0
- package/test/persona-settings.test.js +30 -8
- package/test/pipeline-r1r2.test.js +129 -0
- package/test/policy-epoch.test.js +2 -2
- package/test/profile-extract.test.js +269 -0
- package/test/provenance-http.test.js +184 -0
- package/test/readme-claims.test.js +45 -0
- package/test/reasoning-effort.test.js +10 -10
- package/test/scope-candidates.test.js +7 -4
- package/test/scope-state.test.js +130 -1
- package/test/serve.test.js +62 -0
- package/test/share-foreign-rows.test.js +144 -0
- package/test/share-http.test.js +144 -0
- package/test/share-import.test.js +577 -0
- package/test/share-imported-guard.test.js +311 -0
- package/test/share-imported-identity.test.js +111 -0
- package/test/share-pack.test.js +511 -0
- package/test/share-receive-http.test.js +756 -0
- package/test/share-routes.test.js +337 -0
- package/test/share-store.test.js +133 -0
- package/test/share-validate.test.js +101 -0
- package/test/standalone-api.test.js +524 -5
- package/test/summarize-parse-diagnostics.test.js +75 -0
- package/test/summarize.test.js +240 -2
- package/test/tools-dto-schema-matrix.test.js +48 -5
- package/test/tools.test.js +6 -1
- package/test/user-settings-preview.test.js +127 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// 车道 D 的面板静态锁。为什么是静态锁:lib/client.js 由宿主 __ModuleLoader__ 加载,
|
|
2
|
+
// 本仓库没有可跑的宿主壳,渲染测试要拖进一份 React 测试栈才够得着那几行真代码。
|
|
3
|
+
// 锁的是「形状」而不是「长得像」:每一条都对应一个**会伤人的回归形态**,
|
|
4
|
+
// 注释里写清它防的是哪一种(AGENTS.md 纪律 8 对测试同样适用)。
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const clientSource = readFileSync(join(here, "..", "lib/client.js"), "utf8");
|
|
13
|
+
const shareSource = readFileSync(join(here, "..", "src/share.js"), "utf8");
|
|
14
|
+
|
|
15
|
+
// 分享弹层最要紧的一条:**看见 report 之前不给下载**(spec §6 的支点)。
|
|
16
|
+
// 防的回归形态:有人为了「方便」把下载按钮改成常驻,或从别处(如列表工具栏)再开一条
|
|
17
|
+
// 直接落盘的口子——那一步停顿就没了,随手全库外发重新变成一次点击。
|
|
18
|
+
test("the share dialog gates the download on the report", () => {
|
|
19
|
+
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/share"'),
|
|
20
|
+
"面板必须发这一枪(不许另造 fetch 绕过 apiFetch 的 token 注入)");
|
|
21
|
+
assert.ok(clientSource.includes("const canDownload = !!rep && rep.included > 0;"),
|
|
22
|
+
"下载按钮的唯一门:有报告且有内容。included 为 0 时对面吃的是 empty-pack(400),不该给一个必败的文件");
|
|
23
|
+
assert.ok(clientSource.includes("canDownload && h(\"div\""),
|
|
24
|
+
"下载那一行必须挂在 canDownload 上,而不是常驻(按钮本身在这样一个 div 里)");
|
|
25
|
+
// 413 与 200 是两份形状:413 没有 memories,只有 report + 字节数 + hint。
|
|
26
|
+
assert.ok(clientSource.includes("if (res.status === 413)"),
|
|
27
|
+
"超水位要走 tooLarge 那一支,不能落到通用失败");
|
|
28
|
+
assert.ok(clientSource.includes("new Blob([JSON.stringify(pack)]"),
|
|
29
|
+
"落盘的必须是服务端给的那一份字节:重拼字段或 pretty-print 都会让「量过的包」与「发出去的包」不是同一坨");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// docs/SHARE.md「车道 D 三笔账」之一:included 只数实际发出的条数,够格进包的是
|
|
33
|
+
// included + deduped。防的回归:有人把「合格条数」的标签挪到 included 上,读数是假的。
|
|
34
|
+
test("the report separates what shipped from what qualified", () => {
|
|
35
|
+
assert.ok(clientSource.includes("report.included + report.deduped"),
|
|
36
|
+
"折叠掉的那几条必须加回去,两个数同屏");
|
|
37
|
+
assert.ok(clientSource.includes('t("memory.explorer.shareDeduped")'),
|
|
38
|
+
"deduped 那一行要真的有渲染点,不是只在字典里躺着");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// 跨文件锁:面板的六个跳过原因与八个可分享类型都只许是服务端那份的投影。
|
|
42
|
+
// 防的回归:share.js 加/改一个 reason 键或一类可分享类型,面板静默少一格
|
|
43
|
+
// ——她的报告上就有一类跳过是看不见的。
|
|
44
|
+
test("the panel's skip-reason and type sets mirror src/share.js", () => {
|
|
45
|
+
const serverReasons = [...shareSource.matchAll(/reason:\s*"([a-z_]+)"/g)].map((m) => m[1]);
|
|
46
|
+
const uniqueReasons = [...new Set(serverReasons)];
|
|
47
|
+
const panelReasons = /const SHARE_SKIP_REASONS = \[([^\]]*)\]/.exec(clientSource)[1]
|
|
48
|
+
.split(",").map((s) => s.trim().replace(/"/g, "")).filter(Boolean);
|
|
49
|
+
assert.deepEqual(panelReasons, uniqueReasons,
|
|
50
|
+
"跳过原因集必须与 share.js 的判序一致(顺序也一致:报告按判序展示)");
|
|
51
|
+
|
|
52
|
+
const serverTypes = /SHARE_ALLOWED_TYPES = new Set\(\[([^\]]*)\]/.exec(shareSource)[1]
|
|
53
|
+
.split(",").map((s) => s.trim().replace(/"/g, "")).filter(Boolean);
|
|
54
|
+
const panelTypes = /const SHARE_TYPE_OPTIONS = \[([^\]]*)\]/.exec(clientSource)[1]
|
|
55
|
+
.split(",").map((s) => s.trim().replace(/"/g, "")).filter(Boolean);
|
|
56
|
+
assert.deepEqual(panelTypes.slice().sort(), serverTypes.slice().sort(),
|
|
57
|
+
"类型下拉只能列真能进包的那几类");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// 别名的尺:服务端 importedTagFor 把整串(含 from-)截到 IMPORTED_TAG_MAX。
|
|
61
|
+
// 防的回归:服务端改这个常量而面板还拦着旧长度,她打的字在对面被静默吃掉。
|
|
62
|
+
test("the alias input matches the server's tag budget", () => {
|
|
63
|
+
const max = Number(/IMPORTED_TAG_MAX = (\d+)/.exec(shareSource)[1]);
|
|
64
|
+
assert.ok(clientSource.includes(`maxLength: ${max - "from-".length}`),
|
|
65
|
+
"输入框长度必须等于服务端留给别名的字符数");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// 文件名以服务端 Content-Disposition 为准(拼法只住在 src/share-http.js 的 shareFilename)。
|
|
69
|
+
// 防的回归:面板自己抄一份「日期 + pack_id 前 8 位」的拼法,两处日后分叉。
|
|
70
|
+
test("the pack filename comes from the response header, with a pack_id fallback", () => {
|
|
71
|
+
assert.ok(clientSource.includes('res.headers.get("content-disposition")'));
|
|
72
|
+
assert.ok(clientSource.includes("const dispositionName = (value) =>"));
|
|
73
|
+
// 回落那一格只许出现一次前缀串,且不带日期拼法。
|
|
74
|
+
assert.equal(clientSource.split("dsh-mneme-share-").length - 1, 1,
|
|
75
|
+
"面板只许有一份包名前缀串(回落用),不许再拼第二套日期命名");
|
|
76
|
+
assert.equal(/pack_id[^;\n]{0,48}slice\(\s*0\s*,\s*8\s*\)/.test(clientSource), false,
|
|
77
|
+
"不许在 pack_id 后面截 8 位——那是 src/share-http.js 的 shareFilename 唯一的拼法");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// docs/SHARE.md「车道 D 三笔账」之二:干跑与真导入的答复同形状,区别只有
|
|
81
|
+
// withDryRun 加的 dry_run 那一格。防的回归形态:界面按「我有没有发 confirm」推断写了
|
|
82
|
+
// 没有——同一件事写两处,改一处就出现「其实没写、却说写了」。
|
|
83
|
+
test("the receive dialog reads dry_run instead of inferring from its own button", () => {
|
|
84
|
+
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/receive"'),
|
|
85
|
+
"入库只许走这一枪(apiFetch 带 token 注入)");
|
|
86
|
+
assert.ok(clientSource.includes("post({ pack })"), "预览那一发不带 confirm——缺省即干跑");
|
|
87
|
+
assert.ok(clientSource.includes("post({ pack, confirm: true })"),
|
|
88
|
+
"真写的那一发必须带 confirm 这个真值,其他形状服务端都不认");
|
|
89
|
+
assert.ok(clientSource.includes('d.dry_run === false ? "memory.explorer.receiveDidCreate"'),
|
|
90
|
+
"「写进去了」这句只能由答复自己说——钉的是那个三元式本身,不是六个字面量都在");
|
|
91
|
+
assert.ok(clientSource.includes("result.dry_run !== false"),
|
|
92
|
+
"确认之后仍回干跑时要单独说「没有写库」,不许沉默,也不许挂在预览那一支上");
|
|
93
|
+
assert.ok(clientSource.includes('"memory.explorer.receiveStillDry"'),
|
|
94
|
+
"那一格要有自己的文案");
|
|
95
|
+
// 第二个旋钮一概不认:收侧只认 confirm(spec §5——reclaim 的 body 里就带 dryRun,
|
|
96
|
+
// 手熟的人会顺手发来,把它读成「要写」等于替她点头)。
|
|
97
|
+
// **切片而不是全文**:本文件里 reclaim/VACUUM 那一发合法地发 `dryRun: false, confirm: true`,
|
|
98
|
+
// 那是 reclaim 的形状不是 receive 的。全文反锁会一上来就红;谁把它「修」成全文锁,
|
|
99
|
+
// 就等于给 reclaim 那颗键定罪。所以按函数名切区段,并且**两个锚点都要验**:
|
|
100
|
+
// 结束锚点丢了的时候 indexOf 返回 -1,切片会一路长到文件尾而不是塌成空——
|
|
101
|
+
// 那时候这条锁看似更严,实际悄悄开始管整个文件(而 ReceiveDialog 之后今天没有 dryRun,
|
|
102
|
+
// 它会空过)。两头都钉住,锚点漂移只会红得明白。
|
|
103
|
+
const receiveStart = clientSource.indexOf("function ReceiveDialog(");
|
|
104
|
+
const receiveEnd = clientSource.indexOf("// #386 第 5 条", receiveStart);
|
|
105
|
+
assert.ok(receiveStart > 0 && receiveEnd > receiveStart,
|
|
106
|
+
"ReceiveDialog 的起止锚点都要在,且顺序对——否则这条锁在管的东西和它说的不是一件");
|
|
107
|
+
const receiveRegion = clientSource.slice(receiveStart, receiveEnd);
|
|
108
|
+
assert.ok(receiveRegion.length > 800, "ReceiveDialog 的区段短得不像话,先修锚再谈锁");
|
|
109
|
+
assert.equal(receiveRegion.includes("dryRun"), false,
|
|
110
|
+
"收侧不许发 dryRun——唯一的入口是 confirm");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// 三笔账之三:title_conflicts 是 advisory 读数,只对「本轮真正会新建的那些」判过,
|
|
114
|
+
// 重吃同一个包时恒空。防的回归:文案写成「现在没有冲突」。
|
|
115
|
+
test("the receive preview keeps the advisory reading honest", () => {
|
|
116
|
+
assert.ok(clientSource.includes("d.title_conflicts"), "撞名清单要显示");
|
|
117
|
+
assert.ok(clientSource.includes('"memory.explorer.receiveConflicts"'));
|
|
118
|
+
// 钉的是**文案**而不是注释:解释性的话必须能写「这不等于没有冲突」来提醒读者,
|
|
119
|
+
// 连注释一起 ban 等于把警告本身禁掉(这一轮就撞上了:新注释里的「没有撞名」先红的是这条锁)。
|
|
120
|
+
// 真正会伤人的是屏上那一句,所以只扫非注释行。
|
|
121
|
+
const copyLines = clientSource.split("\n").filter((line) => !/^\s*\/\//.test(line)).join("\n");
|
|
122
|
+
assert.equal(/现在没有冲突|没有撞名|无冲突|no conflicts|no title clashes/i.test(copyLines), false,
|
|
123
|
+
"不许出现「没有冲突」这类绝对断言(清单为空只说明本轮没有新的要报)");
|
|
124
|
+
// 先预览才允许确认:干跑是必经的一站,不是可选装饰。
|
|
125
|
+
assert.ok(clientSource.includes("disabled: busy || !preview"),
|
|
126
|
+
"确认按钮在拿到预览之前必须 disabled");
|
|
127
|
+
// advisory 读数的分母要说出来,否则「清单为空」与「什么也没比」在屏上长一个样。
|
|
128
|
+
// 注意它是**包内条数**而不是「比过了几条」,所以文案只许说「包内 N 条」。
|
|
129
|
+
assert.ok(clientSource.includes("d.conflicts_checked"),
|
|
130
|
+
"要把服务端给的包内条数显示出来");
|
|
131
|
+
assert.ok(clientSource.includes('t("memory.explorer.receivePackCount")'),
|
|
132
|
+
"那一行要有自己的渲染点,不是只在字典里躺着");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// D3 评审裁定补的锁:两种「按了没反应」的形态。
|
|
136
|
+
// a) 200 但正文读不出——曾经一路 return null,调用方把它读成「屏上已有提示」,
|
|
137
|
+
// 于是确认那一发悄悄没走完,而上一块的「将新建 N 条」还留在屏上;
|
|
138
|
+
// b) 预览还在飞的时候又能选一个文件——飞回来的数字说的是前一个包,确认写的却是后一个。
|
|
139
|
+
test("the receive dialog never goes silent, and never lets a superseded preview drive the write", () => {
|
|
140
|
+
assert.ok(clientSource.includes('if (!d || typeof d !== "object") {'),
|
|
141
|
+
"200 但没有可读正文也要 setErr,不许 return null 装成「已经处理过了」");
|
|
142
|
+
assert.ok(clientSource.includes("disabled: busy, onClick: () => fileRef.current?.click()"),
|
|
143
|
+
"预览/确认在飞的时候不许再选文件(那一块数字属于上一个包)");
|
|
144
|
+
assert.ok(clientSource.includes('typeof parsed !== "object"'),
|
|
145
|
+
"解得出 JSON 但不是对象,是另一种失败,得有它自己的一句话");
|
|
146
|
+
assert.ok(clientSource.includes('e.target.value = ""'),
|
|
147
|
+
"选完清空控件值:否则改过的同一个文件再选一次不发 change,面板拿着旧包预览");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// 外来区三件:筛选、来源徽标、整包退出。锁的是「每件事都挂在一个服务端真值上」,
|
|
151
|
+
// 而不是挂在一个界面自己造出来的判断上。
|
|
152
|
+
test("the foreign filter is a browse-state server filter and stays inert while searching", () => {
|
|
153
|
+
assert.ok(clientSource.includes('foreignOnly ? "&source=imported" : ""'),
|
|
154
|
+
"浏览态交给服务端筛:分页与 total 同口径,本地筛会切掉尾巴");
|
|
155
|
+
// **搜索态不做本地过滤**(本轮扫描改掉的裁定,不是漏做):/search 那张 DTO 的 source 是
|
|
156
|
+
// 召回渠道(keyword/vector/bm25,src/service.js:939),行的 provenance 被盖掉,「这条是不是
|
|
157
|
+
// 外来的」在这张 DTO 上压根读不出来。宁可让 chip 在搜索态明确失效并说清原因,也不要按一个
|
|
158
|
+
// 假信号筛——test/share-foreign-rows.test.js 的第三条用例锁着这个事实。
|
|
159
|
+
assert.ok(clientSource.includes("disabled: !!query.trim() && !foreignOnly"),
|
|
160
|
+
"有搜索词时不许**开启**外来筛选,但任何时候都许关掉——只给 disabled 会把她锁在一个她解不开的状态里");
|
|
161
|
+
assert.ok(clientSource.includes('"memory.explorer.filterForeignSearchHint"'),
|
|
162
|
+
"失效要有自己的文案,不能只靠 disabled 让人猜");
|
|
163
|
+
assert.equal(clientSource.includes('(!foreignOnly || m.source'), false,
|
|
164
|
+
"不许退回「搜索态本地按 source 筛」那条假信号");
|
|
165
|
+
// 服务端不吃 source 这件事,不许用「给 search 的 URL 也拼一个键」的方式绕过去:
|
|
166
|
+
// 那条路由压根不读它(src/api.js 的 search handler 里没有 source 这一档),拼上去
|
|
167
|
+
// 只会造出一个「看起来筛过了、其实没有」的形状。全文件只许有一处拼这个参数。
|
|
168
|
+
assert.equal(clientSource.split("&source=imported").length - 1, 1,
|
|
169
|
+
"只有浏览态那一条 URL 会拼 source=imported");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// 来源徽标与撤销都按服务端给的真值门控:徽标认 source + from- 标签,撤销认 pack_id。
|
|
173
|
+
test("the foreign badge and the withdraw action gate on server truth, not on guesswork", () => {
|
|
174
|
+
assert.ok(clientSource.includes('m.source === "imported"'),
|
|
175
|
+
"徽标先认 source,再认标签:人手工打的 from-* 标签不算外来行");
|
|
176
|
+
assert.ok(clientSource.includes('startsWith("from-")'),
|
|
177
|
+
"别名读数就是 importedTagFor 打的那条标签,面板不另找地方读别名");
|
|
178
|
+
assert.ok(clientSource.includes("memory.share_pack_id &&"),
|
|
179
|
+
"撤销按钮按包号自门控:/search 的 DTO 不带这一格,那种行按不了(已知代价,docs/SHARE.md 记着)");
|
|
180
|
+
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/receive/undo"'),
|
|
181
|
+
"整包退出只许走这一枪");
|
|
182
|
+
assert.ok(clientSource.includes("{ packId: memory.share_pack_id, confirm: true }"),
|
|
183
|
+
"删除那一发要带 confirm 真值,且 packId 只从行上取");
|
|
184
|
+
// 两段式的**第一段**也要有锁:只钉 confirm 那一发等于没钉——把三元式删掉、每次都带
|
|
185
|
+
// confirm:true,十把锁一把都不会红,而「整包物删」变成一次点击就发生。
|
|
186
|
+
assert.ok(clientSource.includes(": { packId: memory.share_pack_id })"),
|
|
187
|
+
"清点那一发必须是不带 confirm 的形状");
|
|
188
|
+
assert.ok(clientSource.includes("onClick: () => undoPack(false)"),
|
|
189
|
+
"第一颗键走的是干跑那一支");
|
|
190
|
+
assert.ok(clientSource.includes("d.dry_run === false"),
|
|
191
|
+
"撤成功的读数同样只由答复的 dry_run 说");
|
|
192
|
+
assert.ok(clientSource.includes("undoPreview.sample"),
|
|
193
|
+
"删除前的确认要带前缀样例(答复的 sample 最多 20 条,deleted 才是全量)");
|
|
194
|
+
// 撤是**物删**(docs/SHARE.md:归档行会占住 UNIQUE 位,撤了导不回来),文案不许写「归档」。
|
|
195
|
+
assert.equal(/memory\.explorer\.undoPack[A-Za-z]*"\s*:\s*"[^"]*(归档|archive)/i.test(clientSource), false,
|
|
196
|
+
"撤销的文案不许出现「归档」——它是物理删除");
|
|
197
|
+
// 删除那一发的成功判据**只能是答复的 dry_run**。`confirm && d.dry_run === false` 那种合取
|
|
198
|
+
// 读的是「我刚才点的是哪一颗」:确认回来一个 dry_run:true 时会把预览重画一遍当作没发生——
|
|
199
|
+
// 接收弹层那一侧修过同一个形状,撤销这条更硬的路不许退回去。
|
|
200
|
+
assert.ok(clientSource.includes("if (d.dry_run === false) onUndone(d.deleted ?? 0)"),
|
|
201
|
+
"撤销的成功判据只看答复");
|
|
202
|
+
assert.equal(clientSource.includes("confirm && d.dry_run === false"), false,
|
|
203
|
+
"不许再出现按按钮形状推断的那种合取");
|
|
204
|
+
assert.ok(clientSource.includes('t("memory.explorer.undoPackGone")'),
|
|
205
|
+
"包已不在本库时要说那一句,而不是给一颗「确认删除 0 条」的键");
|
|
206
|
+
// 同一形状只许有一份:抽屉的退出清单与接收弹层的撞名清单都是 {type,title} 两列。
|
|
207
|
+
assert.ok(clientSource.includes("TypeRowList({ t, rows: undoPreview.sample })"),
|
|
208
|
+
"清单形状走公共组件,不许第三份手写 .map");
|
|
209
|
+
assert.ok(clientSource.includes('foreignOnly && !!query.trim() && h("div"'),
|
|
210
|
+
"「搜索时不按」要有一行看得见的说明,不能只挂在 disabled 的 title 上");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// 外来判据是这条车道唯一一处「面板自己写的字面量」:lib/client.js 是独立 bundle,
|
|
214
|
+
// 不能 import src/memory-source.js,所以用静态镜像把它钉回服务端那一份。
|
|
215
|
+
// 服务端改了那个值而这里没跟,面板的徽标与筛选会安静地筛不到东西——正是最难发现的一种坏。
|
|
216
|
+
test("the panel's foreign-ness literal mirrors the server's single definition", () => {
|
|
217
|
+
const sourceModule = readFileSync(join(here, "..", "src/memory-source.js"), "utf8");
|
|
218
|
+
const imported = /IMPORTED_SOURCE = "([^"]+)"/.exec(sourceModule);
|
|
219
|
+
assert.ok(imported, "src/memory-source.js 的导出形状变了,先修这条锁的锚");
|
|
220
|
+
const value = imported[1];
|
|
221
|
+
assert.ok(clientSource.includes(`m.source === "${value}"`),
|
|
222
|
+
"徽标读的行来源必须与服务端定义的那个值同字面");
|
|
223
|
+
assert.ok(clientSource.includes(`&source=${value}`),
|
|
224
|
+
"浏览态那条 URL 拼的来源值必须是同一个");
|
|
225
|
+
});
|
package/test/client.test.js
CHANGED
|
@@ -2077,11 +2077,13 @@ test("panel copy and icons must not silently vanish (missing dict lines, unregis
|
|
|
2077
2077
|
// 人格定义页(beta)面板面回归:形状沿用 A4 的「分组 + i18n 双语 occurrences ≥ 2
|
|
2078
2078
|
// + 关键接线」断言风格(只做源码断言,不挂 React 渲染环境)。
|
|
2079
2079
|
test("persona page ships settings card, master switch and i18n (beta v1)", () => {
|
|
2080
|
-
// 开关进 FEATURE_GROUPS 的 enhance
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
)
|
|
2080
|
+
// 开关进 FEATURE_GROUPS 的 enhance 组(行为开关非座位闸门)。按组解析成员再逐个点名,
|
|
2081
|
+
// 而不是把某一行写成字面量正则——那样往组里加第二个键就会把这条护栏弄红(实测踩过)。
|
|
2082
|
+
const enhance = clientSource.match(/group\.enhance", items: \[((?:[^\]]|\n)*)\]/);
|
|
2083
|
+
assert.ok(enhance, "FEATURE_GROUPS group.enhance must be parseable");
|
|
2084
|
+
for (const flag of ["personaEnabled", "personaStrictAddressing"]) {
|
|
2085
|
+
assert.ok(enhance[1].includes(`"${flag}"`), `FEATURE_GROUPS group.enhance must carry ${flag}`);
|
|
2086
|
+
}
|
|
2085
2087
|
// i18n 键中英双语齐备(occurrences ≥ 2 模式;label + .hint 两路由字典锁统一覆盖)
|
|
2086
2088
|
for (const key of [
|
|
2087
2089
|
"memory.features.personaEnabled",
|
|
@@ -2096,7 +2098,17 @@ test("persona page ships settings card, master switch and i18n (beta v1)", () =>
|
|
|
2096
2098
|
"memory.settings.personaEnabledToggle",
|
|
2097
2099
|
"memory.settings.personaView",
|
|
2098
2100
|
"memory.settings.personaViewHint",
|
|
2099
|
-
"memory.settings.personaViewEmpty"
|
|
2101
|
+
"memory.settings.personaViewEmpty",
|
|
2102
|
+
// 预览改由后端渲染之后新增的读数文案(缺一种语言就是半边界面裸奔键名)
|
|
2103
|
+
"memory.features.personaStrictAddressing",
|
|
2104
|
+
"memory.features.personaStrictAddressing.hint",
|
|
2105
|
+
"memory.settings.personaViewUnknown",
|
|
2106
|
+
"memory.settings.personaViewSourceDefault",
|
|
2107
|
+
"memory.settings.personaViewSourceCard",
|
|
2108
|
+
"memory.settings.personaViewSourceNone",
|
|
2109
|
+
"memory.settings.personaViewSourceFallback",
|
|
2110
|
+
"memory.settings.personaViewGateOff",
|
|
2111
|
+
"memory.settings.personaViewRefresh"
|
|
2100
2112
|
]) {
|
|
2101
2113
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2102
2114
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
@@ -2110,6 +2122,17 @@ test("persona page ships settings card, master switch and i18n (beta v1)", () =>
|
|
|
2110
2122
|
clientSource.includes("memory.features.personaEnabled.hint"),
|
|
2111
2123
|
"the master-switch hint must document the two-gate semantics"
|
|
2112
2124
|
);
|
|
2125
|
+
// 「注入生效文本」必须由后端给:前端再拼一份就是第二张皮(原来正是这段前端拼接
|
|
2126
|
+
// 没有人格行、没有表头与前缀,却自称生效文本)。
|
|
2127
|
+
assert.ok(clientSource.includes("injectedPreview.text"), "the view must render the backend-rendered block");
|
|
2128
|
+
assert.ok(!clientSource.includes('"- " + profile.trim()'), "frontend hand-splicing of the injected block must be gone");
|
|
2129
|
+
assert.ok(clientSource.includes("setInjectedPreview(null)"), "a failed preview must fail closed to 未知, not to a stale or guessed block");
|
|
2130
|
+
// 预览只能有一个所有者,且要丢掉过期响应:先切卡 A 再切卡 B 时,A 的 GET 可能后回来。
|
|
2131
|
+
// 这张卡的标题是「注入生效文本」,显示另一张卡的文本比不显示更糟。
|
|
2132
|
+
assert.ok(clientSource.includes("if (seq !== previewSeq.current) return;"), "preview must drop stale responses");
|
|
2133
|
+
assert.equal(clientSource.split("setInjectedPreview(").length - 1, 2, "loadPreview must be the only preview writer (success + failure)");
|
|
2134
|
+
// source=default 有两种来路:带了 ?agent=<身份> 却回落 ≠ 真的解析不到身份。
|
|
2135
|
+
assert.ok(clientSource.includes("injectedPreview.agent"), "the fallback label must separate a resolved-but-fallen-back identity from an unresolvable one");
|
|
2113
2136
|
});
|
|
2114
2137
|
|
|
2115
2138
|
// v1.2 多卡回归:卡列表 CRUD 可见、字段标签渲染为可见元素(v1.1 反馈:
|
|
@@ -2187,3 +2210,189 @@ test("update-check status names match the backend classify contract verbatim", (
|
|
|
2187
2210
|
assert.ok(apiSource.includes('path: "/api/dsh-mneme/version-check"'),
|
|
2188
2211
|
"the backend must register the version-check route");
|
|
2189
2212
|
});
|
|
2213
|
+
|
|
2214
|
+
// 画像提取草稿(#497)面板面:路由对齐、四种结局各有自己的话、以及「采用」必须把草稿
|
|
2215
|
+
// 文本直接交给保存。最后一条防的是一个具体陷阱——保存函数带默认参数时,把函数本身当
|
|
2216
|
+
// onClick 处理器会把 click 事件当成要存的文本存进去。
|
|
2217
|
+
test("profile extraction ships its button, distinct outcomes and correct save wiring", () => {
|
|
2218
|
+
const apiSource = readFileSync(join(root, "src/api.js"), "utf8");
|
|
2219
|
+
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/profile/extract"'),
|
|
2220
|
+
"the panel must call the extract route");
|
|
2221
|
+
assert.ok(apiSource.includes('path: "/api/dsh-mneme/profile/extract"'),
|
|
2222
|
+
"the backend must register the extract route");
|
|
2223
|
+
|
|
2224
|
+
// 四种结局(成功草稿 / 未启用 / 没有可用记忆 / 模型没配合)必须各有一句人话:
|
|
2225
|
+
// 压成一句「没有草稿」就等于让用户自己猜是坏了还是本来就没东西。
|
|
2226
|
+
for (const key of [
|
|
2227
|
+
"memory.settings.profileExtract",
|
|
2228
|
+
"memory.settings.profileExtracting",
|
|
2229
|
+
"memory.settings.profileExtractHint",
|
|
2230
|
+
"memory.settings.profileExtractDisabled",
|
|
2231
|
+
"memory.settings.profileExtractEmpty",
|
|
2232
|
+
"memory.settings.profileExtractNoRoute",
|
|
2233
|
+
"memory.settings.profileExtractFailed",
|
|
2234
|
+
"memory.settings.profileSaveFailed",
|
|
2235
|
+
"memory.settings.profileDraftTitle",
|
|
2236
|
+
"memory.settings.profileDraftRows",
|
|
2237
|
+
"memory.settings.profileDraftTruncated",
|
|
2238
|
+
"memory.settings.profileDraftApply",
|
|
2239
|
+
"memory.settings.profileDraftDiscard",
|
|
2240
|
+
"memory.features.profileExtractEnabled",
|
|
2241
|
+
"memory.features.profileExtractEnabled.hint"
|
|
2242
|
+
]) {
|
|
2243
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2244
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2245
|
+
}
|
|
2246
|
+
assert.ok(/group\.enhance[\s\S]{0,700}"profileExtractEnabled"/.test(clientSource),
|
|
2247
|
+
"FEATURE_GROUPS group.enhance must carry profileExtractEnabled");
|
|
2248
|
+
|
|
2249
|
+
// 采用草稿:显式传文本;保存按钮也不能把 click 事件当参数传进去。
|
|
2250
|
+
assert.ok(clientSource.includes("if (await saveProfile(text))"), "applying a draft must commit state only after a verified save");
|
|
2251
|
+
assert.ok(!clientSource.includes("onClick: saveProfile }"), "the save button must not receive the click event as its value argument");
|
|
2252
|
+
// apiFetch 是 fetch 的薄壳:非 2xx 不抛。画像这条以前没判 res.ok(同页的 persona
|
|
2253
|
+
// 一直判),于是服务端 403/413 时界面照样闪「画像已保存」。
|
|
2254
|
+
const profileSaver = clientSource.slice(clientSource.indexOf("async function saveProfile("));
|
|
2255
|
+
assert.ok(profileSaver.slice(0, 900).includes('if (!res.ok) throw new Error("HTTP "'),
|
|
2256
|
+
"the profile saver must verify the response status before reporting success");
|
|
2257
|
+
// 没配模型是一句可行动的话(先去选模型),不能和「模型没按格式回」共用一条文案。
|
|
2258
|
+
assert.ok(clientSource.includes('"no-route" ? "noroute"'), "no-route must map to its own panel outcome, not the generic failure");
|
|
2259
|
+
});
|
|
2260
|
+
|
|
2261
|
+
// Issue #521:degraded 轮的跳过明细必须能在面板上看见。此前 skipped 只落库,
|
|
2262
|
+
// 响应里没有聚合,status: degraded + applied: 3 从外面看完全是健康轮的样子——
|
|
2263
|
+
// 一整类动作 100% 被拦要自己写 SQL 才看得出,那个 bug 跑了一整天才被发现。
|
|
2264
|
+
test("dream status card surfaces normalized skip reasons, not just status (issue #521)", () => {
|
|
2265
|
+
// 三个新文案键 zh/en 各一份。
|
|
2266
|
+
for (const key of [
|
|
2267
|
+
"memory.status.skippedTitle",
|
|
2268
|
+
"memory.status.skippedHint",
|
|
2269
|
+
"memory.status.skippedMore"
|
|
2270
|
+
]) {
|
|
2271
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2272
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// 面板必须真的消费后端给的聚合字段,而不是自己再拼一份(归一化只有一份实现,
|
|
2276
|
+
// 住在 src/dream/decisions.js,与产生这些 error 的校验器同文件)。
|
|
2277
|
+
assert.ok(clientSource.includes("skippedReasons"), "the card must read the aggregated skippedReasons field");
|
|
2278
|
+
|
|
2279
|
+
// 归一化实现只许有一份,且必须在 decisions.js 里(与校验器同文件,措辞不会漂移)。
|
|
2280
|
+
const decisionsSource = readFileSync(new URL("../src/dream/decisions.js", import.meta.url), "utf8");
|
|
2281
|
+
assert.ok(decisionsSource.includes("export function normalizeSkipReason("), "normalization lives in decisions.js");
|
|
2282
|
+
assert.ok(decisionsSource.includes("export function summarizeSkipped("), "aggregation lives in decisions.js");
|
|
2283
|
+
assert.ok(!clientSource.includes("normalizeSkipReason("), "the panel must not re-implement normalization");
|
|
2284
|
+
// 序号前缀必须被剥掉:不剥的话 decision[0]/decision[1] 会被拆成 N 组(#521 实测 280 条拆成 20 组)。
|
|
2285
|
+
// #522 评审:一条决策可能攒下多条错误(用 "; " 拼接),所以剥前缀必须覆盖每一段,
|
|
2286
|
+
// 不是只剥行首那一个——先按 "; decision[N]:" 切段,逐段再剥。
|
|
2287
|
+
assert.ok(/replace\(\/\^decision\\\[\\d\+\\\]:/.test(decisionsSource), "the decision[N]: prefix is stripped");
|
|
2288
|
+
assert.ok(/split\(\/;\\s\*\(\?=decision/.test(decisionsSource), "multi-error strings are split before prefix stripping");
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
// #522 评审:跳过明细原先只挂在 StatusPanel 折叠区里的 DreamStatusCards 上,而那个
|
|
2292
|
+
// 折叠区默认收起、收起时连子卡都不挂载——于是「一整类动作 100% 被拦」在首屏照样
|
|
2293
|
+
// 看不见,等于把 #521 的病换个地方犯。这条锁住:明细由 TokenFlowSection(首屏,
|
|
2294
|
+
// 就在 KPI 带下方)渲染,且只有一份实现(SkippedReasonsNotice)。
|
|
2295
|
+
test("skipped reasons are visible without expanding the collapsed detail section (#522)", () => {
|
|
2296
|
+
assert.ok(clientSource.includes("function SkippedReasonsNotice("),
|
|
2297
|
+
"the notice must be its own component so both call sites share one implementation");
|
|
2298
|
+
// 首屏:TokenFlowSection 的返回值里必须出现这个组件(KPI 带之后、tokenflow 之前)。
|
|
2299
|
+
const tokenFlow = clientSource.slice(
|
|
2300
|
+
clientSource.indexOf("function TokenFlowSection("),
|
|
2301
|
+
clientSource.indexOf("function DreamStatusCards(")
|
|
2302
|
+
);
|
|
2303
|
+
assert.ok(tokenFlow.includes("h(SkippedReasonsNotice,"),
|
|
2304
|
+
"the first screen must render the skip notice, not only the collapsed section");
|
|
2305
|
+
// 折叠区那份不再重复渲染:同一个数字在首屏与展开区各出现一次,读者会以为有两批跳过。
|
|
2306
|
+
const dreamCards = clientSource.slice(
|
|
2307
|
+
clientSource.indexOf("function DreamStatusCards("),
|
|
2308
|
+
clientSource.indexOf("function InjectStatusCard(")
|
|
2309
|
+
);
|
|
2310
|
+
assert.ok(!dreamCards.includes("h(SkippedReasonsNotice,"),
|
|
2311
|
+
"the collapsed section must not render the same notice a second time");
|
|
2312
|
+
assert.ok(!dreamCards.includes("skippedReasons"),
|
|
2313
|
+
"the collapsed card no longer needs its own copy of the skip buckets");
|
|
2314
|
+
});
|
|
2315
|
+
// 设置页目录(#518):用户要的是「左侧一个分类,直接跳到某一节」。这里锁五件事:
|
|
2316
|
+
// ① 目录由 tocGroups 一张表生成,五项分组标题全部走 groupHead(key) 渲染——两处各写
|
|
2317
|
+
// 一遍清单是这类索引最典型的腐烂方式,而腐烂后的目录会把人领到不存在的锚点;
|
|
2318
|
+
// ② 点一下必须真的滚(scrollIntoView),不是摆设;
|
|
2319
|
+
// ③ 文案双语齐备——只有中文键的目录在英文界面下会渲染出键名;④⑤ 两条是布局锁,见断言末尾。
|
|
2320
|
+
test("settings page ships a section index that jumps to each group (#518)", () => {
|
|
2321
|
+
// 目录与标题同源:tocGroups 的每一项都必须从 group.<key> 取文案。
|
|
2322
|
+
const tocBlock = clientSource.match(/const tocGroups = \[([\s\S]*?)\];/);
|
|
2323
|
+
assert.ok(tocBlock, "the settings view must declare tocGroups");
|
|
2324
|
+
const tocKeys = [...tocBlock[1].matchAll(/key: "([^"]+)"/g)].map((m) => m[1]);
|
|
2325
|
+
assert.deepEqual(tocKeys, ["personal", "engine", "search", "connect", "help"],
|
|
2326
|
+
"the index must cover every settings group, in render order");
|
|
2327
|
+
for (const key of tocKeys) {
|
|
2328
|
+
assert.ok(tocBlock[1].includes('t("memory.settings.group.' + key + '")'),
|
|
2329
|
+
'tocGroups entry "' + key + '" must read its label from memory.settings.group.' + key);
|
|
2330
|
+
// 分组标题只有一条通路:正文里的五个标题都经过 groupHead(key),
|
|
2331
|
+
// 没有一处再手写 className 为 mneme-set-grouphead 的 div。
|
|
2332
|
+
const headCalls = clientSource.split('groupHead("' + key + '")').length - 1;
|
|
2333
|
+
assert.equal(headCalls, 1, 'group "' + key + '" must be rendered exactly once, via groupHead()');
|
|
2334
|
+
}
|
|
2335
|
+
const rawHeads = clientSource.split('className: "mneme-set-grouphead"').length - 1;
|
|
2336
|
+
assert.equal(rawHeads, 1, "no section may hand-write the group head; only groupHead() may (it owns the jump ref)");
|
|
2337
|
+
|
|
2338
|
+
// 跳转必须落到 ref 上并调用 scrollIntoView——目录不滚动等于没有目录。
|
|
2339
|
+
assert.match(clientSource, /tocRefs\.current\[key\] = node/,
|
|
2340
|
+
"every group head must register itself as a jump target");
|
|
2341
|
+
assert.match(clientSource, /node\.scrollIntoView\(\{ block: "start"/,
|
|
2342
|
+
"jumping must scroll the target section into view");
|
|
2343
|
+
|
|
2344
|
+
// 双语:三条新键都要在 zh 与 en 各出现一次(en 块在后,计数 >= 2)。
|
|
2345
|
+
for (const key of [
|
|
2346
|
+
"memory.settings.toc.label",
|
|
2347
|
+
"memory.settings.toc.aria",
|
|
2348
|
+
"memory.settings.toc.jump"
|
|
2349
|
+
]) {
|
|
2350
|
+
const occurrences = clientSource.split('"' + key + '"').length - 1;
|
|
2351
|
+
assert.ok(occurrences >= 2, "i18n key " + key + " must exist in both zh and en (got " + occurrences + ")");
|
|
2352
|
+
}
|
|
2353
|
+
// 占位符是唯一的插值点:写成裸文案就等于把 "{name}" 直接显示给用户。
|
|
2354
|
+
assert.ok(clientSource.includes('"memory.settings.toc.jump": "跳到「{name}」"'),
|
|
2355
|
+
"the jump tooltip must keep its {name} placeholder");
|
|
2356
|
+
|
|
2357
|
+
// ④ 粘顶条会盖住跳转目标:只锁 scrollIntoView 存在,看不出标题停在目录背后——
|
|
2358
|
+
// 布局里唯一还能在源码上判的一条就是那个偏移,所以钉住它。
|
|
2359
|
+
const headRule = clientSource.match(/"\.mneme-set-grouphead\{([^"]*)\}/);
|
|
2360
|
+
assert.ok(headRule && /scroll-margin-top:\d+px/.test(headRule[1]),
|
|
2361
|
+
"the jump target must clear the sticky index via scroll-margin-top");
|
|
2362
|
+
|
|
2363
|
+
// ⑤ 窄屏是横向滚动,不是换行:换行会把粘顶条撑高,上面那个偏移跟着失准。
|
|
2364
|
+
const listRule = clientSource.match(/"\.mneme-set-toclist\{([^"]*)\}/);
|
|
2365
|
+
assert.ok(listRule && /overflow-x:auto/.test(listRule[1]) && !/flex-wrap:wrap/.test(listRule[1]),
|
|
2366
|
+
"the chip row must scroll sideways on narrow panels instead of wrapping under the sticky bar");
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
// #514:用户以为插件「轻量」关掉了人格定义,实际是宿主 minimal 预设整体压制了注入。
|
|
2370
|
+
// 三道锁防复发:① 设置页那两个会被人开关的卡(运行模式 / 人格)都要能看到这条
|
|
2371
|
+
// 告示,且两处共用同一份取数与同一组文案,不许各自长一套;② 宿主的 minimal 与
|
|
2372
|
+
// 插件的 light 必须在文案里被明确区分,否则两个词在同一个页面里出现就还是混;
|
|
2373
|
+
// ③ 告示只在 suppressed 为真时出现——关掉注入的用户、preset 未知的宿主不该被吓。
|
|
2374
|
+
test("minimal preset notice reaches the settings page, not just the status tab (issue #514)", () => {
|
|
2375
|
+
for (const key of ["memory.status.minimalPresetTitle", "memory.status.minimalPresetBody", "memory.settings.mode.hostPresetHint"]) {
|
|
2376
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2377
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2378
|
+
}
|
|
2379
|
+
// ② 区分两个词:文案里必须同时点名宿主的极简/预设与插件的轻量档。
|
|
2380
|
+
assert.ok(clientSource.includes("极简模式(minimal 预设)"), "the zh copy must name the host's minimal preset explicitly");
|
|
2381
|
+
assert.ok(/轻量档不会关闭人格定义|Light never disables persona/.test(clientSource),
|
|
2382
|
+
"the copy must say that light mode does not disable persona — that is the exact misreading in #514");
|
|
2383
|
+
|
|
2384
|
+
// ① 两处消费:告示必须在运行模式卡与人格卡各出现一次,且都读同一个 minimalWarn。
|
|
2385
|
+
const uses = clientSource.split('t("memory.status.minimalPresetTitle")').length - 1;
|
|
2386
|
+
assert.ok(uses >= 2, `the notice must render in both the mode card and the persona card (got ${uses})`);
|
|
2387
|
+
assert.ok(clientSource.includes("const minimalWarn = !injectStatus.loading && injectStatus.suppressed;"),
|
|
2388
|
+
"the notice must be gated on the shared inject-status state, not on a second fetch of its own");
|
|
2389
|
+
|
|
2390
|
+
// 取数只有一份:useInjectStatus 之外不许再有人直接打 /inject-status。
|
|
2391
|
+
const fetches = clientSource.split('"/api/dsh-mneme/inject-status"').length - 1;
|
|
2392
|
+
assert.equal(fetches, 1, "inject-status must have exactly one reader; a second fetch lets the two surfaces drift");
|
|
2393
|
+
|
|
2394
|
+
// 3 未压制时零打扰:settings 里判的必须是 injectStatus.suppressed。
|
|
2395
|
+
assert.ok(!/minimalWarn\s*=\s*true/.test(clientSource), "the notice must never be hard-wired on");
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
|