@modusensus/dsh-mneme 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/bin/dsh-mneme-choro.mjs +254 -0
- package/docs/CONFIGURATION.md +8 -1
- package/lib/api-standalone.js +17 -21
- package/lib/api.js +247 -11
- package/lib/bootstrap-http.js +105 -0
- package/lib/bootstrap.js +5 -1
- package/lib/choro-command.js +104 -0
- package/lib/client.js +1028 -102
- package/lib/config-keys.js +144 -0
- package/lib/config.js +28 -0
- package/lib/index.js +50 -4
- package/lib/inject.js +15 -3
- package/lib/lang.js +54 -0
- package/lib/quality-filter.js +48 -12
- package/lib/service.js +3 -0
- package/lib/settings.js +8 -1
- package/lib/store.js +502 -0
- package/lib/tools.js +127 -1
- package/lib/wiki/inject.js +164 -0
- package/lib/wiki/partition.js +200 -0
- package/lib/wiki/plan.js +214 -0
- package/lib/wiki/queries.js +220 -0
- package/lib/wiki/render.js +187 -0
- package/lib/wiki/scan.js +371 -0
- package/lib/wiki/t0.js +463 -0
- package/lib/wiki/t1.js +496 -0
- package/lib/write-admission.js +5 -3
- package/package.json +4 -2
- package/src/api-standalone.js +17 -21
- package/src/api.js +247 -11
- package/src/bootstrap-http.js +105 -0
- package/src/bootstrap.js +5 -1
- package/src/choro-command.js +104 -0
- package/src/config-keys.js +144 -0
- package/src/config.js +28 -0
- package/src/index.js +50 -4
- package/src/inject.js +15 -3
- package/src/lang.js +54 -0
- package/src/quality-filter.js +48 -12
- package/src/service.js +3 -0
- package/src/settings.js +8 -1
- package/src/store.js +502 -0
- package/src/tools.js +127 -1
- package/src/wiki/inject.js +164 -0
- package/src/wiki/partition.js +200 -0
- package/src/wiki/plan.js +214 -0
- package/src/wiki/queries.js +220 -0
- package/src/wiki/render.js +187 -0
- package/src/wiki/scan.js +371 -0
- package/src/wiki/t0.js +463 -0
- package/src/wiki/t1.js +496 -0
- package/src/write-admission.js +5 -3
- package/test/api-routes-gaps.test.js +131 -1
- package/test/api.test.js +7 -1
- package/test/bootstrap.test.js +8 -0
- package/test/choro-command.test.js +218 -0
- package/test/client.test.js +655 -19
- package/test/config-keys-routes.test.js +233 -0
- package/test/contrast.test.js +14 -7
- package/test/inject.test.js +51 -0
- package/test/llm-audit.test.js +29 -1
- package/test/quality-backfill.test.js +290 -0
- package/test/quality-filter.test.js +113 -4
- package/test/service.test.js +1 -1
- package/test/wiki-api.test.js +153 -0
- package/test/wiki-inject.test.js +265 -0
- package/test/wiki-partition.test.js +112 -0
- package/test/wiki-plan.test.js +80 -0
- package/test/wiki-queries.test.js +187 -0
- package/test/wiki-scan.test.js +208 -0
- package/test/wiki-store.test.js +190 -0
- package/test/wiki-t0.test.js +380 -0
- package/test/wiki-t1.test.js +235 -0
- package/test/wiki-tools.test.js +121 -0
package/test/client.test.js
CHANGED
|
@@ -35,14 +35,45 @@ test("status card: hasData counts the archive metric", () => {
|
|
|
35
35
|
assert.match(m[0], /d\.archive\?\.total/, "hasData must count archive.total, not just runs/active rows");
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
// The memory entry
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
38
|
+
// The memory entry is a first-class host panel now: `sidebar.panellist` puts a
|
|
39
|
+
// glyph into the host's own panel list (the host row owns the button, the click
|
|
40
|
+
// and aria-current) and `main` puts the library into the center column, side by
|
|
41
|
+
// side with the conversation — the centered sheet is no longer the primary
|
|
42
|
+
// surface (#559 follow-up; baleen does the same).
|
|
43
|
+
//
|
|
44
|
+
// Both seats are gated on the host declaring them, so the old footer entry has
|
|
45
|
+
// to survive as the fallback for hosts without the seat pair. This test locks
|
|
46
|
+
// the pair AND the fallback: dropping either one strands a whole host family
|
|
47
|
+
// (new hosts get a sheet on top of the conversation again, old hosts lose the
|
|
48
|
+
// entry entirely).
|
|
49
|
+
test("memory entry registers the host panel seats and keeps the footer fallback", () => {
|
|
50
|
+
assert.ok(
|
|
51
|
+
clientSource.includes('ctx.slots.inject("sidebar.panellist"'),
|
|
52
|
+
"client must inject into sidebar.panellist (the host panel list)"
|
|
53
|
+
);
|
|
54
|
+
assert.ok(
|
|
55
|
+
clientSource.includes('name: "sidebar.panellist"'),
|
|
56
|
+
"the sidebar seat must be registered under its real slot name"
|
|
57
|
+
);
|
|
58
|
+
// main 的 key 必须与侧栏 id 逐字相同:宿主 PanelRow 点一下就是
|
|
59
|
+
// layout.selectPanel(id),它在 main 槽里按 options.key 找 entry,对不上直接抛。
|
|
60
|
+
// 两处改成同一个常量以外的任何写法(比如 main 用 "mneme"、侧栏用 "dsh-mneme")
|
|
61
|
+
// 面板点了没反应且控制台报 "main panel ... is not registered"。
|
|
62
|
+
assert.ok(
|
|
63
|
+
clientSource.includes('key: "dsh-mneme"'),
|
|
64
|
+
"the main seat key must match the sidebar seat id verbatim"
|
|
65
|
+
);
|
|
66
|
+
assert.ok(
|
|
67
|
+
clientSource.includes('ctx.slots.spec("sidebar.panellist")'),
|
|
68
|
+
"the footer fallback must be gated on the host actually declaring the panel seat"
|
|
69
|
+
);
|
|
70
|
+
assert.ok(
|
|
71
|
+
clientSource.includes("dropFooterSeat()"),
|
|
72
|
+
"once the panel seat exists the footer entry must be torn down, or the sidebar shows two memory entries"
|
|
73
|
+
);
|
|
43
74
|
assert.ok(
|
|
44
75
|
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
45
|
-
"client must
|
|
76
|
+
"client must keep injecting into sidebar.footer.action as the old-host fallback"
|
|
46
77
|
);
|
|
47
78
|
assert.equal(
|
|
48
79
|
clientSource.includes('"settings.section"'),
|
|
@@ -51,6 +82,153 @@ test("memory entry registers into the sidebar foot slot", () => {
|
|
|
51
82
|
);
|
|
52
83
|
});
|
|
53
84
|
|
|
85
|
+
// chorography(仓库地志)第二个座位(M1):与记忆面板同一个 panellist inject 块
|
|
86
|
+
// 里注册,成对规则逐字相同——id 与 main 的 key 必须同为 "dsh-mneme-wiki",否则
|
|
87
|
+
// 宿主点面板行直接抛 "main panel … is not registered"(#562 在记忆座位上实测过
|
|
88
|
+
// 的同一堵墙)。座位无条件注册(P11):inject 同名二次注册的 cordis 语义没验证
|
|
89
|
+
// 过,不赌;开关关时由内容层渲染空态(路由 409 disabled),生效走重启纪律。
|
|
90
|
+
test("chorography wiki entry registers its own seat pair in the same panellist block", () => {
|
|
91
|
+
assert.ok(
|
|
92
|
+
clientSource.includes('id: "dsh-mneme-wiki"'),
|
|
93
|
+
"the wiki sidebar seat must use its own id"
|
|
94
|
+
);
|
|
95
|
+
assert.ok(
|
|
96
|
+
clientSource.includes('key: "dsh-mneme-wiki"'),
|
|
97
|
+
"the wiki main seat key must match the wiki sidebar id verbatim"
|
|
98
|
+
);
|
|
99
|
+
assert.ok(
|
|
100
|
+
clientSource.includes("h(WikiMainPanel, { t })"),
|
|
101
|
+
"the wiki main seat must render WikiMainPanel"
|
|
102
|
+
);
|
|
103
|
+
assert.ok(
|
|
104
|
+
clientSource.includes('phase: "disabled"'),
|
|
105
|
+
"with chorographyEnabled off the wiki page must render the disabled empty state (409 from /choro/repos)"
|
|
106
|
+
);
|
|
107
|
+
// wiki 座位与记忆座位的清理在同一把扫帚下:漏一个就留下一个挂着的 glyph,
|
|
108
|
+
// 宿主折叠该槽后指向一个死面板。
|
|
109
|
+
assert.ok(
|
|
110
|
+
/return \(\) => \{\s*disposeGlyph\(\);\s*disposeMain\(\);\s*disposeWikiGlyph\(\);\s*disposeWikiMain\(\);/.test(clientSource),
|
|
111
|
+
"the panellist cleanup must dispose both seat pairs"
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// 上一条锁的是「代码里有没有这两套座位」,这条锁「装/卸真的按条件跑」。
|
|
116
|
+
// 回归表现(都靠字符串锁抓不到):
|
|
117
|
+
// · 新宿主上两套都装 → 侧栏两颗记忆入口;
|
|
118
|
+
// · 宿主先声明 panellist、后来撤掉/换形态 → 回落不装回来,入口整个消失;
|
|
119
|
+
// · 回落被重复装入 → footer 里叠两个按钮;
|
|
120
|
+
// · 卸载 panellist 座位时忘了连带卸 main → 侧栏没图标但 activePanelId 仍指向
|
|
121
|
+
// 一个已死面板(宿主 retainMainPanels 会把它复位成会话,界面上看不出来,
|
|
122
|
+
// 但用户的「回到记忆库」状态被悄悄清掉)。
|
|
123
|
+
test("panel seats: the fallback mounts and unmounts with the host's declaration", () => {
|
|
124
|
+
// 匹配范围必须一直吃到最后一条 inject 的收尾:非贪婪到第一个 \n });
|
|
125
|
+
// 会停在 panellist 那段的中途,footer 回落根本进不了沙箱,测出来的永远不会红。
|
|
126
|
+
const src = clientSource.match(/const hostHasPanelSeat = \(\) =>[\s\S]*?ctx\.slots\.inject\("sidebar\.footer\.action"[\s\S]*?\n \}\);/);
|
|
127
|
+
assert.ok(src, "the seat block must be extractable verbatim");
|
|
128
|
+
const boot = new Function("ctx", "t", "h", `${src[0]}\n return { hostHasPanelSeat };`);
|
|
129
|
+
const makeCtx = (specFor) => {
|
|
130
|
+
const regs = [];
|
|
131
|
+
const ctx = {
|
|
132
|
+
slots: {
|
|
133
|
+
spec: (key) => specFor(key),
|
|
134
|
+
inject: (key, cb) => { ctx.injected.push({ key, cb }); return () => {}; },
|
|
135
|
+
register: (opts, comp) => {
|
|
136
|
+
const rec = { opts, comp, alive: true };
|
|
137
|
+
regs.push(rec);
|
|
138
|
+
return () => { rec.alive = false; };
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
injected: []
|
|
142
|
+
};
|
|
143
|
+
return { ctx, regs };
|
|
144
|
+
};
|
|
145
|
+
const alive = (regs, name) => regs.filter((r) => r.alive && r.opts.name === name).length;
|
|
146
|
+
|
|
147
|
+
// 宿主只在「那个槽已被声明」时才跑 inject 的回调,所以这里也只在对应的
|
|
148
|
+
// spec 存在时点火:一把全点会把新座位的注册塞进老宿主,测出来的就不是真行为。
|
|
149
|
+
const fire = (ctx, key) => ctx.injected.filter((i) => i.key === key).map((i) => i.cb());
|
|
150
|
+
|
|
151
|
+
// 新宿主:panellist 已声明。panellist + main 各两份(记忆 + chorography 两个
|
|
152
|
+
// 座位,M1),每对各自 id/key 逐字同名;footer 回落不装。
|
|
153
|
+
{
|
|
154
|
+
const { ctx, regs } = makeCtx((key) => (key === "sidebar.panellist" ? { kind: "list" } : undefined));
|
|
155
|
+
const api = boot(ctx, (k) => k, () => null);
|
|
156
|
+
assert.equal(api.hostHasPanelSeat(), true);
|
|
157
|
+
const disposes = fire(ctx, "sidebar.panellist");
|
|
158
|
+
fire(ctx, "sidebar.footer.action");
|
|
159
|
+
assert.equal(disposes.length, 1);
|
|
160
|
+
const ids = (name) => regs.filter((r) => r.alive && r.opts.name === name).map((r) => r.opts.id ?? r.opts.key).sort();
|
|
161
|
+
assert.equal(alive(regs, "sidebar.panellist"), 2, "memory + wiki glyphs");
|
|
162
|
+
assert.deepEqual(ids("sidebar.panellist"), ["dsh-mneme", "dsh-mneme-wiki"], "每对座位各自 id 逐字唯一");
|
|
163
|
+
assert.equal(alive(regs, "main"), 2, "memory + wiki pages");
|
|
164
|
+
assert.deepEqual(ids("main"), ["dsh-mneme", "dsh-mneme-wiki"], "main key 与侧栏 id 逐字同名");
|
|
165
|
+
assert.equal(alive(regs, "sidebar.footer.action"), 0, "the footer fallback must not mount on a host that has the seat");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 老宿主:没有 panellist。回落必须装上,且只装一份。
|
|
169
|
+
{
|
|
170
|
+
const { ctx, regs } = makeCtx(() => undefined);
|
|
171
|
+
const api = boot(ctx, (k) => k, () => null);
|
|
172
|
+
assert.equal(api.hostHasPanelSeat(), false);
|
|
173
|
+
fire(ctx, "sidebar.footer.action");
|
|
174
|
+
assert.equal(alive(regs, "sidebar.panellist"), 0);
|
|
175
|
+
assert.equal(alive(regs, "main"), 0);
|
|
176
|
+
assert.equal(alive(regs, "sidebar.footer.action"), 1, "old hosts keep exactly one footer entry");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 座位消失:卸载 panellist 之后回落必须自己装回来,否则那台宿主上没有入口。
|
|
180
|
+
// 真实顺序是「声明先没了(spec 变 undefined),再把 entry 的 disposer 跑掉」,
|
|
181
|
+
// 所以这里要让 spec 跟着一起变——判据读的是当下这次 spec(),不是启动时那次。
|
|
182
|
+
{
|
|
183
|
+
let declared = true;
|
|
184
|
+
const { ctx, regs } = makeCtx(() => (declared ? { kind: "list" } : undefined));
|
|
185
|
+
boot(ctx, (k) => k, () => null);
|
|
186
|
+
const disposes = fire(ctx, "sidebar.panellist");
|
|
187
|
+
assert.equal(alive(regs, "sidebar.footer.action"), 0);
|
|
188
|
+
declared = false;
|
|
189
|
+
disposes.forEach((d) => d());
|
|
190
|
+
assert.equal(alive(regs, "sidebar.panellist"), 0);
|
|
191
|
+
assert.equal(alive(regs, "main"), 0, "unmounting the seat must tear down the main panel too");
|
|
192
|
+
assert.equal(alive(regs, "sidebar.footer.action"), 1, "the fallback must come back when the seat disappears");
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// 侧栏那颗 glyph 是「宿主面板行里的图标」,不是按钮:宿主 PanelRow 已经渲染
|
|
197
|
+
// button + aria-label + Tooltip + aria-current,插件再套一层按钮就会出现两颗
|
|
198
|
+
// 可聚焦元素(Tab 停两次,读屏报两次),而且我们这层的 onClick 还得自己去调
|
|
199
|
+
// layout.selectPanel —— 那是宿主自己的活。
|
|
200
|
+
// 主面板同理不自带「回到会话」:宿主 openSession / 新建会话都走 selectPanel(null),
|
|
201
|
+
// 用户点侧栏任何会话行就回去了(baleen 也不做这颗按钮)。
|
|
202
|
+
function sliceComponent(name) {
|
|
203
|
+
const start = clientSource.indexOf(" function " + name + "(");
|
|
204
|
+
assert.notEqual(start, -1, name + " must exist as a component");
|
|
205
|
+
// 组件体到下一个「四空格缩进的 }」为止:与本文件 :3060 那条静态扫描同一套缩进约定。
|
|
206
|
+
const end = clientSource.indexOf("\n }", start);
|
|
207
|
+
assert.notEqual(end, -1, name + " must be a plain function component");
|
|
208
|
+
return clientSource.slice(start, end + 7);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
test("panel seats hand the host a glyph and a page, not a second button or a close affordance", () => {
|
|
212
|
+
const glyph = sliceComponent("SidebarPanelGlyph");
|
|
213
|
+
assert.equal(/h\("button"/.test(glyph), false,
|
|
214
|
+
"the sidebar glyph must not render its own button (the host panel row owns it)");
|
|
215
|
+
assert.ok(glyph.includes("mneme-paneliglyph"),
|
|
216
|
+
"the glyph needs its own root class to carry the conflict badge and the semantic color layer");
|
|
217
|
+
assert.ok(glyph.includes("h(ConflictBadge, { pending })"),
|
|
218
|
+
"the panel-list entry must keep the pending-conflict badge");
|
|
219
|
+
|
|
220
|
+
const main = sliceComponent("MnemeMainPanel");
|
|
221
|
+
assert.ok(main.includes("h(MemoryExplorer, { t })"),
|
|
222
|
+
"the center-column page must reuse the same MemoryExplorer");
|
|
223
|
+
assert.ok(main.includes('className: "mneme-panel"'),
|
|
224
|
+
"the page needs the fill container (the host main slot only gives min-width:0 + overflow:hidden)");
|
|
225
|
+
// 面板切换归宿主:PanelRow 点一下就是 layout.selectPanel(id),openSession /
|
|
226
|
+
// 新建会话又都是 selectPanel(null)。插件自己再调一次会把用户的导航意图打断
|
|
227
|
+
// (宿主那边 selectPanel 还会 abort 当前 navigation)。
|
|
228
|
+
assert.equal(clientSource.includes("ctx.layout.selectPanel"), false,
|
|
229
|
+
"the plugin must never call layout.selectPanel — the host row and openSession already do");
|
|
230
|
+
});
|
|
231
|
+
|
|
54
232
|
// The tab era is over: the in-conversation memory tab fought the floating
|
|
55
233
|
// composer and squeezed the session layout, so the library no longer
|
|
56
234
|
// registers as a conversation view. The sidebar entry opens the centered
|
|
@@ -182,6 +360,32 @@ test("sidebar entry portals into the host panel list with footer fallback", () =
|
|
|
182
360
|
);
|
|
183
361
|
});
|
|
184
362
|
|
|
363
|
+
// Issue #573:宿主 0.2.2 起把面板列表的「更多」行(PanelMoreMenu)也追加进同一个
|
|
364
|
+
// nav.panelList,于是组尾不再是面板行。仍按「追加到组尾」落位时,入口会排到
|
|
365
|
+
// 「更多」下面,菜单一展开就把入口盖住——用户看到的是「记忆入口不见了」。
|
|
366
|
+
// 回归表现:入口掉到「更多」之后、或展开菜单后入口被遮。
|
|
367
|
+
// 落位改成「插在『更多』之前」,判据与其余几处一致走 [class*=] 前缀,不写死
|
|
368
|
+
// 宿主哈希类名;老宿主没有该行时必须原样退回追加组尾,不能整块不落位。
|
|
369
|
+
test("sidebar entry lands above the host's More row, not below it", () => {
|
|
370
|
+
assert.ok(
|
|
371
|
+
clientSource.includes(`list.querySelector('[class*="moreAnchor"]')`),
|
|
372
|
+
"the More row must be located by class prefix like every other host class"
|
|
373
|
+
);
|
|
374
|
+
assert.ok(
|
|
375
|
+
clientSource.includes("list.insertBefore(created, more)"),
|
|
376
|
+
"the wide entry must insert before the More row so the expanded menu cannot cover it"
|
|
377
|
+
);
|
|
378
|
+
assert.ok(
|
|
379
|
+
clientSource.includes("created.nextElementSibling === more"),
|
|
380
|
+
"placement must stay idempotent against the More anchor so the observer never writes on steady state"
|
|
381
|
+
);
|
|
382
|
+
// 老宿主没有「更多」行:追加组尾的分支必须还在,否则那些宿主上入口根本不落位。
|
|
383
|
+
assert.ok(
|
|
384
|
+
clientSource.includes("created === list.lastElementChild"),
|
|
385
|
+
"hosts without a More row must keep the append-to-tail fallback"
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
|
|
185
389
|
// Issue #130: ecosystem plugins (task board, skill explorer, …) inject their
|
|
186
390
|
// own entries right after the New-Session row, and late inserters land above
|
|
187
391
|
// earlier ones — a fixed "before regionArea" slot degrades into the tail of
|
|
@@ -709,6 +913,45 @@ test("token flow: 7-day bars pad to a continuous week, heatmap uses its own 56-d
|
|
|
709
913
|
);
|
|
710
914
|
});
|
|
711
915
|
|
|
916
|
+
// 8 周沉淀图(热力右侧那块空白)是第二条独立数据源,四处静默失效都会让图看着
|
|
917
|
+
// 正常但结论错掉,所以照样锁形状:
|
|
918
|
+
// ① 必须自带 days=56 请求——复用 days=7 的响应会让 8 根柱子只有 1 根有数据;
|
|
919
|
+
// ② sinkStats 为 null(未到)与 {}(已知为空)必须分开,否则加载态先画 8 根
|
|
920
|
+
// 零高柱子,再「长出来」,用户看到的是一次假抖动;
|
|
921
|
+
// ③ 图例/柱体不能在三总量全 0 时照画——那等于把「最近没有东西进库」这句
|
|
922
|
+
// 谎话说成事实;
|
|
923
|
+
// ④ 两列并排必须带窄容器回落,否则 480px 侧栏里热力与沉淀互相压扁。
|
|
924
|
+
test("consolidation chart: own 56-day window, null-vs-empty split, and a narrow-container fallback", () => {
|
|
925
|
+
assert.ok(
|
|
926
|
+
clientSource.includes("semantic/consolidation-stats?days=56"),
|
|
927
|
+
"the consolidation chart must issue its own days=56 request"
|
|
928
|
+
);
|
|
929
|
+
assert.ok(
|
|
930
|
+
/if \(!sinkStats\) return null;/.test(clientSource),
|
|
931
|
+
"an unloaded consolidation response must stay null, else the loading state paints 8 empty weeks"
|
|
932
|
+
);
|
|
933
|
+
assert.ok(
|
|
934
|
+
/sinkDays && !sinkError/.test(clientSource),
|
|
935
|
+
"labels/legend must be gated on a loaded, non-erroring response"
|
|
936
|
+
);
|
|
937
|
+
assert.ok(
|
|
938
|
+
/sinkAddedTotal \+ sinkMergedTotal \+ sinkArchivedTotal === 0/.test(clientSource),
|
|
939
|
+
"an all-zero window must render the empty state, not eight zero-height bars"
|
|
940
|
+
);
|
|
941
|
+
// 两列:热力按内容宽 auto,沉淀吃剩余;窄容器回落单列。
|
|
942
|
+
assert.ok(
|
|
943
|
+
clientSource.includes("grid-template-columns:auto minmax(0,1fr)"),
|
|
944
|
+
"the heat row and the consolidation chart must sit in two columns"
|
|
945
|
+
);
|
|
946
|
+
assert.ok(
|
|
947
|
+
clientSource.includes("@container (max-width:960px){.mneme-flowrow{grid-template-columns:minmax(0,1fr)}}"),
|
|
948
|
+
"two columns must collapse to one in a narrow container"
|
|
949
|
+
);
|
|
950
|
+
// 两根柱的语义色必须走语义层,不能直接拿宿主 label token 当填充色(#178)。
|
|
951
|
+
assert.ok(clientSource.includes(".mneme-sinkbar--added{background:var(--mneme-chart-3)}"));
|
|
952
|
+
assert.ok(clientSource.includes(".mneme-sinkbar--merged{background:var(--mneme-chart-2)}"));
|
|
953
|
+
});
|
|
954
|
+
|
|
712
955
|
// 巩固/睡眠模型路由 UI:下拉数据来自宿主侧已注册适配器(/llm-providers,
|
|
713
956
|
// 云端插件侧端点),「测试连通性」走 POST /test-model 真实最小调用。旧后端
|
|
714
957
|
// 端点 404 时必须回退纯文本输入——前端自门控,不挡旧版本。
|
|
@@ -1197,14 +1440,17 @@ test("conflict queue: empty-state explainer card", () => {
|
|
|
1197
1440
|
}
|
|
1198
1441
|
});
|
|
1199
1442
|
|
|
1200
|
-
// 状态卡待处理高亮 +
|
|
1201
|
-
|
|
1443
|
+
// 状态卡待处理高亮 + 三个入口的 badge:宿主面板行图标(SidebarPanelGlyph)、
|
|
1444
|
+
// 老宿主的 portal 入口(SidebarTopEntry)、footer 回退按钮(SidebarFallbackTrigger)。
|
|
1445
|
+
// 三个都吃同一个模块级计数,所以这里锁的是「一个都没漏接」——漏一个的那台宿主
|
|
1446
|
+
// 上红点就不出现,而抽屉里仍然显示有待裁决冲突。
|
|
1447
|
+
test("conflict queue: actionable status card and every sidebar entry badge", () => {
|
|
1202
1448
|
assert.ok(clientSource.includes('"mneme-conflict-jump"'), "pending>0 card must carry the highlight class");
|
|
1203
1449
|
assert.ok(clientSource.includes("mneme-statuscard--actionable"), "highlight CSS must exist");
|
|
1204
|
-
assert.
|
|
1205
|
-
"badge must be wired into both entry
|
|
1206
|
-
assert.
|
|
1207
|
-
"
|
|
1450
|
+
assert.equal((clientSource.match(/h\(ConflictBadge, \{ pending \}\)/g) || []).length, 3,
|
|
1451
|
+
"badge must be wired into the panel-list glyph and both legacy entry surfaces");
|
|
1452
|
+
assert.equal((clientSource.match(/= useConflictBadgeCount\(\);/g) || []).length, 3,
|
|
1453
|
+
"all three entry components must subscribe to the shared badge count");
|
|
1208
1454
|
assert.ok(clientSource.includes('"/api/dsh-mneme/dream-status"'), "badge must reuse the dream-status endpoint");
|
|
1209
1455
|
});
|
|
1210
1456
|
|
|
@@ -1413,7 +1659,7 @@ test("panel can create a memory by hand: menu entry wired to the dialog", () =>
|
|
|
1413
1659
|
// 入口在「更多操作」菜单里,且菜单项真的调用 setCreateOpen(true)——只断言文案
|
|
1414
1660
|
// 存在的话,把 onClick 摘掉测试照样绿。
|
|
1415
1661
|
assert.ok(clientSource.includes('"memory.explorer.newMemory"'), "menu entry label must exist");
|
|
1416
|
-
assert.ok(/role: "menuitem", onClick: \(\) => \{ setCreateOpen\(true\); setMenuOpen\(false\); \}/.test(clientSource),
|
|
1662
|
+
assert.ok(/role: "menuitem", (?:disabled: bootstrapBusy, )?onClick: \(\) => \{ setCreateOpen\(true\); setMenuOpen\(false\); \}/.test(clientSource),
|
|
1417
1663
|
"the menu item must actually open the create dialog");
|
|
1418
1664
|
assert.ok(clientSource.includes("const [createOpen, setCreateOpen] = useState(false)"),
|
|
1419
1665
|
"the dialog needs its own open state");
|
|
@@ -2211,9 +2457,8 @@ test("update-check status names match the backend classify contract verbatim", (
|
|
|
2211
2457
|
"the backend must register the version-check route");
|
|
2212
2458
|
});
|
|
2213
2459
|
|
|
2214
|
-
// 画像提取草稿(#497
|
|
2215
|
-
//
|
|
2216
|
-
// onClick 处理器会把 click 事件当成要存的文本存进去。
|
|
2460
|
+
// 画像提取草稿(#497)面板面:路由对齐、四种结局各有自己的话、按钮坐在哪张卡上、
|
|
2461
|
+
// 以及草稿**只出副本**(2026-11-04 改口径:不再把草稿写进画像字段)。
|
|
2217
2462
|
test("profile extraction ships its button, distinct outcomes and correct save wiring", () => {
|
|
2218
2463
|
const apiSource = readFileSync(join(root, "src/api.js"), "utf8");
|
|
2219
2464
|
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/profile/extract"'),
|
|
@@ -2235,7 +2480,10 @@ test("profile extraction ships its button, distinct outcomes and correct save wi
|
|
|
2235
2480
|
"memory.settings.profileDraftTitle",
|
|
2236
2481
|
"memory.settings.profileDraftRows",
|
|
2237
2482
|
"memory.settings.profileDraftTruncated",
|
|
2238
|
-
"memory.settings.
|
|
2483
|
+
"memory.settings.profileDraftCopy",
|
|
2484
|
+
"memory.settings.profileDraftCopied",
|
|
2485
|
+
"memory.settings.profileDraftCopyHint",
|
|
2486
|
+
"memory.settings.profileDraftCopyFailed",
|
|
2239
2487
|
"memory.settings.profileDraftDiscard",
|
|
2240
2488
|
"memory.features.profileExtractEnabled",
|
|
2241
2489
|
"memory.features.profileExtractEnabled.hint"
|
|
@@ -2246,8 +2494,40 @@ test("profile extraction ships its button, distinct outcomes and correct save wi
|
|
|
2246
2494
|
assert.ok(/group\.enhance[\s\S]{0,700}"profileExtractEnabled"/.test(clientSource),
|
|
2247
2495
|
"FEATURE_GROUPS group.enhance must carry profileExtractEnabled");
|
|
2248
2496
|
|
|
2249
|
-
//
|
|
2250
|
-
|
|
2497
|
+
// 位置锁(2026-11-04):提取按钮属于「Agent 眼中的你」卡,不属于「用户画像」卡。
|
|
2498
|
+
// 此前没有任何用例锁位置——整块 UI 搬走测试也不会红,这正是它被放错地方却一直绿的原因。
|
|
2499
|
+
// 锁法:三处锚点的先后顺序 + 两张卡标题之间不许出现 profileExtract。
|
|
2500
|
+
const profileCardIdx = clientSource.indexOf('h("div", { className: "mneme-set-title" }, t("memory.settings.profile"))');
|
|
2501
|
+
const personaViewIdx = clientSource.indexOf('h("div", { className: "mneme-set-title" }, t("memory.settings.personaView"))');
|
|
2502
|
+
const extractButtonIdx = clientSource.indexOf('t("memory.settings.profileExtract")');
|
|
2503
|
+
assert.ok(profileCardIdx > 0 && personaViewIdx > profileCardIdx && extractButtonIdx > personaViewIdx,
|
|
2504
|
+
"the extract button must render inside the persona-view card, which comes after the user-profile card");
|
|
2505
|
+
assert.ok(!clientSource.slice(profileCardIdx, personaViewIdx).includes("profileExtract"),
|
|
2506
|
+
"the user-profile card must not carry any extract UI");
|
|
2507
|
+
|
|
2508
|
+
// 只出副本(2026-11-04 桉桉定的口径):草稿不许再走落库路径。此前 applyProfileDraft 会
|
|
2509
|
+
// 直接 PUT /profile —— 把用户手写的那份覆盖掉,且没有撤销。
|
|
2510
|
+
assert.ok(clientSource.includes("function copyProfileDraft()"), "the draft action must be a copy, not a save");
|
|
2511
|
+
assert.ok(!clientSource.includes("if (await saveProfile(text))"), "a draft must never be committed to the profile field");
|
|
2512
|
+
assert.ok(clientSource.includes("copyProfileDraftFallback(text)"),
|
|
2513
|
+
"the draft action must copy the draft text, with a fallback when the clipboard API is unavailable");
|
|
2514
|
+
// 复制是这条路径唯一的产出:静默失败等于让用户以为复制成功、粘贴时拿到旧文本。
|
|
2515
|
+
assert.ok(clientSource.includes('t("memory.settings.profileDraftCopyFailed")'),
|
|
2516
|
+
"a failed copy must say so instead of silently doing nothing");
|
|
2517
|
+
// 光 announce() 不够:那个 live region 是 1px 裁剪元素,只有屏幕阅读器读得到。
|
|
2518
|
+
// 视力正常的用户看到按钮文字不变、页面不变,会以为复制成功,粘贴时拿到旧内容。
|
|
2519
|
+
assert.ok(clientSource.includes("const [profileDraftCopyFailed, setProfileDraftCopyFailed] = react.useState(false)"),
|
|
2520
|
+
"the failed copy needs its own state, not just an announce() to a hidden live region");
|
|
2521
|
+
assert.ok(clientSource.includes("setProfileDraftCopyFailed(true)"),
|
|
2522
|
+
"the failure branch must flip the visible state");
|
|
2523
|
+
const copyFailedIdx = clientSource.indexOf("profileDraftCopyFailed ? h(\"span\"");
|
|
2524
|
+
assert.ok(copyFailedIdx > 0, "the failure must render as visible text near the copy button, not only in the live region");
|
|
2525
|
+
assert.ok(clientSource.slice(copyFailedIdx, copyFailedIdx + 220).includes('role: "alert"'),
|
|
2526
|
+
"the visible failure text should announce itself to assistive tech too");
|
|
2527
|
+
// 颜色走语义层:宿主 label-tertiary 这类低对比层当正文色会掉到 AA 以下(#178)。
|
|
2528
|
+
assert.ok(clientSource.slice(copyFailedIdx, copyFailedIdx + 220).includes('style: { color: "var(--mneme-danger)" }'),
|
|
2529
|
+
"the failure text must use the --mneme-danger semantic layer, not a raw host token");
|
|
2530
|
+
// 采用路径没了之后,保存按钮仍不能把 click 事件当参数传进去(保存函数带默认参数)。
|
|
2251
2531
|
assert.ok(!clientSource.includes("onClick: saveProfile }"), "the save button must not receive the click event as its value argument");
|
|
2252
2532
|
// apiFetch 是 fetch 的薄壳:非 2xx 不抛。画像这条以前没判 res.ok(同页的 persona
|
|
2253
2533
|
// 一直判),于是服务端 403/413 时界面照样闪「画像已保存」。
|
|
@@ -2258,6 +2538,58 @@ test("profile extraction ships its button, distinct outcomes and correct save wi
|
|
|
2258
2538
|
assert.ok(clientSource.includes('"no-route" ? "noroute"'), "no-route must map to its own panel outcome, not the generic failure");
|
|
2259
2539
|
});
|
|
2260
2540
|
|
|
2541
|
+
// 复制的竞态(CodeRabbit #559 第二轮):剪贴板是异步的,回调属于哪一份草稿必须能认出来。
|
|
2542
|
+
// 复制 A 还没落地时又提取出草稿 B,A 的回调就会在 B 上打勾或报错——B 一个字都没被复制过,
|
|
2543
|
+
// 而「已复制」会挂 1.5 秒,于是同一屏上可能出现「已复制」和「复制没成功」两句相反的话。
|
|
2544
|
+
test("profile draft copy ticket keeps stale copy callbacks off a newer draft", () => {
|
|
2545
|
+
const copySrc = clientSource.slice(
|
|
2546
|
+
clientSource.indexOf("function copyProfileDraft("),
|
|
2547
|
+
clientSource.indexOf("function copyProfileDraft(") + 2000
|
|
2548
|
+
);
|
|
2549
|
+
assert.ok(clientSource.includes("const profileCopyTicket = react.useRef(0)"),
|
|
2550
|
+
"each copy attempt needs a ticket so a stale clipboard callback can be told apart");
|
|
2551
|
+
assert.ok(copySrc.includes("const ticket = ++profileCopyTicket.current"),
|
|
2552
|
+
"every attempt must take a fresh ticket before the async clipboard call");
|
|
2553
|
+
assert.ok(copySrc.includes("const current = () => ticket === profileCopyTicket.current"),
|
|
2554
|
+
"the callback must compare its ticket against the live one");
|
|
2555
|
+
// 成功与失败两条回调都要判票:只判一条,另一条照样会污染新草稿。
|
|
2556
|
+
assert.equal((copySrc.match(/if \(\(?!current\(\)\)\) return;/g) || []).length >= 2 ||
|
|
2557
|
+
(copySrc.match(/if \(!current\(\)\) return;/g) || []).length >= 2, true,
|
|
2558
|
+
"both the success and failure callbacks must drop themselves when their ticket is stale");
|
|
2559
|
+
// 失败必须撤掉「已复制」:成功态能挂 1.5 秒,这期间重试失败会同时显示两句相反的话。
|
|
2560
|
+
const failSrc = copySrc.slice(copySrc.indexOf("const failed = () =>"));
|
|
2561
|
+
assert.ok(failSrc.slice(0, 200).includes("setProfileDraftCopied(false)"),
|
|
2562
|
+
"a failed retry must clear the lingering copied state instead of showing both messages");
|
|
2563
|
+
// 换草稿(重新提取 / 丢弃)都要让旧票作废,并清掉旧提示。
|
|
2564
|
+
const extractSrc = clientSource.slice(clientSource.indexOf("async function extractProfileDraft("));
|
|
2565
|
+
assert.ok(extractSrc.slice(0, 400).includes("profileCopyTicket.current += 1"),
|
|
2566
|
+
"starting a new extraction must invalidate the previous copy attempt");
|
|
2567
|
+
const discardSrc = clientSource.slice(clientSource.indexOf('t("memory.settings.profileDraftDiscard")') - 400);
|
|
2568
|
+
assert.ok(discardSrc.slice(0, 400).includes("profileCopyTicket.current += 1") &&
|
|
2569
|
+
discardSrc.slice(0, 400).includes("setProfileDraftCopyFailed(false)"),
|
|
2570
|
+
"discarding the draft must clear both the ticket and the error text it left behind");
|
|
2571
|
+
});
|
|
2572
|
+
|
|
2573
|
+
// 实体总数读不到时不许拿切片长度冒充(CodeRabbit #559 第二轮):路由回落时把
|
|
2574
|
+
// entities.length 写进 total,面板再把它当全库数显示,等于把「这次取回多少」说成
|
|
2575
|
+
// 「全库有多少」——那正是这条路由原来「永远 500」的错法换个方向复发。
|
|
2576
|
+
test("entity card shows an unknown total instead of passing a slice off as the library", () => {
|
|
2577
|
+
for (const key of ["memory.status.entitiesUnknown"]) {
|
|
2578
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2579
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2580
|
+
}
|
|
2581
|
+
const cardSrc = clientSource.slice(
|
|
2582
|
+
clientSource.indexOf("function EntitiesStatusCard("),
|
|
2583
|
+
clientSource.indexOf("function EntitiesStatusCard(") + 2600
|
|
2584
|
+
);
|
|
2585
|
+
assert.ok(cardSrc.includes("j.totalExact === true"),
|
|
2586
|
+
"the card must gate the big number on the backend's exactness flag");
|
|
2587
|
+
assert.ok(cardSrc.includes('state.totalKnown === false ? "—"'),
|
|
2588
|
+
"an inexact total must render as unknown, not as a plausible-looking number");
|
|
2589
|
+
assert.ok(cardSrc.includes('t("memory.status.entitiesUnknown")'),
|
|
2590
|
+
"the card must say why the total is missing, not just blank it out");
|
|
2591
|
+
});
|
|
2592
|
+
|
|
2261
2593
|
// Issue #521:degraded 轮的跳过明细必须能在面板上看见。此前 skipped 只落库,
|
|
2262
2594
|
// 响应里没有聚合,status: degraded + applied: 3 从外面看完全是健康轮的样子——
|
|
2263
2595
|
// 一整类动作 100% 被拦要自己写 SQL 才看得出,那个 bug 跑了一整天才被发现。
|
|
@@ -2714,6 +3046,256 @@ test("entity graph surfaces the passive-confirmation review surface (issue #24)"
|
|
|
2714
3046
|
// · 现有用例——它们读的是源码字符串,从不执行组件。
|
|
2715
3047
|
// 所以这里不锁某一个名字,而是把**每个组件里「调了 setter 却没有声明」**这件事整体扫一遍,
|
|
2716
3048
|
// 才对得起「别再有人把 state 放进隔壁组件」这个意图。
|
|
3049
|
+
test("panel exposes the bootstrap entry and the config-key coverage card (issue #386)", () => {
|
|
3050
|
+
// 第 6 条:从目录引导。后端从 #220 就在,缺的是面板入口——所以锁四件事:
|
|
3051
|
+
// 菜单项存在、弹层真的挂在菜单上、请求打的是新路由、错误分码显示。
|
|
3052
|
+
assert.ok(
|
|
3053
|
+
/setBootstrapOpen\(true\); setMenuOpen\(false\)/.test(clientSource),
|
|
3054
|
+
"the overflow menu needs a bootstrap item that opens its dialog"
|
|
3055
|
+
);
|
|
3056
|
+
assert.ok(
|
|
3057
|
+
/bootstrapOpen && h\(BootstrapDialog, \{/.test(clientSource),
|
|
3058
|
+
"the dialog must actually be mounted, not just declared"
|
|
3059
|
+
);
|
|
3060
|
+
// 菜单按钮的 onClick 里必须也关掉它,否则点「⋯」会留下上一个弹层。但它现在带一个
|
|
3061
|
+
// 条件:引导请求在飞时不关(否则等于绕过弹层里那两道 busy 禁用的锁,评审
|
|
3062
|
+
// #4452245292)。所以这里锁的是「有条件地关」,不是无条件关。
|
|
3063
|
+
assert.ok(
|
|
3064
|
+
/setImportOpen\(false\);\s*\n\s*\/\/[\s\S]{0,200}?if \(!bootstrapBusy\) setBootstrapOpen\(false\);/.test(clientSource),
|
|
3065
|
+
"the overflow toggle must close the bootstrap dialog when it is not busy"
|
|
3066
|
+
);
|
|
3067
|
+
// 父组件必须真的把这个标志接下来,否则上面那句恒为真(undefined → !undefined)。
|
|
3068
|
+
assert.ok(
|
|
3069
|
+
/const \[bootstrapBusy, setBootstrapBusy\] = useState\(false\)/.test(clientSource),
|
|
3070
|
+
"the parent needs a bootstrapBusy flag for the guard above to mean anything"
|
|
3071
|
+
);
|
|
3072
|
+
assert.ok(
|
|
3073
|
+
/onBusyChange: reportBootstrapBusy/.test(clientSource),
|
|
3074
|
+
"the dialog must report its busy state up, or the guard reads a stale false"
|
|
3075
|
+
);
|
|
3076
|
+
// 这个转发函数必须同步调外层、且身份稳定:走 useEffect 转报的话要等渲染提交后
|
|
3077
|
+
// 才跑,中间那一拍外层读到 false(#4452539705 的老毛病);而若每渲染新建一个,
|
|
3078
|
+
// BootstrapDialog 的卸载 effect(依赖 [onBusyChange])会在父组件每次重渲染时
|
|
3079
|
+
// 先跑 cleanup 调一次 onBusyChange(false)——而父组件正是在置 busy 之后重渲染的。
|
|
3080
|
+
assert.ok(
|
|
3081
|
+
/const reportBootstrapBusy = useCallback\(\(v\) => \{[\s\S]{0,200}?setBootstrapBusy\(v\);[\s\S]{0,200}?onBusyChange\(v\);/.test(clientSource),
|
|
3082
|
+
"the forwarder must set both levels synchronously and keep a stable identity"
|
|
3083
|
+
);
|
|
3084
|
+
// 会挂弹层的菜单项在忙碌时必须禁用(评审 #4452539738):新建 / 导入 / 引导 / 分享 /
|
|
3085
|
+
// 接收共五处,加上「⋯」按钮自己(见下),再加上四个视图标签(评审 #4453639221,
|
|
3086
|
+
// 源码里那一处在 `.map` 里,一处覆盖四个)。数个数而不是逐个锁字符串——少一处就
|
|
3087
|
+
// 等于留了一条绕过弹层的路,而这条路的代价是用户看不到自己那次引导的结果。
|
|
3088
|
+
assert.equal(
|
|
3089
|
+
clientSource.split("disabled: bootstrapBusy").length - 1,
|
|
3090
|
+
7,
|
|
3091
|
+
"every dialog-mounting menu item, the overflow toggle and the view tabs must be disabled while bootstrap is busy"
|
|
3092
|
+
);
|
|
3093
|
+
// 视图标签离开 memory 会连 `.mneme-xtools` 与其中的 BootstrapDialog 一起卸载
|
|
3094
|
+
// (评审 #4453639221)。两道锁都要有:`disabled` 是第一道,onClick 走 leaveMemory
|
|
3095
|
+
// 是第二道——将来有人给标签换一种可点的实现时,函数里的判断还在。
|
|
3096
|
+
assert.ok(
|
|
3097
|
+
/disabled: bootstrapBusy,[\s\S]{0,300}?onClick: \(\) => leaveMemory\(s\.key\)/.test(clientSource),
|
|
3098
|
+
"view tabs must route through leaveMemory, not setView directly"
|
|
3099
|
+
);
|
|
3100
|
+
assert.ok(
|
|
3101
|
+
/const leaveMemory = \(key\) => \{[\s\S]{0,120}?if \(bootstrapBusy\) return;[\s\S]{0,80}?setView\(key\);/.test(clientSource),
|
|
3102
|
+
"leaveMemory must refuse to switch views while a bootstrap request is in flight"
|
|
3103
|
+
);
|
|
3104
|
+
// 视图标签不是唯一的出口:记忆视图里那个实体 chip(`openGraphFor`)也把 view 切到
|
|
3105
|
+
// "entity",同样会把 BootstrapDialog 一起卸掉。这条是变异自证抓出来的——四个变异体
|
|
3106
|
+
// 里只有它活下来,说明我先前只锁了标签、漏了这条真实的旁路。
|
|
3107
|
+
assert.ok(
|
|
3108
|
+
/const openGraphFor = \(name\) => \{[\s\S]{0,120}?if \(bootstrapBusy\) return;/.test(clientSource),
|
|
3109
|
+
"the entity chip's graph jump must also refuse to leave memory while bootstrap is busy"
|
|
3110
|
+
);
|
|
3111
|
+
// 「⋯」按钮自己也要禁用(评审 #4453225585):菜单与引导弹层共用同一个绝对定位锚点、
|
|
3112
|
+
// 同一层 z-index,弹层排在菜单之后渲染,会把菜单顶上那两项导出盖住——它们没有
|
|
3113
|
+
// disabled,却点不到。上一轮我以为「导出项不挂弹层所以照旧可点」就没事了,那是错的:
|
|
3114
|
+
// 可点的是菜单项本身,但它被遮住了。不开菜单,才不存在「可点但被遮住」。
|
|
3115
|
+
assert.ok(
|
|
3116
|
+
/title: t\("memory\.explorer\.more"\),[\s\S]{0,600}?disabled: bootstrapBusy,[\s\S]{0,200}?onClick: \(\) => \{\s*\n\s*setMenuOpen\(!menuOpen\);/.test(clientSource),
|
|
3117
|
+
"the overflow toggle must be disabled while bootstrap is busy, so the dialog never covers its items"
|
|
3118
|
+
);
|
|
3119
|
+
const bsIdx = clientSource.indexOf("function BootstrapDialog");
|
|
3120
|
+
// 切到下一个弹层为止,而不是给一个拍脑袋的字符数:这轮改动静悄悄把窗口撑破了,
|
|
3121
|
+
// 断言于是在一个截断的片段上做,失败信息还指着「输入框没禁用」这种不存在的毛病。
|
|
3122
|
+
const bsEnd = clientSource.indexOf("// --- 分享包弹层 ---", bsIdx);
|
|
3123
|
+
const bsBody = clientSource.slice(bsIdx, bsEnd > bsIdx ? bsEnd : bsIdx + 4200);
|
|
3124
|
+
assert.ok(bsIdx > 0, "BootstrapDialog must exist");
|
|
3125
|
+
// 忙碌状态必须**同步**报上去(评审 #4452539705):只靠上面那个 useEffect 的话,
|
|
3126
|
+
// 它要等这次渲染提交后才跑,中间那一拍父组件的 bootstrapBusy 还是 false,此时点
|
|
3127
|
+
// 「⋯」仍会把弹层卸掉,请求的成功或失败就再没有地方显示。
|
|
3128
|
+
assert.ok(
|
|
3129
|
+
/setBusy\(true\);[\s\S]{0,400}?onBusyChange\(true\);/.test(bsBody),
|
|
3130
|
+
"busy must be reported up synchronously, not only through the effect"
|
|
3131
|
+
);
|
|
3132
|
+
assert.ok(
|
|
3133
|
+
/apiFetch\("\/api\/dsh-mneme\/bootstrap"/.test(bsBody),
|
|
3134
|
+
"the dialog must POST to the bootstrap route"
|
|
3135
|
+
);
|
|
3136
|
+
assert.ok(
|
|
3137
|
+
/JSON\.stringify\(\{ dir \}\)/.test(bsBody),
|
|
3138
|
+
"the request body carries the raw dir string"
|
|
3139
|
+
);
|
|
3140
|
+
// 请求在飞时锁住三处:输入框、取消、提交。少了输入框那一处,用户改成 B 之后回来的
|
|
3141
|
+
// 提示会贴在 B 旁边、而它讲的是 A 的结果(评审 #4452245279)。断言从 `type: "text"`
|
|
3142
|
+
// 一路看到 `onChange`,而不是只说「附近有个 disabled: busy」——后者底下的按钮也能满足。
|
|
3143
|
+
assert.ok(
|
|
3144
|
+
/type: "text",[\s\S]{0,400}?disabled: busy,[\s\S]{0,400}?onChange: \(e\) => \{ setDir/.test(bsBody),
|
|
3145
|
+
"the dir input must be disabled while the request is in flight"
|
|
3146
|
+
);
|
|
3147
|
+
// 成功路径不再复位 busy(900ms 提示期间按钮不该重新变得可点),且定时器句柄要能清:
|
|
3148
|
+
// 否则第二次提交的结果会写进已经卸载的组件(评审 #4452245256)。断言窗口只取
|
|
3149
|
+
// 「setMsg…到 setTimeout」之间——再往后就是 catch 里的网络错误兜底,那一处复位是对的。
|
|
3150
|
+
const doneIdx = bsBody.indexOf("memory.explorer.bootstrapDone");
|
|
3151
|
+
const timerIdx = bsBody.indexOf("timerRef.current = setTimeout(");
|
|
3152
|
+
assert.ok(timerIdx > doneIdx, "the success branch must schedule its close through the stored ref");
|
|
3153
|
+
const successTail = bsBody.slice(doneIdx, timerIdx);
|
|
3154
|
+
assert.ok(!/setBusy\(false\)/.test(successTail), "the success path must not re-enable the button");
|
|
3155
|
+
assert.ok(/clearTimeout\(timerRef\.current\)/.test(bsBody), "the stored timer must actually be cleared on unmount");
|
|
3156
|
+
// 刷新必须同步调、不能排进定时器(评审 #4452539711):切视图会卸载本组件并把
|
|
3157
|
+
// timerRef 清掉,若刷新也挂在那 900ms 后面,它就静默丢了——库已经写进去了,
|
|
3158
|
+
// 列表却还是旧的。而这条路径上用户刚看到「新建 N 条」,最容易立刻去翻列表。
|
|
3159
|
+
const importedIdx = bsBody.indexOf("onImported();");
|
|
3160
|
+
assert.ok(importedIdx > 0, "the success path must refresh the list");
|
|
3161
|
+
assert.ok(
|
|
3162
|
+
importedIdx < timerIdx,
|
|
3163
|
+
"onImported must run synchronously, not inside the close timer"
|
|
3164
|
+
);
|
|
3165
|
+
assert.ok(
|
|
3166
|
+
!/setTimeout\(\(\)\s*=>\s*\{?\s*onImported/.test(bsBody),
|
|
3167
|
+
"the refresh must not be deferred into the close timer"
|
|
3168
|
+
);
|
|
3169
|
+
// 错误按服务端的码分开说(missing-dir / dir-not-found / not-a-directory /
|
|
3170
|
+
// bootstrap-unavailable),不能一律「引导失败」——三种情况下用户该做的事不同。
|
|
3171
|
+
assert.ok(
|
|
3172
|
+
/"memory\.explorer\.bootstrapErr\." \+ String\(code/.test(bsBody),
|
|
3173
|
+
"error codes must be mapped to distinct messages"
|
|
3174
|
+
);
|
|
3175
|
+
for (const key of [
|
|
3176
|
+
"memory.explorer.bootstrap",
|
|
3177
|
+
"memory.explorer.bootstrapTitle",
|
|
3178
|
+
"memory.explorer.bootstrapHint",
|
|
3179
|
+
"memory.explorer.bootstrapConfirm",
|
|
3180
|
+
"memory.explorer.bootstrapDone",
|
|
3181
|
+
"memory.explorer.bootstrapNoDir",
|
|
3182
|
+
"memory.explorer.bootstrapFailed",
|
|
3183
|
+
"memory.explorer.bootstrapErr.missing-dir",
|
|
3184
|
+
"memory.explorer.bootstrapErr.dir-not-found",
|
|
3185
|
+
"memory.explorer.bootstrapErr.not-a-directory",
|
|
3186
|
+
"memory.explorer.bootstrapErr.bootstrap-unavailable"
|
|
3187
|
+
]) {
|
|
3188
|
+
assert.ok(
|
|
3189
|
+
clientSource.split(`"${key}"`).length - 1 >= 2,
|
|
3190
|
+
`i18n key ${key} must exist in both dictionaries`
|
|
3191
|
+
);
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
// 第 7 条:配置键覆盖面只读卡。
|
|
3195
|
+
const ckIdx = clientSource.indexOf("function ConfigKeysCard");
|
|
3196
|
+
assert.ok(ckIdx > 0, "ConfigKeysCard must exist");
|
|
3197
|
+
const ckBody = clientSource.slice(ckIdx, ckIdx + 2900);
|
|
3198
|
+
assert.ok(/apiFetch\("\/api\/dsh-mneme\/config-keys"\)/.test(ckBody), "the card must read the new route");
|
|
3199
|
+
// 取数失败与「真的没有只读键」必须分开说,否则失败会被读成「清单是空的」。
|
|
3200
|
+
assert.ok(
|
|
3201
|
+
!/state\.failed \|\| !counts\s*\?\s*null/.test(ckBody),
|
|
3202
|
+
"a failed fetch must not collapse into an empty list"
|
|
3203
|
+
);
|
|
3204
|
+
assert.ok(/memory\.settings\.configKeys\.loadFailed/.test(ckBody), "the failed branch needs its own copy");
|
|
3205
|
+
// 摘要里的三类数字都必须来自服务端算好的桶:只填 {file} 会让「schema − panel」与
|
|
3206
|
+
// 展开后「只能改文件」那段对不上(评审 #4452245244)。
|
|
3207
|
+
assert.ok(
|
|
3208
|
+
/\.replace\("\{undefaulted\}", String\(counts\.undefaulted\)\)/.test(ckBody),
|
|
3209
|
+
"the summary must report the undefaulted bucket too, not make the reader subtract"
|
|
3210
|
+
);
|
|
3211
|
+
// 只列键名,不渲染任何值。
|
|
3212
|
+
assert.ok(/className: "mneme-keychip"/.test(ckBody), "keys render as chips");
|
|
3213
|
+
assert.ok(!/\.value/.test(ckBody.replace(/display:\s*"flex"[\s\S]{0,80}/g, "")), "the card must not render any key value");
|
|
3214
|
+
for (const key of [
|
|
3215
|
+
"memory.settings.configKeys.title",
|
|
3216
|
+
"memory.settings.configKeys.desc",
|
|
3217
|
+
"memory.settings.configKeys.summary",
|
|
3218
|
+
"memory.settings.configKeys.fileOnlyHead",
|
|
3219
|
+
"memory.settings.configKeys.undefaultedHead",
|
|
3220
|
+
"memory.settings.configKeys.undefaultedHint",
|
|
3221
|
+
"memory.settings.configKeys.show",
|
|
3222
|
+
"memory.settings.configKeys.hide",
|
|
3223
|
+
"memory.settings.configKeys.loadFailed"
|
|
3224
|
+
]) {
|
|
3225
|
+
assert.ok(
|
|
3226
|
+
clientSource.split(`"${key}"`).length - 1 >= 2,
|
|
3227
|
+
`i18n key ${key} must exist in both dictionaries`
|
|
3228
|
+
);
|
|
3229
|
+
}
|
|
3230
|
+
// 卡片要真的挂到设置页上(connect 组尾、help 组头之前),不是写了没插。
|
|
3231
|
+
const mountIdx = clientSource.indexOf("h(ConfigKeysCard, { t })");
|
|
3232
|
+
const helpIdx = clientSource.indexOf('groupHead("help")');
|
|
3233
|
+
assert.ok(mountIdx > 0, "ConfigKeysCard must be mounted in the settings page");
|
|
3234
|
+
assert.ok(mountIdx < helpIdx, "the card belongs to the end of the connect group, before the help section");
|
|
3235
|
+
// 引导成功只递增 reloadKey;搜索态的列表走 remoteItems 那条 effect,它若不吃
|
|
3236
|
+
// reloadKey,搜索词不变就永远看不到新导入的行(评审 #4452245305)。
|
|
3237
|
+
assert.ok(
|
|
3238
|
+
/\}, \[query, semantic, searchTopK, reloadKey\]\);/.test(clientSource),
|
|
3239
|
+
"the search effect must re-run after an import/bootstrap bumps reloadKey"
|
|
3240
|
+
);
|
|
3241
|
+
});
|
|
3242
|
+
|
|
3243
|
+
test("the memory overlay refuses to close while a library request is in flight (issue #386)", () => {
|
|
3244
|
+
// 引导请求在飞时,Esc / 背板 / ✕ 三条关闭路径都必须在。少一处,弹层连同
|
|
3245
|
+
// MemoryExplorer 一起卸载,POST 还在飞——回来那句「新建几条、并入几条」就没有
|
|
3246
|
+
// 地方显示,用户会以为没成功,而库其实已经写进去了(评审 #4453225573)。
|
|
3247
|
+
const ovIdx = clientSource.indexOf("function MemoryOverlay");
|
|
3248
|
+
assert.ok(ovIdx > 0, "MemoryOverlay must exist");
|
|
3249
|
+
// 切到下一个模块级定义为止:给字符数会在下一次改动里被悄悄撑破。
|
|
3250
|
+
const ovEnd = clientSource.indexOf("const EXPLORER_TYPES", ovIdx);
|
|
3251
|
+
const ovBody = clientSource.slice(ovIdx, ovEnd > ovIdx ? ovEnd : ovIdx + 2600);
|
|
3252
|
+
|
|
3253
|
+
// 忙碌标志必须从 Explorer 上得来:MemoryOverlay 自己不知道有没有请求在飞。
|
|
3254
|
+
assert.ok(
|
|
3255
|
+
/useState\(false\)/.test(ovBody) && /explorerBusy/.test(ovBody),
|
|
3256
|
+
"the overlay needs its own busy flag"
|
|
3257
|
+
);
|
|
3258
|
+
assert.ok(
|
|
3259
|
+
/h\(MemoryExplorer, \{ t, onBusyChange: setExplorerBusy \}\)/.test(ovBody),
|
|
3260
|
+
"the explorer must report its busy state up to the overlay"
|
|
3261
|
+
);
|
|
3262
|
+
// 用 ref 读、不把 busy 列进那个 effect 的依赖:effect 每次重跑都会
|
|
3263
|
+
// closeBtnRef.current.focus(),请求一起一落就把正在输目录的用户踢出输入框。
|
|
3264
|
+
assert.ok(
|
|
3265
|
+
/const explorerBusyRef = useRef\(false\)/.test(ovBody),
|
|
3266
|
+
"the guard must read through a ref, so busy changes do not re-run the focus effect"
|
|
3267
|
+
);
|
|
3268
|
+
assert.ok(
|
|
3269
|
+
/explorerBusyRef\.current = explorerBusy/.test(ovBody),
|
|
3270
|
+
"the ref must track the state"
|
|
3271
|
+
);
|
|
3272
|
+
assert.ok(
|
|
3273
|
+
!/\[open, explorerBusy\]/.test(ovBody),
|
|
3274
|
+
"explorerBusy must stay out of the focus effect's deps"
|
|
3275
|
+
);
|
|
3276
|
+
|
|
3277
|
+
// 三条关闭路径逐一检查:Esc、背板、✕ 都走同一个守卫。
|
|
3278
|
+
assert.equal(
|
|
3279
|
+
ovBody.split("if (!explorerBusyRef.current) setOpen(false);").length - 1,
|
|
3280
|
+
3,
|
|
3281
|
+
"all three close paths (Escape, backdrop, ✕) must guard on the busy ref"
|
|
3282
|
+
);
|
|
3283
|
+
assert.ok(
|
|
3284
|
+
/if \(e\.key === "Escape"\) \{ if \(!explorerBusyRef\.current\) setOpen\(false\); return; \}/.test(ovBody),
|
|
3285
|
+
"Escape must not close the overlay while a request is in flight"
|
|
3286
|
+
);
|
|
3287
|
+
// ✕ 用 aria-disabled 而不是真的 disabled:真 disabled 会把焦点从按钮上拿走,
|
|
3288
|
+
// 而 #178 的 Tab 环拿 closeBtnRef 当入口,焦点落空就出不去。
|
|
3289
|
+
assert.ok(
|
|
3290
|
+
/"aria-disabled": String\(explorerBusy\)/.test(ovBody),
|
|
3291
|
+
"the close button must announce the blocked state"
|
|
3292
|
+
);
|
|
3293
|
+
assert.ok(
|
|
3294
|
+
!/className: "mneme-footbtn",\s*\n\s*ref: closeBtnRef,\s*\n\s*disabled: /.test(ovBody),
|
|
3295
|
+
"the close button must not be hard-disabled — that would break the #178 focus ring"
|
|
3296
|
+
);
|
|
3297
|
+
});
|
|
3298
|
+
|
|
2717
3299
|
test("every client component declares the setters it calls (issue #553)", () => {
|
|
2718
3300
|
// 浏览器/宿主全局,不是 React state,不该被要求声明
|
|
2719
3301
|
const GLOBALS = new Set([
|
|
@@ -2776,3 +3358,57 @@ test("every client component declares the setters it calls (issue #553)", () =>
|
|
|
2776
3358
|
"a setter must be declared in the component that calls it — a state hook placed in the "
|
|
2777
3359
|
+ "neighbouring component compiles fine but throws ReferenceError at render time (#553)");
|
|
2778
3360
|
});
|
|
3361
|
+
|
|
3362
|
+
// --- 实体页计数口径(#562 真机验证发现) ---
|
|
3363
|
+
// 实体页左侧栏此前固定显示「500 个实体」:它拿 `entities.length` 当总数,而那份列表
|
|
3364
|
+
// 是 `/entities?limit=500` 取回的最新 500 个。库里一万六千个实体被截成一个看起来正常
|
|
3365
|
+
// 的数字,用户据此判断「实体太少」——#559 只修了状态页那张卡,实体页这一处漏了。
|
|
3366
|
+
// 本用例锁四件事,防的回归各不相同:
|
|
3367
|
+
// ① 总数来源必须是后端回的 `total`+ `totalExact`,不是切片长度;
|
|
3368
|
+
// ② 全库数读不到时(老宿主 / 计数失败)退回切片长度而不是显示空白;
|
|
3369
|
+
// ③ 列表本身仍只有 500 条,必须在界面上说清「总数是全库、列表是最近 N 个」,
|
|
3370
|
+
// 否则数字对了而列表还是 500 行,同一处观感错位只是换了个方向;
|
|
3371
|
+
// ④ 那句说明走 i18n(中英各一条),不能硬编码中文。
|
|
3372
|
+
test("the entity rail counts the whole library, not the newest slice", () => {
|
|
3373
|
+
const start = clientSource.indexOf("function EntityPanel(");
|
|
3374
|
+
assert.ok(start > 0, "EntityPanel must exist");
|
|
3375
|
+
const nextFn = clientSource.indexOf("\n function ", start + 10);
|
|
3376
|
+
assert.ok(nextFn > start, "EntityPanel must be followed by another module-level function");
|
|
3377
|
+
const body = clientSource.slice(start, nextFn);
|
|
3378
|
+
|
|
3379
|
+
// ① 总数取自 totalExact 守门的 total。
|
|
3380
|
+
assert.ok(
|
|
3381
|
+
body.includes("setEntityTotal(j.totalExact === true && typeof j.total === \"number\" ? j.total : null)"),
|
|
3382
|
+
"the rail total must come from the backend's total/totalExact, not the slice length"
|
|
3383
|
+
);
|
|
3384
|
+
|
|
3385
|
+
// ② 计数行用全库数,读不到才退回切片长度。
|
|
3386
|
+
assert.ok(
|
|
3387
|
+
body.includes('t("memory.entities.count").replace("{n}", String(entityTotal !== null ? entityTotal : entities.length))'),
|
|
3388
|
+
"the rail count must print the whole-library total with a slice-length fallback"
|
|
3389
|
+
);
|
|
3390
|
+
assert.ok(
|
|
3391
|
+
!body.includes('t("memory.entities.count").replace("{n}", String(entities.length))'),
|
|
3392
|
+
"printing entities.length directly is the \"always 500\" bug this test exists to prevent"
|
|
3393
|
+
);
|
|
3394
|
+
|
|
3395
|
+
// ③ 列表只列最近 N 个这件事必须写出来(且只在真有截断时写)。
|
|
3396
|
+
assert.ok(
|
|
3397
|
+
/entityTotal !== null && entityTotal > entities\.length[\s\S]{0,200}memory\.entities\.countSampled/.test(body),
|
|
3398
|
+
"a truncated list must say so instead of letting one number stand for both"
|
|
3399
|
+
);
|
|
3400
|
+
assert.ok(
|
|
3401
|
+
body.includes('t("memory.entities.countSampled").replace("{shown}", String(entities.length))'),
|
|
3402
|
+
"the notice must name how many entities the list actually shows"
|
|
3403
|
+
);
|
|
3404
|
+
|
|
3405
|
+
// ④ 文案双语齐备,样式走语义色层。
|
|
3406
|
+
for (const key of ["memory.entities.countSampled"]) {
|
|
3407
|
+
const defined = clientSource.split(`"${key}":`).length - 1;
|
|
3408
|
+
assert.equal(defined, 2, `i18n key ${key} must be defined exactly once in zh and once in en (got ${defined})`);
|
|
3409
|
+
}
|
|
3410
|
+
assert.ok(
|
|
3411
|
+
clientSource.includes(".mneme-xcountnote{") && /color:var\(--mneme-fg-dim\)/.test(clientSource.slice(clientSource.indexOf(".mneme-xcountnote{"))),
|
|
3412
|
+
"the notice needs its own dim style on the semantic colour layer"
|
|
3413
|
+
);
|
|
3414
|
+
});
|