@modusensus/dsh-mneme 0.8.14 → 0.8.15
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/LICENSE +21 -21
- package/README.md +655 -654
- package/assets/icon.svg +3 -3
- package/bin/cli.mjs +689 -689
- package/bin/dsh-mneme-mcp.mjs +510 -510
- package/bin/dsh-mneme-serve.mjs +151 -151
- package/cordis.patch.yml +21 -21
- package/docs/CONFIGURATION.md +263 -0
- package/docs/CONTINUITY.md +124 -0
- package/docs/DAEMON.md +46 -0
- package/docs/ENTITIES.md +245 -0
- package/docs/LOCAL_MODEL.md +222 -0
- package/docs/SEMANTIC.md +256 -0
- package/docs/SLEEP.md +164 -0
- package/docs/STORAGE.md +89 -0
- package/lib/api-standalone.js +673 -673
- package/lib/api.js +1437 -1433
- package/lib/bootstrap.js +211 -211
- package/lib/client.js +5444 -5295
- package/lib/commands.js +95 -95
- package/lib/config.js +880 -843
- package/lib/content-hash.js +54 -54
- package/lib/continuity.js +220 -220
- package/lib/document-index.js +94 -94
- package/lib/document.js +303 -303
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +674 -674
- package/lib/dream/narratives.js +44 -44
- package/lib/dream/sleep.js +819 -819
- package/lib/dream.js +1501 -1501
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +252 -252
- package/lib/graph/anchoring.js +179 -179
- package/lib/guide.js +74 -74
- package/lib/heat.js +144 -144
- package/lib/hot-memory.js +46 -46
- package/lib/index.js +535 -503
- package/lib/inject.js +464 -450
- package/lib/lang.js +248 -229
- package/lib/local-embedder.js +358 -358
- package/lib/maintenance.js +142 -142
- package/lib/mirror.js +330 -330
- package/lib/organize.js +450 -450
- package/lib/peak-hours.js +117 -117
- package/lib/placement.js +13 -0
- package/lib/pre-inject-gate.js +340 -0
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +175 -175
- package/lib/reranker.js +269 -269
- package/lib/runtime/adopt.js +176 -176
- package/lib/runtime/closure.js +172 -172
- package/lib/runtime/download.js +247 -247
- package/lib/runtime/layout.js +265 -265
- package/lib/runtime/loader.js +252 -252
- package/lib/runtime/model-fetch.js +361 -361
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +223 -204
- package/lib/scope.js +146 -146
- package/lib/search/adaptive.js +60 -60
- package/lib/search/bm25.js +96 -96
- package/lib/semantic.js +229 -229
- package/lib/sensitive-scan.js +209 -209
- package/lib/serve.js +215 -215
- package/lib/service.js +2663 -2605
- package/lib/settings.js +517 -511
- package/lib/store.js +3007 -3007
- package/lib/summarize.js +891 -856
- package/lib/tools.js +743 -737
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/lib/write-admission.js +402 -402
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +118 -118
- package/runtime-manifest.json +526 -526
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +249 -249
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +224 -224
- package/scripts/changelog-prep.mjs +36 -36
- package/scripts/check-sync.js +42 -42
- package/scripts/e2e-dsh.js +220 -220
- package/scripts/mneme-runtime.mjs +277 -277
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +47 -47
- package/scripts/sync-test-badge.mjs +69 -69
- package/scripts/test-count-sync.mjs +44 -44
- package/src/api-standalone.js +673 -673
- package/src/api.js +1437 -1433
- package/src/bootstrap.js +211 -211
- package/src/commands.js +95 -95
- package/src/config.js +880 -843
- package/src/content-hash.js +54 -54
- package/src/continuity.js +220 -220
- package/src/document-index.js +94 -94
- package/src/document.js +303 -303
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +674 -674
- package/src/dream/narratives.js +44 -44
- package/src/dream/sleep.js +819 -819
- package/src/dream.js +1501 -1501
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +252 -252
- package/src/graph/anchoring.js +179 -179
- package/src/guide.js +74 -74
- package/src/heat.js +144 -144
- package/src/hot-memory.js +46 -46
- package/src/index.js +535 -503
- package/src/inject.js +464 -450
- package/src/lang.js +248 -229
- package/src/local-embedder.js +358 -358
- package/src/maintenance.js +142 -142
- package/src/mirror.js +330 -330
- package/src/organize.js +450 -450
- package/src/peak-hours.js +117 -117
- package/src/placement.js +13 -0
- package/src/pre-inject-gate.js +340 -0
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +175 -175
- package/src/reranker.js +269 -269
- package/src/runtime/adopt.js +176 -176
- package/src/runtime/closure.js +172 -172
- package/src/runtime/download.js +247 -247
- package/src/runtime/layout.js +265 -265
- package/src/runtime/loader.js +252 -252
- package/src/runtime/model-fetch.js +361 -361
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +223 -204
- package/src/scope.js +146 -146
- package/src/search/adaptive.js +60 -60
- package/src/search/bm25.js +96 -96
- package/src/semantic.js +229 -229
- package/src/sensitive-scan.js +209 -209
- package/src/serve.js +215 -215
- package/src/service.js +2663 -2605
- package/src/settings.js +517 -511
- package/src/store.js +3007 -3007
- package/src/summarize.js +891 -856
- package/src/tools.js +743 -737
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/src/write-admission.js +402 -402
- package/test/adaptive-inject-budget.test.js +85 -85
- package/test/api-routes-gaps.test.js +218 -218
- package/test/api.test.js +1702 -1690
- package/test/audit.test.js +448 -448
- package/test/baseline-persist.test.js +248 -248
- package/test/benchmark.test.js +94 -94
- package/test/bootstrap.test.js +220 -220
- package/test/changelog-prep.test.js +60 -60
- package/test/client.test.js +1230 -1169
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +106 -106
- package/test/config.test.js +66 -66
- package/test/conflict-freeze.test.js +300 -300
- package/test/content-hash.test.js +204 -204
- package/test/continuity.test.js +333 -333
- package/test/document-dir.test.js +233 -233
- package/test/document.test.js +671 -671
- package/test/dream-archive-guardrail.test.js +116 -116
- package/test/dream-candidate-hybrid.test.js +281 -281
- package/test/dream-coverage-degrade.test.js +86 -86
- package/test/dream-failure-backoff.test.js +173 -173
- package/test/dream-peak-hours.test.js +194 -194
- package/test/dream.test.js +1385 -1383
- package/test/embedding-ready.test.js +196 -196
- package/test/entities.test.js +627 -547
- package/test/entity-recall.test.js +179 -179
- package/test/epistemic.test.js +325 -325
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-anchoring.test.js +184 -184
- package/test/graph-api.test.js +175 -175
- package/test/graph-inject-budget.test.js +103 -103
- package/test/graph-passive-confirm.test.js +91 -91
- package/test/graph-weight-evolution.test.js +104 -104
- package/test/heat.test.js +175 -175
- package/test/helpers/dream-mock.js +82 -82
- package/test/helpers/peer-worker.mjs +25 -25
- package/test/helpers/runtime-source.js +60 -60
- package/test/helpers/tar-builder.js +80 -80
- package/test/helpers/write-admission-samples.js +308 -308
- package/test/hot-memory.test.js +145 -145
- package/test/inject-content-cap.test.js +91 -91
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +77 -77
- package/test/inject-parent-gate.test.js +106 -106
- package/test/inject-pools.test.js +194 -194
- package/test/inject-recall.test.js +105 -105
- package/test/inject-rotation.test.js +161 -161
- package/test/inject.test.js +420 -420
- package/test/lang.test.js +87 -87
- package/test/lastrunat-persist.test.js +101 -101
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +598 -598
- package/test/llm-message-source.test.js +207 -207
- package/test/local-embedder.test.js +292 -292
- package/test/maintenance.test.js +391 -391
- package/test/mcp-stdio.test.js +385 -385
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror-types.test.js +208 -208
- package/test/mirror.test.js +249 -249
- package/test/narrative-bars.test.js +162 -162
- package/test/organize.test.js +418 -418
- package/test/peer-blockers.test.js +193 -193
- package/test/placement.test.js +36 -0
- package/test/policy-epoch.test.js +259 -259
- package/test/pre-inject-gate.test.js +434 -0
- package/test/quality-filter.test.js +186 -186
- package/test/reasoning-effort.test.js +752 -752
- package/test/recall-anti-update.test.js +82 -82
- package/test/recall-evals.test.js +253 -253
- package/test/recall-layer.test.js +326 -326
- package/test/recall-stats.test.js +189 -189
- package/test/receipt-chain.test.js +451 -451
- package/test/reflection.test.js +226 -226
- package/test/reindex-backfill.test.js +62 -62
- package/test/reranker-single-logit.test.js +73 -73
- package/test/reranker.test.js +197 -197
- package/test/resident-status.test.js +83 -83
- package/test/runtime-adopt.test.js +206 -206
- package/test/runtime-cli.test.js +89 -89
- package/test/runtime-closure.test.js +193 -193
- package/test/runtime-download.test.js +302 -302
- package/test/runtime-layout.test.js +163 -163
- package/test/runtime-loader.test.js +255 -255
- package/test/runtime-manifest.test.js +159 -159
- package/test/runtime-model-fetch.test.js +348 -348
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +319 -279
- package/test/scope-candidates.test.js +239 -239
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-inject-soft.test.js +106 -106
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +367 -367
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/sensitive-scan.test.js +207 -207
- package/test/serve-bin.test.js +121 -121
- package/test/serve.test.js +196 -196
- package/test/service-search.test.js +174 -174
- package/test/service.test.js +405 -405
- package/test/settings.test.js +261 -261
- package/test/skip-invalid-restore.test.js +262 -262
- package/test/sleep-action-set.test.js +422 -422
- package/test/sleep-heat.test.js +126 -126
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +527 -527
- package/test/standalone-api.test.js +751 -751
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +396 -396
- package/test/stress.test.js +209 -209
- package/test/summarize-occurred-at.test.js +127 -0
- package/test/summarize-reasoning-effort.test.js +189 -189
- package/test/summarize.test.js +1465 -1465
- package/test/supersede-state-tracking.test.js +394 -0
- package/test/test-count-sync.test.js +60 -60
- package/test/tools-dto-schema-matrix.test.js +325 -325
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +428 -374
- package/test/updated-at-semantics.test.js +113 -113
- package/test/vector-index.test.js +221 -221
- package/test/version-check.test.js +152 -152
- package/test/webServer-optional.test.js +82 -82
- package/test/write-admission.test.js +668 -668
package/test/client.test.js
CHANGED
|
@@ -1,1169 +1,1230 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
6
|
-
import { spawnSync } from "node:child_process";
|
|
7
|
-
|
|
8
|
-
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
-
const clientSource = readFileSync(join(root, "lib/client.js"), "utf8");
|
|
10
|
-
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
11
|
-
|
|
12
|
-
// The Web client bundle registers itself via __ModuleLoader__.load. DSH
|
|
13
|
-
// resolves the bundle by plugin package name, so the registered id must match
|
|
14
|
-
// package.json `name` exactly (a mismatch surfaces as "loaded without
|
|
15
|
-
// registering '@modusensus/dsh-mneme'" in the DSH client).
|
|
16
|
-
test("client bundle registers under the package name", () => {
|
|
17
|
-
const match = clientSource.match(/__ModuleLoader__\.load\(\{\s*id:\s*"([^"]+)"/);
|
|
18
|
-
assert.ok(match, "client bundle must call __ModuleLoader__.load with an id");
|
|
19
|
-
assert.equal(match[1], pkg.name, "registered id must equal package.json name");
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
// client.js is hand-authored under lib/ only (no src/ counterpart), so the
|
|
23
|
-
// src->lib sync must never prune it.
|
|
24
|
-
test("client bundle is lib-only with no src counterpart", () => {
|
|
25
|
-
assert.equal(existsSync(join(root, "src/client.js")), false, "src/ must not contain client.js");
|
|
26
|
-
assert.equal(existsSync(join(root, "lib/client.js")), true, "lib/client.js must exist");
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
// 归档侧第五指标(#275)自己按「归档区为空就整段省略」门控,但整卡还有一道 hasData 门:
|
|
30
|
-
// 那道门只认召回回执与活跃僵尸行时,「全归档 + 窗口内无回执」的库会直接 return null,
|
|
31
|
-
// 指标永远不显示(自动评审 #312 指出的回归)。这里锁死 hasData 必须把 archive.total 计进来。
|
|
32
|
-
test("status card: hasData counts the archive metric", () => {
|
|
33
|
-
const m = clientSource.match(/const hasData =[^;]+;/);
|
|
34
|
-
assert.ok(m, "the status card must declare hasData");
|
|
35
|
-
assert.match(m[0], /d\.archive\?\.total/, "hasData must count archive.total, not just runs/active rows");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
// The memory entry lives at the sidebar foot, not in the settings modal: the
|
|
39
|
-
// migration must register into `sidebar.footer.action` (the list slot the
|
|
40
|
-
// sidebar shell renders beside Settings) and must not keep a `settings.section`
|
|
41
|
-
// registration, or the entry would appear twice under different hosts.
|
|
42
|
-
test("memory entry registers into the sidebar foot slot", () => {
|
|
43
|
-
assert.ok(
|
|
44
|
-
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
45
|
-
"client must inject into sidebar.footer.action"
|
|
46
|
-
);
|
|
47
|
-
assert.equal(
|
|
48
|
-
clientSource.includes('"settings.section"'),
|
|
49
|
-
false,
|
|
50
|
-
"the old settings.section registration must be gone"
|
|
51
|
-
);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// The tab era is over: the in-conversation memory tab fought the floating
|
|
55
|
-
// composer and squeezed the session layout, so the library no longer
|
|
56
|
-
// registers as a conversation view. The sidebar entry opens the centered
|
|
57
|
-
// sheet directly — from any state, including the new-chat hero screen. A
|
|
58
|
-
// dialog role stays banned either way.
|
|
59
|
-
test("sidebar entry opens the sheet; conversation-view tab stays retired", () => {
|
|
60
|
-
assert.equal(
|
|
61
|
-
clientSource.includes("role: \"dialog\""),
|
|
62
|
-
false,
|
|
63
|
-
"no dialog surface should remain"
|
|
64
|
-
);
|
|
65
|
-
assert.ok(
|
|
66
|
-
/function openLibrary\(\) \{\s*setOverlayOpen\(true\);/.test(clientSource),
|
|
67
|
-
"the entry must open the overlay directly (no tab activation)"
|
|
68
|
-
);
|
|
69
|
-
assert.ok(
|
|
70
|
-
clientSource.includes("conflictBadgeCount.refresh();"),
|
|
71
|
-
"opening the library must re-read the conflict count (badge must not show a stale number)"
|
|
72
|
-
);
|
|
73
|
-
assert.equal(
|
|
74
|
-
clientSource.includes('ctx.slots.inject("conversation.view"'),
|
|
75
|
-
false,
|
|
76
|
-
"the conversation.view tab must stay retired"
|
|
77
|
-
);
|
|
78
|
-
assert.equal(
|
|
79
|
-
clientSource.includes("activateExplorerTab"),
|
|
80
|
-
false,
|
|
81
|
-
"the tab-click activation machinery must be gone"
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
// The sheet is deliberately NOT fullscreen: a dimmed backdrop plus a rounded
|
|
86
|
-
// panel capped at 1180×880 keeps the conversation visible behind it. It
|
|
87
|
-
// reuses the full MemoryExplorer and closes on Esc, the close button, or a
|
|
88
|
-
// backdrop click.
|
|
89
|
-
test("memory library opens as a centered sheet with backdrop", () => {
|
|
90
|
-
assert.ok(
|
|
91
|
-
clientSource.includes(".mneme-backdrop{position:fixed;inset:0"),
|
|
92
|
-
"a dimmed backdrop must sit behind the sheet"
|
|
93
|
-
);
|
|
94
|
-
assert.ok(
|
|
95
|
-
/\.mneme-overlay\{position:fixed;z-index:1000;left:50%;top:50%;transform:translate\(-50%,-50%\)/.test(clientSource),
|
|
96
|
-
"the sheet must be centered, not viewport-filling"
|
|
97
|
-
);
|
|
98
|
-
assert.ok(
|
|
99
|
-
clientSource.includes("width:min(1240px,calc(100vw - 88px))"),
|
|
100
|
-
"the sheet must not occupy the full width and keeps friendly margins"
|
|
101
|
-
);
|
|
102
|
-
assert.ok(
|
|
103
|
-
clientSource.includes('h(MemoryExplorer, { t })'),
|
|
104
|
-
"the sheet renders the same MemoryExplorer component"
|
|
105
|
-
);
|
|
106
|
-
assert.ok(
|
|
107
|
-
clientSource.includes('e.key === "Escape"'),
|
|
108
|
-
"Esc must close the sheet"
|
|
109
|
-
);
|
|
110
|
-
assert.ok(
|
|
111
|
-
clientSource.includes('className: "mneme-backdrop"'),
|
|
112
|
-
"the backdrop is a clickable close surface"
|
|
113
|
-
);
|
|
114
|
-
assert.ok(
|
|
115
|
-
clientSource.includes('"memory.overlay.close"'),
|
|
116
|
-
"the close affordance keeps its localized label"
|
|
117
|
-
);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// The sidebar hands each entry only its column state: a wide row (icon +
|
|
121
|
-
// label) when expanded, a bare rail icon when collapsed — for both the
|
|
122
|
-
// portalled top button and the footer fallback.
|
|
123
|
-
test("trigger renders a wide row or a rail icon from the wide flag", () => {
|
|
124
|
-
assert.ok(
|
|
125
|
-
/wide \? "mneme-trigger" : "mneme-trigger mneme-rail"/.test(clientSource),
|
|
126
|
-
"trigger must branch on the wide flag"
|
|
127
|
-
);
|
|
128
|
-
assert.ok(
|
|
129
|
-
/wide && h\("span", \{ className: "mneme-trigger-label" \}/.test(clientSource),
|
|
130
|
-
"the label span must render only when wide"
|
|
131
|
-
);
|
|
132
|
-
assert.ok(
|
|
133
|
-
/size: wide \? 16 : 18/.test(clientSource),
|
|
134
|
-
"the portalled icon must follow the host panel-row size (16) and the rail size (18)"
|
|
135
|
-
);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
// 入口的两态形状(宿主内核 0.2 侧边栏改版后重写,见 lib/client.js 的
|
|
139
|
-
// SidebarTopEntry 头注释):
|
|
140
|
-
// · 展开:混进宿主 nav.panelList 里「插件 / 自动化任务」那一组,套用宿主
|
|
141
|
-
// panelRow 的实时类名 → 一颗低调面板行,不再是第二个高亮「新会话」按钮;
|
|
142
|
-
// · 收起:宿主把面板列表整组 display:none,只有 fixed 到标题栏的开关与
|
|
143
|
-
// 「新会话」还可见,所以入口必须搬回那个位置(见下一条用例)。
|
|
144
|
-
// 本用例锁「展开态借的是宿主面板行」,回归表现是入口退回成大按钮或落回
|
|
145
|
-
// 新会话行后面变成孤立项。
|
|
146
|
-
test("sidebar entry portals into the host panel list with footer fallback", () => {
|
|
147
|
-
assert.ok(
|
|
148
|
-
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
149
|
-
"the footer slot registration must stay as anchor + fallback"
|
|
150
|
-
);
|
|
151
|
-
assert.ok(
|
|
152
|
-
clientSource.includes("SidebarTopEntry"),
|
|
153
|
-
"the portalled top entry must exist"
|
|
154
|
-
);
|
|
155
|
-
assert.ok(
|
|
156
|
-
clientSource.includes(`'[class*="regionArea"]'`),
|
|
157
|
-
"the portal must anchor at the host's regionArea container"
|
|
158
|
-
);
|
|
159
|
-
assert.ok(
|
|
160
|
-
clientSource.includes(`'[class*="panelList"]'`),
|
|
161
|
-
"the wide shape must target the host panel list (the 插件 / 自动化任务 group)"
|
|
162
|
-
);
|
|
163
|
-
assert.ok(
|
|
164
|
-
clientSource.includes(`'[class*="panelRow"]:not(.mneme-topentry-native)'`),
|
|
165
|
-
"the panel-row class must come from a host row, never from our own portalled button"
|
|
166
|
-
);
|
|
167
|
-
assert.ok(
|
|
168
|
-
clientSource.includes("list.appendChild(created)"),
|
|
169
|
-
"the wide entry must be appended into that group, below the host's own entries"
|
|
170
|
-
);
|
|
171
|
-
assert.ok(
|
|
172
|
-
clientSource.includes("insertBefore(created, target)"),
|
|
173
|
-
"the entry must keep the idempotent place() helper for hosts without a panel list"
|
|
174
|
-
);
|
|
175
|
-
assert.ok(
|
|
176
|
-
clientSource.includes("if (!host) return fallback;"),
|
|
177
|
-
"the portal entry persists across collapse (no footer jump); footer fallback only covers portal failure"
|
|
178
|
-
);
|
|
179
|
-
assert.ok(
|
|
180
|
-
clientSource.includes('"memory.view.label"'),
|
|
181
|
-
"the sheet aria-label must come from the memory.view.label dictionary key"
|
|
182
|
-
);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
// Issue #130: ecosystem plugins (task board, skill explorer, …) inject their
|
|
186
|
-
// own entries right after the New-Session row, and late inserters land above
|
|
187
|
-
// earlier ones — a fixed "before regionArea" slot degrades into the tail of
|
|
188
|
-
// that group, detaching the memory button from its familiar top spot. The
|
|
189
|
-
// entry therefore (a) resolves the same anchor as the ecosystem's shared
|
|
190
|
-
// sidebar-entry-core (logoRow row, legacy direct-child button), (b) re-asserts
|
|
191
|
-
// its slot from the existing observer, and (c) stays idempotent — the anchor
|
|
192
|
-
// check short-circuits before any DOM write, so observers never ping-pong.
|
|
193
|
-
test("entry re-asserts its slot against ecosystem entries", () => {
|
|
194
|
-
assert.ok(
|
|
195
|
-
clientSource.includes(`btn.closest('[class*="logoRow"]')`),
|
|
196
|
-
"the anchor must resolve the New-Session logo row like the ecosystem core"
|
|
197
|
-
);
|
|
198
|
-
assert.ok(
|
|
199
|
-
clientSource.includes("const target = anchor ? anchor.nextSibling : region;"),
|
|
200
|
-
"the entry must sit right after the New-Session row, falling back before regionArea"
|
|
201
|
-
);
|
|
202
|
-
assert.ok(
|
|
203
|
-
clientSource.includes("if (created.parentElement === parent && created.nextSibling === target) return;"),
|
|
204
|
-
"placement must be idempotent so the observer never writes on steady state"
|
|
205
|
-
);
|
|
206
|
-
assert.ok(
|
|
207
|
-
/place\(cur\);\s*\n\s*sync\(cur\);/.test(clientSource),
|
|
208
|
-
"the observer must re-place the entry and re-read its classes on childList churn"
|
|
209
|
-
);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// 用户实测确认的遮挡 bug(v0.8.10):收起侧边栏时入口沿用宿主给「新会话」的
|
|
213
|
-
// left:48px,正好压在「新会话」图标上把它盖掉,左上角只剩两颗图标。
|
|
214
|
-
// 修法:不再共用那个 left,用一条更具体的规则推到 84px(「新会话」右侧 =
|
|
215
|
-
// 宿主 48px + 钮宽 28px + 间距 8px),并把宿主的面板菜单变量
|
|
216
|
-
// --dsh-windows-menu-start(宿主收起态自己设 84px)顺延到 120px。
|
|
217
|
-
// 回归表现:左上角少一颗图标,或「应用 / 编辑」与记忆图标叠在一起。
|
|
218
|
-
test("collapsed entry vacates the New-Session slot instead of covering it", () => {
|
|
219
|
-
assert.ok(
|
|
220
|
-
clientSource.includes("html[data-windows-titlebar] .mneme-topentry .mneme-topentry-rail{left:84px}"),
|
|
221
|
-
"the collapsed entry must sit right of the host's 48px New-Session slot"
|
|
222
|
-
);
|
|
223
|
-
assert.ok(
|
|
224
|
-
// 源码里这段 CSS 含转义引号(注入 <style> 后才还原成普通引号),断言按源码字面量写。
|
|
225
|
-
clientSource.includes(':has([data-plugin-entry=\\"@modusensus/dsh-mneme\\"]){--dsh-windows-menu-start:120px}'),
|
|
226
|
-
"the caption menu must shift right so the memory icon never overlaps 应用 / 编辑"
|
|
227
|
-
);
|
|
228
|
-
assert.ok(
|
|
229
|
-
clientSource.includes(":has([data-sidebar-collapsed=true]):has("),
|
|
230
|
-
"the menu shift must apply only while the sidebar is collapsed"
|
|
231
|
-
);
|
|
232
|
-
assert.ok(
|
|
233
|
-
clientSource.includes("`${cls.row} mneme-topentry-rail`.trim()"),
|
|
234
|
-
"the collapsed button carries the host New-Session class plus our rail offset rule"
|
|
235
|
-
);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
// 宿主面板行的类名带着激活态修饰类(.xxx_panelActive),而我们的入口不是
|
|
239
|
-
// panel 插槽的成员、永远不该常亮;照抄整串类名会让记忆入口跟着宿主当前选中
|
|
240
|
-
// 的面板一起高亮。这里锁「读类名时必须剔掉激活态」。
|
|
241
|
-
test("panel-row highlight never leaks onto the memory entry", () => {
|
|
242
|
-
assert.ok(
|
|
243
|
-
clientSource.includes('!name.includes("panelActive")'),
|
|
244
|
-
"panelActive must be filtered out of the copied host row classes"
|
|
245
|
-
);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
// Three alignment/softness guarantees born from field feedback: (a) the
|
|
249
|
-
// portalled entry re-reads the live host classes on every mutation — the host
|
|
250
|
-
// and skins rewrite hashed class names asynchronously, so a mount-time snapshot
|
|
251
|
-
// goes stale (收起态尤其依赖这条:搬迁后类名必须重新对齐宿主「新会话」按钮),
|
|
252
|
-
// (b) it never hardcodes a width, so whichever host class it carries decides the
|
|
253
|
-
// box model, (c) the toolbar dropdown escapes the transform stacking trap — the
|
|
254
|
-
// container needs the z-index because transform creates the context.
|
|
255
|
-
test("entry re-reads the live host classes and lifts the toolbar dropdown", () => {
|
|
256
|
-
assert.ok(
|
|
257
|
-
clientSource.includes("new MutationObserver"),
|
|
258
|
-
"the entry must re-sync the copied class via MutationObserver"
|
|
259
|
-
);
|
|
260
|
-
assert.ok(
|
|
261
|
-
clientSource.includes('attributeFilter: ["class"]'),
|
|
262
|
-
"the observer must watch class attribute changes"
|
|
263
|
-
);
|
|
264
|
-
assert.doesNotMatch(
|
|
265
|
-
clientSource,
|
|
266
|
-
/\.mneme-topentry-native\{(?:[^}]*;)?width:/,
|
|
267
|
-
"the entry must use the native button width, which reserves its horizontal margins"
|
|
268
|
-
);
|
|
269
|
-
assert.ok(
|
|
270
|
-
clientSource.includes(".mneme-topentry{display:flex;flex-direction:column;position:relative}"),
|
|
271
|
-
"the entry wrapper must stretch the native button within a column flex layout (position:relative hosts the #177 badge)"
|
|
272
|
-
);
|
|
273
|
-
assert.equal(
|
|
274
|
-
clientSource.includes(".mneme-topentry-native .mneme-topentry-label{flex:1;min-width:0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"),
|
|
275
|
-
false,
|
|
276
|
-
"the old left-aligned label override must go; native centering applies"
|
|
277
|
-
);
|
|
278
|
-
assert.ok(
|
|
279
|
-
/\.mneme-xtools\{[^}]*z-index:3\}/.test(clientSource),
|
|
280
|
-
"the toolbar container must carry z-index:3 (sticky month header is 2, drawer 6)"
|
|
281
|
-
);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
// Importance renders as Lucide star glyphs (the morphicons-paired data set;
|
|
285
|
-
// the runtime cannot require the ESM-only morphicons engine, so the path
|
|
286
|
-
// ships inline like the other stroke icons). Raw text stars are fully
|
|
287
|
-
// retired: the drawer's edit-mode <option> select used to keep "★".repeat
|
|
288
|
-
// labels (SVG cannot render inside <option>) — it is now the SVG star
|
|
289
|
-
// button row, and the inline indicators (importance chips / entity meta /
|
|
290
|
-
// inject preview) share the same StarGlyph, so every star in the panel has
|
|
291
|
-
// one shape. (用户反馈:文本 ★ 过尖、与卡片 SVG 星不统一。)
|
|
292
|
-
test("importance renders as star glyphs, not raw text stars", () => {
|
|
293
|
-
assert.ok(
|
|
294
|
-
clientSource.includes("STAR_PATH_D"),
|
|
295
|
-
"the Lucide star path data must be inlined"
|
|
296
|
-
);
|
|
297
|
-
assert.ok(
|
|
298
|
-
clientSource.includes("const ImportanceStars"),
|
|
299
|
-
"the star-row component must exist"
|
|
300
|
-
);
|
|
301
|
-
assert.equal(
|
|
302
|
-
(clientSource.match(/★/g) || []).length,
|
|
303
|
-
0,
|
|
304
|
-
"no raw text star may remain anywhere — every star renders through the shared SVG path"
|
|
305
|
-
);
|
|
306
|
-
assert.ok(
|
|
307
|
-
/h\(ImportanceStars, \{ className: "mneme-dmetaval"/.test(clientSource),
|
|
308
|
-
"the drawer detail must render the star row"
|
|
309
|
-
);
|
|
310
|
-
assert.ok(
|
|
311
|
-
/h\(ImportanceStars, \{ value: m\.importance/.test(clientSource),
|
|
312
|
-
"the card foot must render the star row"
|
|
313
|
-
);
|
|
314
|
-
assert.ok(
|
|
315
|
-
/className: "mneme-staredit"/.test(clientSource),
|
|
316
|
-
"the drawer edit importance must be the SVG star button row, not a text-star <select>"
|
|
317
|
-
);
|
|
318
|
-
assert.ok(
|
|
319
|
-
/h\(StarGlyph, \{ size: 11 \}\)/.test(clientSource),
|
|
320
|
-
"inline star indicators must go through the shared StarGlyph"
|
|
321
|
-
);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
// better-sidebar ecosystem integration is an optional capability (official
|
|
325
|
-
// external-plugin-guide §2.2): 'betterSidebar' IS declared in inject (DSH's
|
|
326
|
-
// runtime gates ctx property access on the inject declaration — probing
|
|
327
|
-
// without declaring fails the whole loader entry, verified in the field) and
|
|
328
|
-
// better-sidebar 软集成(issue #88 修正):模块级 inject 声明 betterSidebar
|
|
329
|
-
// 是硬等待——未安装 bs 的环境整个 entry pending("1 entry did not activate",
|
|
330
|
-
// Failed to load plugins)。正确模式(dsh-server-deck 同款):外层入口零
|
|
331
|
-
// inject 立即激活(独立模式保底),tab 注册挂在内层动态子插件
|
|
332
|
-
// ctx.plugin({ inject: ['betterSidebar'] }) 由 cordis 原生等待服务——bs 未装
|
|
333
|
-
// 时该内层 fiber 永远 INACTIVE,静默无害。
|
|
334
|
-
test("better-sidebar tab mounts via an inner sub-plugin, standalone mode intact", () => {
|
|
335
|
-
assert.ok(
|
|
336
|
-
/const reg = bsCtx\.betterSidebar;[\s\S]{0,60}typeof reg\.registerTab !== "function"/.test(clientSource),
|
|
337
|
-
"the inner apply must still guard the service shape before registering"
|
|
338
|
-
);
|
|
339
|
-
assert.ok(
|
|
340
|
-
/(?:const TAB_ID = |id: )"dsh-mneme:memory"/.test(clientSource),
|
|
341
|
-
"the registered tab id must be package-prefixed"
|
|
342
|
-
);
|
|
343
|
-
assert.ok(
|
|
344
|
-
/title: \(\) => t\("memory\.view\.label"\)/.test(clientSource),
|
|
345
|
-
"the tab title must reuse the localized 记忆库 label"
|
|
346
|
-
);
|
|
347
|
-
assert.ok(
|
|
348
|
-
/component: \(\) => h\(MemoryExplorer, \{ t \}\)/.test(clientSource),
|
|
349
|
-
"the tab must reuse the MemoryExplorer views"
|
|
350
|
-
);
|
|
351
|
-
assert.ok(
|
|
352
|
-
clientSource.includes('"dsh-mneme: better-sidebar tab"'),
|
|
353
|
-
"the registration effect must carry a named label for scope cleanup"
|
|
354
|
-
);
|
|
355
|
-
// issue #88:模块级 inject 声明 betterSidebar 是硬等待——未安装 bs 的环境
|
|
356
|
-
// 整个 entry pending("1 entry did not activate")。tab 注册必须挂在内层
|
|
357
|
-
// 动态子插件(dsh-server-deck 同款模式),外层入口零 inject 立即激活。
|
|
358
|
-
assert.ok(
|
|
359
|
-
/const inject = \["slots", "locale"\]/.test(clientSource),
|
|
360
|
-
"the module inject must not declare betterSidebar (hard-wait regression)"
|
|
361
|
-
);
|
|
362
|
-
assert.ok(
|
|
363
|
-
/ctx\.plugin\?\.\(\{[\s\S]*?inject: \["betterSidebar"\][\s\S]*?apply: \(bsCtx\) =>/.test(clientSource),
|
|
364
|
-
"the tab registration must live in an inner dynamic sub-plugin waiting on cordis"
|
|
365
|
-
);
|
|
366
|
-
assert.ok(
|
|
367
|
-
/if \(\+\+tries <= 10\) timer = setTimeout\(attempt, 1000\);/.test(clientSource) === false,
|
|
368
|
-
"the old 10×1s probe must go — cordis waits for the inner inject natively"
|
|
369
|
-
);
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// The graph toggle must not read as "share": the primitives share icon is
|
|
373
|
-
// banned and a custom node-graph glyph takes its place.
|
|
374
|
-
test("graph toggle uses a node-graph glyph, not the share icon", () => {
|
|
375
|
-
assert.equal(
|
|
376
|
-
clientSource.includes("IconShareOutline16"),
|
|
377
|
-
false,
|
|
378
|
-
"IconShareOutline16 reads as share and must not appear"
|
|
379
|
-
);
|
|
380
|
-
assert.ok(
|
|
381
|
-
clientSource.includes("GraphNodesIcon"),
|
|
382
|
-
"the custom node-graph icon must back the graph toggle"
|
|
383
|
-
);
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
// #287 的成因已经不存在了,但那条教训要留着:当年的崩法是「宿主图标名换代 →
|
|
387
|
-
// 取到 undefined → h(undefined) 即 React #130,整个 slot entry 崩掉」。现在的解法
|
|
388
|
-
// 不是继续探测多代名字,而是**不再依赖宿主图标库**——插件自己画 mneme mark。
|
|
389
|
-
// 这条用例锁三件事:(a) 客户端里不再有对宿主 IconArchive* 名字的探测(回到探测
|
|
390
|
-
// 就等于把 #287 那类耦合请回来);(b) mark 走一个内联 SVG 组件、自带路径常量;
|
|
391
|
-
// (c) 四个入口共用同一个 helper,避免某处漏改又出现"归档盒"。
|
|
392
|
-
test("mneme mark is self-drawn: no host icon-name probing left (#287 root cause)", () => {
|
|
393
|
-
assert.equal(
|
|
394
|
-
/primitives\.IconArchiveOutline(20|Regular|Medium)/.test(clientSource),
|
|
395
|
-
false,
|
|
396
|
-
"the client must not probe host archive glyph names any more"
|
|
397
|
-
);
|
|
398
|
-
assert.ok(
|
|
399
|
-
/const MNEME_MARK_D = "M[^"]+"/.test(clientSource),
|
|
400
|
-
"the mark must carry its own path constant"
|
|
401
|
-
);
|
|
402
|
-
assert.ok(
|
|
403
|
-
clientSource.includes('viewBox: "0 0 16 16"') && clientSource.includes('stroke: "currentColor"'),
|
|
404
|
-
"the mark must follow the host icon spec (16 grid, currentColor) so it reads as first-party"
|
|
405
|
-
);
|
|
406
|
-
assert.ok(
|
|
407
|
-
/const renderMnemeMark = \(props\) => h\(MnemeMark, props\)/.test(clientSource),
|
|
408
|
-
"the mark must render through one shared helper"
|
|
409
|
-
);
|
|
410
|
-
for (const site of [
|
|
411
|
-
"renderMnemeMark({ size: 15 })", // 浮层标题栏
|
|
412
|
-
"renderMnemeMark({ size: wide ? 16 : 18 })", // 侧栏 trigger + portal 入口(面板行档 16 / 标题栏档 18)
|
|
413
|
-
"renderMnemeMark({ size })" // better-sidebar tab 图标
|
|
414
|
-
]) {
|
|
415
|
-
assert.ok(clientSource.includes(site), `every render site must go through the helper: ${site}`);
|
|
416
|
-
}
|
|
417
|
-
assert.equal(
|
|
418
|
-
/renderArchiveIcon/.test(clientSource),
|
|
419
|
-
false,
|
|
420
|
-
"the retired archive-glyph helper must be gone, not left as a second path"
|
|
421
|
-
);
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
// 插件列表那颗图标由宿主从 package.json 的 `icon` 读:必须是**包内相对路径**、
|
|
425
|
-
// 落在 files 白名单里、且宿主只接受 SVG/PNG/JPEG/WebP ≤256KiB。
|
|
426
|
-
// 宿主 `iconOf()` 的硬规则:绝对路径或带 scheme 直接报错、realpath 后不许跑出
|
|
427
|
-
// manifest 目录、超 256KiB 报错;缺文件/坏文件时静默回落成通用插画(等于用户
|
|
428
|
-
// 看到的不是自家图标)。这里把可静态检的部分全锁住。
|
|
429
|
-
test("plugin-list icon: declared, in-package, and shipped in files", () => {
|
|
430
|
-
const icon = pkg.icon;
|
|
431
|
-
assert.equal(typeof icon, "string", "package.json must declare an icon");
|
|
432
|
-
assert.equal(icon, "assets/icon.svg", "the icon must be the self-drawn mark");
|
|
433
|
-
assert.ok(!/^([A-Za-z]:|[A-Za-z][A-Za-z\d+.-]*:)/.test(icon), "the icon must be a relative path (host rejects absolute/URL)");
|
|
434
|
-
assert.ok(pkg.files.includes("assets"), "the assets dir must ship, or the published package has no icon");
|
|
435
|
-
const file = join(root, icon);
|
|
436
|
-
assert.ok(existsSync(file), `the declared icon must exist: ${icon}`);
|
|
437
|
-
const bytes = readFileSync(file);
|
|
438
|
-
assert.ok(bytes.length > 0 && bytes.length <= 256 * 1024, "the icon must be a non-empty file under the host's 256 KiB cap");
|
|
439
|
-
const svg = bytes.toString("utf8");
|
|
440
|
-
assert.ok(svg.includes("<svg") && svg.includes("viewBox"), "the icon must be an SVG with a viewBox");
|
|
441
|
-
// <img> 不能继承 currentColor:文件必须自带颜色,否则会渲染成黑块/不可见。
|
|
442
|
-
assert.ok(/stroke="#[0-9a-fA-F]{6}"/.test(svg), "the standalone file must bake its own colour (<img> cannot inherit currentColor)");
|
|
443
|
-
// 文件与客户端内联路径必须是同一颗:两边形状漂移是最容易悄悄发生的事。
|
|
444
|
-
const inline = clientSource.match(/const MNEME_MARK_D = "([^"]+)"/)[1];
|
|
445
|
-
assert.ok(svg.includes(inline), "assets/icon.svg must use the same path as the inline mark (no shape drift)");
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
// 插件列表的标题/描述读包内的 `locale/<语言码>.json`:宿主 readPluginMeta 先解析
|
|
449
|
-
// `<specifier>/locale/en.json`,再遍历同目录的每个 *.json(文件名必须过语言码正则),
|
|
450
|
-
// 取 meta.title / meta.description;一个都读不到就回落到 package.json 的 name——
|
|
451
|
-
// 也就是裸包名 "@modusensus/dsh-mneme"(用户看到的现状)。
|
|
452
|
-
// ⚠️ 真正的门槛是**能否被 Node 解析**:宿主用 ESM resolver 取这个文件,而 exports
|
|
453
|
-
// 字段会把没列出的子路径**全部挡掉**,且 optionalResourcePath 对"读不到"是静默的
|
|
454
|
-
// (返回 undefined 继续走回落),不报错。所以少了 "./locale/*" 这一行,文件乖乖躺在
|
|
455
|
-
// 包里也永远不生效——这条回归极难靠肉眼发现,必须锁死。
|
|
456
|
-
test("plugin-list title/description are localized through locale/*.json", () => {
|
|
457
|
-
assert.ok(
|
|
458
|
-
Object.keys(pkg.exports).includes("./locale/*"),
|
|
459
|
-
"exports must expose ./locale/* or the host resolver silently never reads the dictionaries"
|
|
460
|
-
);
|
|
461
|
-
assert.ok(pkg.files.includes("locale"), "the locale dir must ship in files, or the published package loses it");
|
|
462
|
-
const dir = join(root, "locale");
|
|
463
|
-
assert.ok(existsSync(dir), "locale/ must exist");
|
|
464
|
-
const files = readdirSync(dir).filter((name) => name.endsWith(".json"));
|
|
465
|
-
assert.ok(files.length >= 2, "at least an English and a Chinese dictionary must ship");
|
|
466
|
-
for (const name of files) {
|
|
467
|
-
const language = name.slice(0, -5);
|
|
468
|
-
assert.match(language, /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/, `locale/${name} must be named after a language id`);
|
|
469
|
-
const parsed = JSON.parse(readFileSync(join(dir, name), "utf8"));
|
|
470
|
-
assert.equal(typeof parsed.meta, "object", `locale/${name} must carry a meta block`);
|
|
471
|
-
assert.ok(typeof parsed.meta.title === "string" && parsed.meta.title.trim() !== "", `locale/${name}: meta.title must be a non-empty string`);
|
|
472
|
-
assert.ok(
|
|
473
|
-
typeof parsed.meta.description === "string" && parsed.meta.description.trim() !== "",
|
|
474
|
-
`locale/${name}: meta.description must be a non-empty string`
|
|
475
|
-
);
|
|
476
|
-
}
|
|
477
|
-
const en = JSON.parse(readFileSync(join(dir, "en.json"), "utf8"));
|
|
478
|
-
assert.ok(en.meta.title !== pkg.name, "the English title must not be the bare package name (that is the bug this fixes)");
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
// 方案 A:查询收敛。状态页只做仪表盘(小页预览 + 服务端 total + 查看全部),
|
|
482
|
-
// 沉淀/归档的完整浏览走记忆库的 deposited/archived 筛选视图(chip 预置 +
|
|
483
|
-
// 状态页入口跳转),详情抽屉给归档记忆一个反向的「恢复」。
|
|
484
|
-
test("status dashboard links into deposited/archived library views", () => {
|
|
485
|
-
assert.ok(
|
|
486
|
-
clientSource.includes('"/api/dsh-mneme/list?deposited=only&limit=8&order=chrono"'),
|
|
487
|
-
"the workbench deposited preview must read the server-side deposited view"
|
|
488
|
-
);
|
|
489
|
-
assert.ok(
|
|
490
|
-
clientSource.includes('"/api/dsh-mneme/list?archived=only&limit=3&order=chrono"'),
|
|
491
|
-
"the workbench archived preview must cap at 3 rows backed by a server total"
|
|
492
|
-
);
|
|
493
|
-
assert.ok(
|
|
494
|
-
clientSource.includes("browseWithFilter"),
|
|
495
|
-
"the status page must jump into the library with preset filters"
|
|
496
|
-
);
|
|
497
|
-
assert.ok(
|
|
498
|
-
/view === "status" && h\(StatusPanel, \{ t, onBrowse: browseWithFilter \}\)/.test(clientSource),
|
|
499
|
-
"the status panel must receive the browse-jump callback"
|
|
500
|
-
);
|
|
501
|
-
assert.ok(
|
|
502
|
-
clientSource.includes('(depositedOnly ? "&deposited=only" : "")'),
|
|
503
|
-
"the library filterQS must carry the deposited chip"
|
|
504
|
-
);
|
|
505
|
-
assert.ok(
|
|
506
|
-
clientSource.includes('(archivedOnly ? "&archived=only" : "")'),
|
|
507
|
-
"the library filterQS must carry the archived chip"
|
|
508
|
-
);
|
|
509
|
-
assert.ok(
|
|
510
|
-
clientSource.includes('postUpdate({ archived: false }, { restored: true })'),
|
|
511
|
-
"the drawer must offer restore for archived memories"
|
|
512
|
-
);
|
|
513
|
-
assert.ok(
|
|
514
|
-
clientSource.includes('"memory.status.viewAll"'),
|
|
515
|
-
"the view-all entries must come from the dictionary"
|
|
516
|
-
);
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
// Every memory feature lives in the main-area library now: the explorer
|
|
520
|
-
// hosts three sub-views (browse / entities / settings) switched by tabs whose
|
|
521
|
-
// labels come from dedicated dictionary keys.
|
|
522
|
-
test("explorer hosts browse, entities and settings sub-views", () => {
|
|
523
|
-
for (const key of ["tabMemory", "tabEntities", "tabSettings"]) {
|
|
524
|
-
assert.ok(
|
|
525
|
-
clientSource.includes(`"memory.explorer.${key}"`),
|
|
526
|
-
`sub-view labels must come from memory.explorer.${key}`
|
|
527
|
-
);
|
|
528
|
-
}
|
|
529
|
-
assert.ok(
|
|
530
|
-
clientSource.includes("h(EntityPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory })"),
|
|
531
|
-
"the entity panel must be embedded as a sub-view"
|
|
532
|
-
);
|
|
533
|
-
assert.ok(
|
|
534
|
-
clientSource.includes('h(SettingsContent, { t })'),
|
|
535
|
-
"the settings forms must be embedded as a sub-view"
|
|
536
|
-
);
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
// The graph panel jumps back into the browser: related-memory rows and the
|
|
540
|
-
// edge source button must land on the browse tab with the target selected.
|
|
541
|
-
test("graph jump lands on the selected memory in the browser", () => {
|
|
542
|
-
assert.ok(
|
|
543
|
-
/onClick: \(\) => onJumpMemory && onJumpMemory\(m\)/.test(clientSource),
|
|
544
|
-
"related-memory rows must jump via onJumpMemory(m)"
|
|
545
|
-
);
|
|
546
|
-
assert.ok(
|
|
547
|
-
clientSource.includes("onJumpMemory({ id: selected.edge.memory_id })"),
|
|
548
|
-
"the edge source button must jump to the origin memory by id"
|
|
549
|
-
);
|
|
550
|
-
assert.ok(
|
|
551
|
-
/const jumpToMemory = \(target\) => \{/.test(clientSource),
|
|
552
|
-
"jumpToMemory must reset filters and select the target"
|
|
553
|
-
);
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
// "entity:" in the browser search is the graph entry grammar: it must offer
|
|
557
|
-
// a jump chip instead of filtering the list.
|
|
558
|
-
test("entity: search grammar offers a graph jump", () => {
|
|
559
|
-
assert.ok(
|
|
560
|
-
clientSource.includes('query.trim().startsWith("entity:")'),
|
|
561
|
-
"the entity: prefix must be recognized"
|
|
562
|
-
);
|
|
563
|
-
assert.ok(
|
|
564
|
-
/onClick: \(\) => openGraphFor\(entityQuery\)/.test(clientSource),
|
|
565
|
-
"the jump chip must switch to the graph sub-view"
|
|
566
|
-
);
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
// The explorer is a card-grid / timeline browse with a right-hand detail
|
|
570
|
-
// drawer: types with counts, a month→day time tree (timeline mode), and the
|
|
571
|
-
// drawer rendering the untruncated content.
|
|
572
|
-
test("explorer lays out types, timeline, and full-text detail", () => {
|
|
573
|
-
assert.ok(
|
|
574
|
-
clientSource.includes('className: "mneme-xmain"'),
|
|
575
|
-
"the three-column grid must be present"
|
|
576
|
-
);
|
|
577
|
-
assert.ok(
|
|
578
|
-
clientSource.includes('className: "mneme-dcontent"'),
|
|
579
|
-
"the detail drawer must render the full content"
|
|
580
|
-
);
|
|
581
|
-
assert.ok(
|
|
582
|
-
clientSource.includes('className: "mneme-cards"'),
|
|
583
|
-
"the card-grid view must exist alongside the timeline"
|
|
584
|
-
);
|
|
585
|
-
assert.ok(
|
|
586
|
-
/toLocaleDateString\(undefined, \{ year: "numeric", month: "long" \}\)/.test(clientSource),
|
|
587
|
-
"month groups must format via the host locale, not hardcoded strings"
|
|
588
|
-
);
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
// The library page must read as a first-party view: the chrome resolves to
|
|
592
|
-
// the host's design tokens (layer-1 canvas, brand-blue active states) and
|
|
593
|
-
// the boxed-panel / pill-chip patterns of the drawer era must stay gone.
|
|
594
|
-
test("explorer chrome aligns with the host design system", () => {
|
|
595
|
-
assert.ok(
|
|
596
|
-
clientSource.includes("background:var(--dsw-alias-bg-layer-1)"),
|
|
597
|
-
"the page canvas must sit on the host bg-layer-1 token"
|
|
598
|
-
);
|
|
599
|
-
assert.ok(
|
|
600
|
-
/\.mneme-vtab\.mneme-active::after/.test(clientSource),
|
|
601
|
-
"active sub-tabs use the host underline treatment"
|
|
602
|
-
);
|
|
603
|
-
assert.ok(
|
|
604
|
-
clientSource.includes(".mneme-vtab.mneme-active{color:var(--dsw-alias-state-business-primary)}"),
|
|
605
|
-
"active sub-tab text must turn the host brand blue"
|
|
606
|
-
);
|
|
607
|
-
assert.equal(
|
|
608
|
-
/\.mneme-xmain\{[^}]*border:1px/.test(clientSource),
|
|
609
|
-
false,
|
|
610
|
-
"the three-column layout must not wrap itself in a boxed panel"
|
|
611
|
-
);
|
|
612
|
-
assert.equal(
|
|
613
|
-
clientSource.includes("border-radius:999px"),
|
|
614
|
-
false,
|
|
615
|
-
"pill chips belong to the drawer era and must stay gone"
|
|
616
|
-
);
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
// heat 阶段二:/list 仅在 heatEnabled=true 时下发逐条 heat,前端徽章三档
|
|
620
|
-
// 配色且自门控(字段缺省自动隐藏)——卡片页脚、抽屉 meta、状态分布卡共用
|
|
621
|
-
// 同一数据源,前端不感知开关状态。
|
|
622
|
-
test("heat badges render from the /list projection and self-hide when off", () => {
|
|
623
|
-
assert.ok(
|
|
624
|
-
/const HeatBadge = \(\{ value, size = 12 \}\) =>/.test(clientSource),
|
|
625
|
-
"the heat badge component must exist"
|
|
626
|
-
);
|
|
627
|
-
assert.ok(
|
|
628
|
-
clientSource.includes("flame:"),
|
|
629
|
-
"the Lucide flame glyph must back the badge"
|
|
630
|
-
);
|
|
631
|
-
assert.ok(
|
|
632
|
-
/h\(HeatBadge, \{ value: m\.heat \}\)/.test(clientSource),
|
|
633
|
-
"the card foot must render the heat badge"
|
|
634
|
-
);
|
|
635
|
-
assert.ok(
|
|
636
|
-
clientSource.includes('t("memory.explorer.heat")'),
|
|
637
|
-
"the drawer must show a localized heat meta row"
|
|
638
|
-
);
|
|
639
|
-
assert.ok(
|
|
640
|
-
clientSource.includes("function HeatStatusCard"),
|
|
641
|
-
"the status grid must include the heat distribution card"
|
|
642
|
-
);
|
|
643
|
-
assert.ok(
|
|
644
|
-
clientSource.includes('typeof items[0].heat !== "number"'),
|
|
645
|
-
"the distribution card must self-hide when /list omits heat"
|
|
646
|
-
);
|
|
647
|
-
assert.ok(
|
|
648
|
-
/\.mneme-heat--hot\{/.test(clientSource),
|
|
649
|
-
"the three-tier heat colors must be styled"
|
|
650
|
-
);
|
|
651
|
-
// order=heat 的前端补口:热度是运行时投影无存储序,SQL 排不了——页内
|
|
652
|
-
// 对已加载条目降序,时间树保持 chrono;chip 自门控(heat 缺省不出现)。
|
|
653
|
-
assert.ok(
|
|
654
|
-
/const heatAvailable = visible\.some\(\(m\) => typeof m\.heat === "number"\);/.test(clientSource),
|
|
655
|
-
"the heat-sort chip must self-gate on the /list heat field"
|
|
656
|
-
);
|
|
657
|
-
assert.ok(
|
|
658
|
-
/const gridItems = heatSort[\s\S]{0,80}\(b\.heat \?\? 0\) - \(a\.heat \?\? 0\)/.test(clientSource),
|
|
659
|
-
"the cards grid must sort loaded items by heat in-page"
|
|
660
|
-
);
|
|
661
|
-
assert.ok(
|
|
662
|
-
clientSource.includes("if (!heatSort) switchViewMode(\"cards\")"),
|
|
663
|
-
"toggling heat sort must land on the cards view (sort does not apply to the month tree)"
|
|
664
|
-
);
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
// 巩固/睡眠模型路由 UI:下拉数据来自宿主侧已注册适配器(/llm-providers,
|
|
668
|
-
// 云端插件侧端点),「测试连通性」走 POST /test-model 真实最小调用。旧后端
|
|
669
|
-
// 端点 404 时必须回退纯文本输入——前端自门控,不挡旧版本。
|
|
670
|
-
test("consolidation/sleep model routing: provider dropdowns from /llm-providers, connectivity test via /test-model, graceful fallback", () => {
|
|
671
|
-
// 1. 端点探测与降级
|
|
672
|
-
assert.ok(
|
|
673
|
-
clientSource.includes('apiFetch("/api/dsh-mneme/llm-providers")'),
|
|
674
|
-
"the features card must probe GET /llm-providers for the route dropdowns"
|
|
675
|
-
);
|
|
676
|
-
assert.ok(
|
|
677
|
-
/setRoutes\(Array\.isArray\(j && j\.providers\) \? j\.providers : \[\]\)/.test(clientSource),
|
|
678
|
-
"the probe must only accept a {providers: []} shape"
|
|
679
|
-
);
|
|
680
|
-
assert.ok(
|
|
681
|
-
/Array\.isArray\(routes\)\s*\?\s*routeSelects\("dreamProvider", "dreamModel", dreamTest, setDreamTest, "dreamReasoningEffort"\)/.test(clientSource),
|
|
682
|
-
"dream routing must upgrade to dropdowns only when the probe succeeded (and pass its own effort key)"
|
|
683
|
-
);
|
|
684
|
-
assert.ok(
|
|
685
|
-
/:\s*h\(react\.Fragment, null, strRow\("dreamProvider"\), strRow\("dreamModel"\)\)/.test(clientSource),
|
|
686
|
-
"when /llm-providers is unavailable the plain text inputs must remain (old-backend fallback)"
|
|
687
|
-
);
|
|
688
|
-
// 2. 睡眠侧行:跟随 sleepModeEnabled 门控,与巩固共用数据源
|
|
689
|
-
assert.ok(
|
|
690
|
-
/const sleepSub = eff\.sleepModeEnabled && Array\.isArray\(routes\) && h\("div", \{ className: "mneme-featsub" \},\s*\n\s*routeSelects\("sleepProvider", "sleepModel", sleepTest, setSleepTest, "sleepReasoningEffort"\)/.test(clientSource),
|
|
691
|
-
"the sleep route row must gate on sleepModeEnabled and share the providers source"
|
|
692
|
-
);
|
|
693
|
-
// 2b. 睡眠路由的值必须进初始化草稿:FEATURE_STRINGS 漏键会导致重挂载后
|
|
694
|
-
// 下拉永远显示「跟随默认路由」(后端存了但前端读不回来)。
|
|
695
|
-
assert.ok(
|
|
696
|
-
/const FEATURE_STRINGS = \["dreamProvider", "dreamModel", "sleepProvider", "sleepModel",/.test(clientSource),
|
|
697
|
-
"sleepProvider/sleepModel must be restored into the draft on mount, not only written"
|
|
698
|
-
);
|
|
699
|
-
// 2c. 连通性测试状态按路由各持一份:共享单份会让点一个按钮两个组同时
|
|
700
|
-
// 显示「测试中/结果」且互相禁用。
|
|
701
|
-
assert.ok(
|
|
702
|
-
/const \[dreamTest, setDreamTest\] = useState\(null\);/.test(clientSource)
|
|
703
|
-
&& /const \[sleepTest, setSleepTest\] = useState\(null\);/.test(clientSource),
|
|
704
|
-
"the connectivity test state must be per-route, not shared between dream and sleep"
|
|
705
|
-
);
|
|
706
|
-
assert.ok(
|
|
707
|
-
/runModelTest\(curP, curM, setTestState, effort\)/.test(clientSource),
|
|
708
|
-
"each route's test button must target its own test state and pass the route's configured effort"
|
|
709
|
-
);
|
|
710
|
-
// 3. 连通性测试:真实最小调用 + 结果展示(成功/失败 + 耗时 + 报错原因)
|
|
711
|
-
assert.ok(
|
|
712
|
-
clientSource.includes('apiFetch("/api/dsh-mneme/test-model", {'),
|
|
713
|
-
"the connectivity test must POST /test-model"
|
|
714
|
-
);
|
|
715
|
-
assert.ok(
|
|
716
|
-
/body: JSON\.stringify\(\{\s*provider, model,\s*\.\.\.\(reasoningEffort && reasoningEffort !== "none" \? \{ reasoningEffort \} : \{\}\)\s*\}\)/.test(clientSource),
|
|
717
|
-
"the test payload must carry provider/model plus the configured reasoningEffort, omitted for 'none'/unset (issue #215, same convention as the backend)"
|
|
718
|
-
);
|
|
719
|
-
assert.ok(
|
|
720
|
-
/typeof j\.durationMs === "number" \? j\.durationMs : Date\.now\(\) - started/.test(clientSource),
|
|
721
|
-
"the result duration must prefer the backend's durationMs and fall back to client timing"
|
|
722
|
-
);
|
|
723
|
-
assert.ok(
|
|
724
|
-
/detail: \[j\.modelId, j\.reply \? "「" \+ j\.reply \+ "」" : ""\]\.filter\(Boolean\)\.join\(" · "\)/.test(clientSource),
|
|
725
|
-
"the success line must surface the model's actual reply (backend caps it at 100 chars)"
|
|
726
|
-
);
|
|
727
|
-
assert.ok(
|
|
728
|
-
/detail: \[j\.modelId, j\.error \|\| "HTTP " \+ res\.status\]\.filter\(Boolean\)\.join\(" · "\)/.test(clientSource),
|
|
729
|
-
"the failure line must carry the tested modelId plus the backend error (502/400 bodies)"
|
|
730
|
-
);
|
|
731
|
-
assert.ok(
|
|
732
|
-
clientSource.includes("memory.features.modelTestOk") && clientSource.includes("memory.features.modelTestFail"),
|
|
733
|
-
"the result line must render success/failure with the localized labels"
|
|
734
|
-
);
|
|
735
|
-
// 4. 下拉改动即提交(与 embedProvider 一致),当前值不在枚举时保留为额外选项
|
|
736
|
-
assert.ok(
|
|
737
|
-
/setStrs\(\(c\) => \(\{ \.\.\.c, \[key\]: v \}\)\);\s*\n\s*put\(\{ \[key\]: v \}\)/.test(clientSource),
|
|
738
|
-
"select changes must commit through the features PUT like the embed provider select"
|
|
739
|
-
);
|
|
740
|
-
assert.ok(
|
|
741
|
-
/curM && !mVals\.includes\(curM\) \? h\("option", \{ key: "current", value: curM \},\s*\n\s*mStale \? curM \+ t\("memory\.features\.routeStaleMark"\) : curM\) : null/.test(clientSource),
|
|
742
|
-
"a configured value missing from the provider's model list must survive as an extra option, marked when stale (issue #191)"
|
|
743
|
-
);
|
|
744
|
-
// 5. 双语 i18n 与样式
|
|
745
|
-
for (const key of ["routeFollowDefault", "modelTest", "modelTesting", "modelTestOk", "modelTestFail", "modelTestHint", "routeStaleMark", "routeStaleHint", "sleepModelHint"]) {
|
|
746
|
-
const occurrences = clientSource.split(`"memory.features.${key}"`).length - 1;
|
|
747
|
-
assert.ok(occurrences >= 2, `i18n key memory.features.${key} must exist in both zh and en (got ${occurrences})`);
|
|
748
|
-
}
|
|
749
|
-
assert.ok(
|
|
750
|
-
clientSource.includes(".mneme-routeselect{width:240px;max-width:60%}"),
|
|
751
|
-
"the route selects must share the string-input width budget"
|
|
752
|
-
);
|
|
753
|
-
});
|
|
754
|
-
|
|
755
|
-
// issue #191:级联下拉刻意保留不在列表里的旧值(不丢配置),但此前与正常
|
|
756
|
-
// 选项无差别展示——切 Provider 后残留的中继风格 model id 照常保存,用户分
|
|
757
|
-
// 不清「改错了」还是「还没生效」。旧值必须显式标记 + 行级提示(改选或点连
|
|
758
|
-
// 通性测试当场验证 + 重启生效);适配器列表为空或 provider 未注册时前端无
|
|
759
|
-
// 从校验 model,一律不标记防误报。
|
|
760
|
-
test("stale route values are marked and explained at the row level (issue #191)", () => {
|
|
761
|
-
// 1. 旧值判定:仅在适配器列表非空时比对;model 只在 provider 已注册时校验
|
|
762
|
-
assert.ok(
|
|
763
|
-
/const hasProviders = entries\.length > 0;/.test(clientSource),
|
|
764
|
-
"stale checks must be gated on a non-empty provider registry (no false alarms)"
|
|
765
|
-
);
|
|
766
|
-
assert.ok(
|
|
767
|
-
/const pStale = hasProviders && !!curP && !entry;/.test(clientSource),
|
|
768
|
-
"a saved provider missing from /llm-providers must be flagged"
|
|
769
|
-
);
|
|
770
|
-
assert.ok(
|
|
771
|
-
/const mStale = hasProviders && !!entry && !!curM && !mVals\.includes\(curM\);/.test(clientSource),
|
|
772
|
-
"a saved model outside the selected provider's list must be flagged only when that provider is registered"
|
|
773
|
-
);
|
|
774
|
-
// 2. 标记落在选项文本上:下拉收起时也能看出旧值不在列表
|
|
775
|
-
assert.ok(
|
|
776
|
-
/pStale && p === curP \? p \+ t\("memory\.features\.routeStaleMark"\) : p/.test(clientSource),
|
|
777
|
-
"the preserved provider option must carry the stale mark"
|
|
778
|
-
);
|
|
779
|
-
// 3. 行级提示:⚠ 警示 + 改选/测试引导 + 重启生效,一条提示说完
|
|
780
|
-
assert.ok(
|
|
781
|
-
/\(pStale \|\| mStale\) && h\("div", \{ className: "mneme-featsubhint" \},\s*\n\s*"⚠ " \+ t\("memory\.features\.routeStaleHint"\)/.test(clientSource),
|
|
782
|
-
"the stale warning hint must render at the route row with the warning prefix"
|
|
783
|
-
);
|
|
784
|
-
// 3b. 旧值保留必须无条件:列表为空(端点刚起 / 零适配器)时也不得把已存
|
|
785
|
-
// provider 静默显示成「跟随默认路由」——只有标记带门,保留不带门。
|
|
786
|
-
assert.ok(
|
|
787
|
-
/curP && !entries\.some\(\(p\) => p\.provider === curP\) \? \[curP\] : \[\]/.test(clientSource),
|
|
788
|
-
"the preserved provider option must stay unconditional (empty list must not hide the saved value)"
|
|
789
|
-
);
|
|
790
|
-
// 3c. 英文提示不得把「不在列表」限定在 model 维度——provider 本身不在
|
|
791
|
-
// 列表时同样渲染此行(CodeRabbit #213 review)。
|
|
792
|
-
assert.ok(
|
|
793
|
-
!clientSource.includes("provider's model list"),
|
|
794
|
-
"the en stale hint must cover provider-absence too, not only the model case"
|
|
795
|
-
);
|
|
796
|
-
// 4. issue #215:三条路由各自透传自己的档位键——routeSelects 按路由行
|
|
797
|
-
// 传入键名、从生效配置取值;sleep 无回退重试,档位被拒时测试是事前唯一
|
|
798
|
-
// 的暴露口。
|
|
799
|
-
assert.ok(
|
|
800
|
-
/const routeSelects = \(providerKey, modelKey, testState, setTestState, effortKey\) => \{/.test(clientSource)
|
|
801
|
-
&& /const effort = effortKey \? \(eff\[effortKey\] \|\| ""\) : ""/.test(clientSource),
|
|
802
|
-
"routeSelects must take the route's effort key and read the configured value from effective settings"
|
|
803
|
-
);
|
|
804
|
-
assert.ok(
|
|
805
|
-
/routeSelects\("dreamProvider", "dreamModel", dreamTest, setDreamTest, "dreamReasoningEffort"\)/.test(clientSource)
|
|
806
|
-
&& /routeSelects\("sleepProvider", "sleepModel", sleepTest, setSleepTest, "sleepReasoningEffort"\)/.test(clientSource)
|
|
807
|
-
&& /routeSelects\("entityExtractionProvider", "entityExtractionModel", entityTest, setEntityTest, "entityExtractionReasoning"\)/.test(clientSource),
|
|
808
|
-
"each route row must pass its own reasoning effort key (dream/sleep/entityExtraction)"
|
|
809
|
-
);
|
|
810
|
-
// 4. 连通性测试按钮的说明与结果互斥(同一行槽位二选一,不堆叠)
|
|
811
|
-
assert.ok(
|
|
812
|
-
/\(testState && !testState\.running\s*\n\s*\? h\("div", \{ className: "mneme-featsubhint" \},/.test(clientSource)
|
|
813
|
-
&& clientSource.includes('t("memory.features.modelTestHint")'),
|
|
814
|
-
"the test row must show either the result or the standing explanation, never both"
|
|
815
|
-
);
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
// 帮助与反馈入口(v0.8):设置页底部三个反馈链接——GitHub 新建 issue 预填
|
|
819
|
-
// (环境信息)、邮件反馈、浏览已知问题。纯前端链接零后端成本;插件版本从
|
|
820
|
-
// /info 拉取(version 只读,不铺任何 token/凭据)。公开链接不得带个人邮箱。
|
|
821
|
-
test("settings feedback card: prefilled issue + mailto + browse, version from /info", () => {
|
|
822
|
-
// 1. 版本预填端点
|
|
823
|
-
assert.ok(
|
|
824
|
-
clientSource.includes('apiFetch("/api/dsh-mneme/info")'),
|
|
825
|
-
"the feedback card must fetch the plugin version from /info"
|
|
826
|
-
);
|
|
827
|
-
assert.ok(
|
|
828
|
-
clientSource.includes("setPkgVersion"),
|
|
829
|
-
"the fetched version must land in component state"
|
|
830
|
-
);
|
|
831
|
-
// 2. GitHub 新建 issue:issues/new?title=&body= 预填环境信息(当前仓库无模板)
|
|
832
|
-
assert.ok(
|
|
833
|
-
clientSource.includes("https://github.com/slow-stack/mneme/issues/new?title="),
|
|
834
|
-
"the issue link must prefill title+body on issues/new"
|
|
835
|
-
);
|
|
836
|
-
assert.ok(
|
|
837
|
-
clientSource.includes("**插件版本**") && clientSource.includes("**平台**"),
|
|
838
|
-
"the prefill body must carry plugin version and platform"
|
|
839
|
-
);
|
|
840
|
-
// 3. 邮件反馈:官方邮箱(对外不写个人邮箱),mailto 预填 subject+body
|
|
841
|
-
assert.ok(
|
|
842
|
-
clientSource.includes("mailto:work@modusensus.space?subject="),
|
|
843
|
-
"the mailto link must point at the public support address"
|
|
844
|
-
);
|
|
845
|
-
// 4. 浏览已知问题:跳仓库 issues 列表页(去重前置步骤)
|
|
846
|
-
assert.ok(
|
|
847
|
-
clientSource.includes('href: "https://github.com/slow-stack/mneme/issues"'),
|
|
848
|
-
"the browse link must open the repo issues list"
|
|
849
|
-
);
|
|
850
|
-
// 5. 双语 i18n
|
|
851
|
-
for (const key of ["feedback.title", "feedback.newIssue", "feedback.email", "feedback.browse", "feedback.hint"]) {
|
|
852
|
-
const occurrences = clientSource.split(`"memory.settings.${key}"`).length - 1;
|
|
853
|
-
assert.ok(occurrences >= 2, `i18n key memory.settings.${key} must exist in both zh and en (got ${occurrences})`);
|
|
854
|
-
}
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
// UI 文案必须双语齐全(中英各一条)。用户明确要求所有 UI 文字都做 i18n,
|
|
858
|
-
// 这条守卫用「每个键恰好出现两次」兜住:出现奇数次就是漏译,>2 次说明重复。
|
|
859
|
-
test("i18n keys are bilingual: every key appears exactly twice (zh + en)", () => {
|
|
860
|
-
const counts = new Map();
|
|
861
|
-
for (const match of clientSource.matchAll(/^\s*"([a-zA-Z0-9_.]+)"\s*:\s*"/gm)) {
|
|
862
|
-
counts.set(match[1], (counts.get(match[1]) ?? 0) + 1);
|
|
863
|
-
}
|
|
864
|
-
const odd = [...counts.entries()].filter(([, n]) => n !== 2).map(([key, n]) => `${key}×${n}`);
|
|
865
|
-
assert.deepEqual(odd, [], `这些 i18n 键没有做到中英各一条:${odd.join(", ")}`);
|
|
866
|
-
assert.ok(counts.size > 100, `解析到的键太少(${counts.size}),正则可能没匹配到 i18n 表`);
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
// issue #135:向量状态卡必须区分「没配」与「配了却一条都没嵌上」。此前卡片只看
|
|
870
|
-
// ready,而未配置的 legacy embedder(ready 恒 true)一路显示成正常 provider。
|
|
871
|
-
test("vector status card surfaces the configured/degraded split", () => {
|
|
872
|
-
// 1. 读后端新字段,缺失(旧服务端)时退化成旧行为而不是误报未配置
|
|
873
|
-
assert.ok(
|
|
874
|
-
clientSource.includes('typeof j?.configured === "boolean" ? j.configured : null'),
|
|
875
|
-
"the card must read `configured` and tolerate older servers that omit it"
|
|
876
|
-
);
|
|
877
|
-
assert.ok(
|
|
878
|
-
clientSource.includes("j?.degraded === true"),
|
|
879
|
-
"the card must read `degraded`"
|
|
880
|
-
);
|
|
881
|
-
// 2. 三态互斥:未配置优先于初始化中,降级只在既非未配置也非初始化时成立
|
|
882
|
-
assert.ok(
|
|
883
|
-
clientSource.includes("const unconfigured = !off && state.configured === false;"),
|
|
884
|
-
"unconfigured must be its own state, not folded into ready"
|
|
885
|
-
);
|
|
886
|
-
assert.ok(
|
|
887
|
-
clientSource.includes("const pending = !off && !unconfigured && state.ready !== true;"),
|
|
888
|
-
"an unconfigured embedder must not be reported as initializing"
|
|
889
|
-
);
|
|
890
|
-
assert.ok(
|
|
891
|
-
clientSource.includes("const degraded = !off && !unconfigured && !pending && state.degraded === true;"),
|
|
892
|
-
"degraded must only apply to a configured, settled embedder"
|
|
893
|
-
);
|
|
894
|
-
// 3. 三个状态各自的标签/说明走 i18n,双语齐备
|
|
895
|
-
for (const key of ["vectorUnconfigured", "vectorUnconfiguredHint", "vectorDegradedHint"]) {
|
|
896
|
-
const occurrences = clientSource.split(`"memory.status.${key}"`).length - 1;
|
|
897
|
-
assert.ok(occurrences >= 2, `i18n key memory.status.${key} must exist in both zh and en (got ${occurrences})`);
|
|
898
|
-
}
|
|
899
|
-
assert.ok(
|
|
900
|
-
clientSource.includes('t("memory.status.vectorUnconfigured")'),
|
|
901
|
-
"the unconfigured state must render its own label"
|
|
902
|
-
);
|
|
903
|
-
assert.ok(
|
|
904
|
-
clientSource.includes('t("memory.status.vectorDegradedHint")'),
|
|
905
|
-
"the degraded state must render its own caption (0 of {m} indexed)"
|
|
906
|
-
);
|
|
907
|
-
});
|
|
908
|
-
|
|
909
|
-
// v0.8.0 A4(issue #17):作用域隔离的面板面——设置卡新组(scopeEnabled /
|
|
910
|
-
// strictScope 开关)+ 记忆条目的 scope provenance 展示。i18n 键必须中英双语
|
|
911
|
-
// 齐全;徽章/详情行沿用 heat 徽章的「字段缺省即不渲染」模式,前端不感知开关。
|
|
912
|
-
/**
|
|
913
|
-
* A4(issue #17)面板面回归:作用域隔离的设置卡分组、十个 i18n 键的中英双语
|
|
914
|
-
* 齐备(沿用 occurrences ≥ 2 模式),以及 ScopeBadge 组件与三处接线(cards
|
|
915
|
-
* 脚部 / timeline 行 / 详情抽屉 dmeta)的真实存在性——只做源码断言,不挂
|
|
916
|
-
* React 渲染环境,与文件内其他 clientSource 断言同一风格。
|
|
917
|
-
*/
|
|
918
|
-
test("scope isolation ships panel switches and provenance surfaces (A4)", () => {
|
|
919
|
-
assert.ok(
|
|
920
|
-
/key: "group\.scope", items: \["scopeEnabled", "strictScope"\]/.test(clientSource),
|
|
921
|
-
"FEATURE_GROUPS must carry a dedicated scope group"
|
|
922
|
-
);
|
|
923
|
-
for (const key of [
|
|
924
|
-
"memory.features.group.scope",
|
|
925
|
-
"memory.features.scopeEnabled",
|
|
926
|
-
"memory.features.scopeEnabled.hint",
|
|
927
|
-
"memory.features.strictScope",
|
|
928
|
-
"memory.features.strictScope.hint",
|
|
929
|
-
"memory.explorer.scopeBadge",
|
|
930
|
-
"memory.explorer.detail.agentScope",
|
|
931
|
-
"memory.explorer.detail.workspaceScope",
|
|
932
|
-
"memory.explorer.detail.sensitivity",
|
|
933
|
-
"memory.explorer.detail.occurred"
|
|
934
|
-
]) {
|
|
935
|
-
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
936
|
-
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
937
|
-
}
|
|
938
|
-
assert.ok(/const ScopeBadge = \(\{ m, t \}\)/.test(clientSource), "ScopeBadge component must exist");
|
|
939
|
-
assert.ok(clientSource.includes("h(ScopeBadge, { m, t })"), "ScopeBadge must be wired into browse views");
|
|
940
|
-
assert.ok(clientSource.includes('t("memory.explorer.detail.agentScope")'), "drawer must render agent scope row");
|
|
941
|
-
assert.ok(clientSource.includes('t("memory.explorer.detail.occurred")'), "drawer must render occurred row");
|
|
942
|
-
});
|
|
943
|
-
|
|
944
|
-
// v0.8.0 冲突集中处理:状态页的待确认冲突队列(reason + 双方内容对比 +
|
|
945
|
-
// 保留 A/B/仅标记动作),resolveConflictPending 的第一个前端出口。双语齐备。
|
|
946
|
-
test("conflict queue ships a central review surface on the status tab", () => {
|
|
947
|
-
for (const key of [
|
|
948
|
-
"memory.status.conflictQueue.reason",
|
|
949
|
-
"memory.status.conflictQueue.sideA",
|
|
950
|
-
"memory.status.conflictQueue.sideB",
|
|
951
|
-
"memory.status.conflictQueue.keepA",
|
|
952
|
-
"memory.status.conflictQueue.keepB",
|
|
953
|
-
"memory.status.conflictQueue.markReviewed",
|
|
954
|
-
"memory.status.conflictQueue.applyHint",
|
|
955
|
-
"memory.status.conflictQueue.missing",
|
|
956
|
-
"memory.status.conflictQueue.refresh"
|
|
957
|
-
]) {
|
|
958
|
-
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
959
|
-
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
960
|
-
}
|
|
961
|
-
assert.ok(/function ConflictsQueue\(\{ t \}\)/.test(clientSource), "ConflictsQueue component must exist");
|
|
962
|
-
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts"'), "queue must fetch the conflicts endpoint");
|
|
963
|
-
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts/resolve"'), "queue must call the resolve endpoint");
|
|
964
|
-
assert.ok(clientSource.includes("h(ConflictsQueue, { t })"), "status tab must render the queue");
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
// --- issue #178 批次一:无障碍(aria-live 网络 / 语义标题 / 焦点管理 / 图摘要) ---
|
|
968
|
-
|
|
969
|
-
// 面板瞬时反馈(保存/裁决/刷新/复制)不能只靠纯视觉 span:announce() 单例
|
|
970
|
-
// polite live region 是唯一的播报通道,所有接线点都必须走它。
|
|
971
|
-
test("a11y: announce() live region exists and every transient feedback routes through it", () => {
|
|
972
|
-
assert.ok(clientSource.includes('function announce(text)'), "module-level announce() must exist");
|
|
973
|
-
assert.ok(clientSource.includes('"aria-live"'), "live region must set aria-live");
|
|
974
|
-
assert.ok(clientSource.includes('"role", "status"'), "live region must carry role=status");
|
|
975
|
-
// 接线点:功能开关保存、画像、向量、token、模式、extapi 保存+复制、
|
|
976
|
-
// 记忆编辑保存、队列刷新、裁决完成 —— 至少 9 处。
|
|
977
|
-
const wired = (clientSource.match(/\bannounce\(t\(/g) || []).length;
|
|
978
|
-
assert.ok(wired >= 9, `announce() wiring points expected >= 9, got ${wired}`);
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
// 状态卡标题原来是 div,读屏无法按标题导航;统一 h3(CSS margin 归零防回归)。
|
|
982
|
-
test("a11y: status card titles are real headings", () => {
|
|
983
|
-
assert.ok(clientSource.includes('h("h3", { className: "mneme-xcolhead" }'), "StatusCard title must be an h3");
|
|
984
|
-
const css = clientSource.match(/\.mneme-xcolhead\{[^}]*\}/);
|
|
985
|
-
assert.ok(css && css[0].includes("margin:0"), "h3 default margin must be neutralized in CSS");
|
|
986
|
-
});
|
|
987
|
-
|
|
988
|
-
// 弹层焦点管理:Tab 圈在面板内,关闭后焦点还给触发元素(两个 opener 都要标记)。
|
|
989
|
-
test("a11y: overlay focus trap and focus restore are wired", () => {
|
|
990
|
-
const openers = (clientSource.match(/"data-mneme-overlay-opener"/g) || []).length;
|
|
991
|
-
assert.equal(openers, 2, "both overlay entry buttons must carry the opener marker");
|
|
992
|
-
assert.ok(clientSource.includes('querySelectorAll(\'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])\')'),
|
|
993
|
-
"overlay must enumerate focusables for the Tab cycle");
|
|
994
|
-
assert.ok(/querySelector\('\[data-mneme-overlay-opener\]'\)/.test(clientSource), "closing must restore focus to the opener");
|
|
995
|
-
});
|
|
996
|
-
|
|
997
|
-
// 冲突裁决按钮「保留 A/B」在同文案多卡片下不可区分,必须带条目标题的 aria-label。
|
|
998
|
-
test("a11y: conflict keep buttons carry item-title aria-labels", () => {
|
|
999
|
-
const labels = (clientSource.match(/"aria-label": `\$\{t\("memory\.status\.conflictQueue\.keep/g) || []).length;
|
|
1000
|
-
assert.equal(labels, 2, "keepA/keepB buttons must both set aria-label");
|
|
1001
|
-
});
|
|
1002
|
-
|
|
1003
|
-
// ego 关系图对读屏是一块不可达 SVG,必须 role=img + 计数摘要(键中英各一)。
|
|
1004
|
-
test("a11y: entity graph svg exposes a count summary", () => {
|
|
1005
|
-
assert.ok(clientSource.includes('role: "img"'), "graph svg must be role=img");
|
|
1006
|
-
for (const key of ["memory.graph.summary"]) {
|
|
1007
|
-
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1008
|
-
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1009
|
-
}
|
|
1010
|
-
});
|
|
1011
|
-
|
|
1012
|
-
// --- issue #177:冲突队列交互与视觉重做 ---
|
|
1013
|
-
|
|
1014
|
-
// 相似度进度条:只从「相似度 X / similarity X」锚定短语回收数字(防把年份当
|
|
1015
|
-
// 相似度),A/B 卡片带侧色类,冻结徽章替代预裁决阶段的「已归档」徽章。
|
|
1016
|
-
test("conflict queue: similarity bar, side colors and frozen badge", () => {
|
|
1017
|
-
assert.ok(clientSource.includes("similarityOf(it.reason)"), "reason must be parsed for similarity");
|
|
1018
|
-
assert.ok(clientSource.includes("/(?:相似度|similarity)\\s*([01](?:\\.\\d+)?)/i"), "parse must anchor on the similarity phrase");
|
|
1019
|
-
for (const key of ["memory.status.conflictQueue.similarity", "memory.status.conflictQueue.frozenBadge"]) {
|
|
1020
|
-
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1021
|
-
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1022
|
-
}
|
|
1023
|
-
assert.ok(clientSource.includes('"mneme-conflict-side--a"'), "side A must carry the A color class");
|
|
1024
|
-
assert.ok(clientSource.includes('"mneme-conflict-side--b"'), "side B must carry the B color class");
|
|
1025
|
-
assert.ok(!/s\.archived && h\("span", \{ className: "mneme-badge mneme-badge--archived"/.test(clientSource),
|
|
1026
|
-
"the pre-ruling archived badge must be replaced by the frozen badge");
|
|
1027
|
-
assert.ok(clientSource.includes("mneme-badge--frozen"), "frozen badge class must exist");
|
|
1028
|
-
});
|
|
1029
|
-
|
|
1030
|
-
// 词级 diff:LCS 对齐是纯函数,锁它的存在、上限护栏与渲染接线(无损性由
|
|
1031
|
-
// difftest 独立验证,此处只锁形状)。
|
|
1032
|
-
test("conflict queue: word diff is wired with size guards", () => {
|
|
1033
|
-
assert.ok(clientSource.includes("function wordDiff(aText, bText)"), "wordDiff must exist");
|
|
1034
|
-
assert.ok(clientSource.includes("mneme-conflict-mark--del"), "del highlight class must exist");
|
|
1035
|
-
assert.ok(clientSource.includes("mneme-conflict-mark--ins"), "ins highlight class must exist");
|
|
1036
|
-
assert.ok(clientSource.includes("n > 800 || m > 800"), "DP must bail on oversized inputs");
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
// 空态教育卡:0 冲突时也渲染(原来整块 return null),解释冻结是什么。
|
|
1040
|
-
test("conflict queue: empty-state explainer card", () => {
|
|
1041
|
-
assert.ok(!/if \(!items \|\| items\.length === 0\) return null;/.test(clientSource),
|
|
1042
|
-
"empty queue must render the explainer instead of nothing");
|
|
1043
|
-
for (const key of ["memory.status.conflictQueue.emptyTitle", "memory.status.conflictQueue.emptyBody"]) {
|
|
1044
|
-
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1045
|
-
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1046
|
-
}
|
|
1047
|
-
});
|
|
1048
|
-
|
|
1049
|
-
// 状态卡待处理高亮 + 侧边栏入口 badge(挂 portal 与回退两个按钮)。
|
|
1050
|
-
test("conflict queue: actionable status card and sidebar entry badge", () => {
|
|
1051
|
-
assert.ok(clientSource.includes('"mneme-conflict-jump"'), "pending>0 card must carry the highlight class");
|
|
1052
|
-
assert.ok(clientSource.includes("mneme-statuscard--actionable"), "highlight CSS must exist");
|
|
1053
|
-
assert.ok((clientSource.match(/h\(ConflictBadge, \{ pending \}\)/g) || []).length === 2,
|
|
1054
|
-
"badge must be wired into both entry buttons");
|
|
1055
|
-
assert.ok((clientSource.match(/= useConflictBadgeCount\(\);/g) || []).length === 2,
|
|
1056
|
-
"both entry components must subscribe to the shared badge count");
|
|
1057
|
-
assert.ok(clientSource.includes('"/api/dsh-mneme/dream-status"'), "badge must reuse the dream-status endpoint");
|
|
1058
|
-
});
|
|
1059
|
-
|
|
1060
|
-
// 用户反馈「裁决完红点消失得很慢」的根因:裁决成功时冲突队列广播
|
|
1061
|
-
// mneme:conflicts-changed,但只有状态卡订阅了它,侧边栏红点漏订——红点于是只能
|
|
1062
|
-
// 等 60s 兜底轮询。本用例锁三类回归:(a) 红点订阅该广播并立即重取;(b) 计数走
|
|
1063
|
-
// 模块级单例(原先 portal 与 footer 回退各起一个 60s 轮询 → 两份请求且互不同步);
|
|
1064
|
-
// (c) 兜底轮询保留(后台 autoDream 自己产生/消解冲突时没人广播,靠它浮现)。
|
|
1065
|
-
test("sidebar badge clears on the conflicts-changed broadcast, not on the next poll", () => {
|
|
1066
|
-
assert.ok(
|
|
1067
|
-
/window\.addEventListener\("mneme:conflicts-changed", onChanged\)/.test(clientSource),
|
|
1068
|
-
"the badge must subscribe to the broadcast the conflict queue already emits"
|
|
1069
|
-
);
|
|
1070
|
-
assert.ok(
|
|
1071
|
-
clientSource.includes("const conflictBadgeCount = (() => {"),
|
|
1072
|
-
"the count must live in one module-level store shared by both entry components"
|
|
1073
|
-
);
|
|
1074
|
-
assert.ok(
|
|
1075
|
-
clientSource.includes("if (listeners.size === 1) {"),
|
|
1076
|
-
"exactly one poll timer per store, refcounted by its subscribers"
|
|
1077
|
-
);
|
|
1078
|
-
assert.ok(
|
|
1079
|
-
/setInterval\(refresh, 60_000\)/.test(clientSource),
|
|
1080
|
-
"the 60s fallback poll must stay: background autoDream resolves conflicts without broadcasting"
|
|
1081
|
-
);
|
|
1082
|
-
assert.ok(
|
|
1083
|
-
clientSource.includes("if (inflight) return inflight;"),
|
|
1084
|
-
"concurrent refreshes (broadcast + open + first subscriber) must collapse into one request"
|
|
1085
|
-
);
|
|
1086
|
-
});
|
|
1087
|
-
|
|
1088
|
-
// 上一条锁的是"接线在不在",这条锁"接线真的按预期跑"。把源码里那段 store 抠出来
|
|
1089
|
-
// 用受控的 apiFetch / setInterval 真跑一遍——用户抱怨的正是时序(慢),而
|
|
1090
|
-
// "存在某个字符串" 证明不了任何时序。回归表现:第二个订阅者又开一条轮询、
|
|
1091
|
-
// 广播后不立刻反映、并发 refresh 打两次请求、订阅者走光后定时器还在跑。
|
|
1092
|
-
test("badge store: one poll for both entries, immediate publish after a refresh", async () => {
|
|
1093
|
-
const src = clientSource.match(/const conflictBadgeCount = \(\(\) => \{[\s\S]*?\}\)\(\);/);
|
|
1094
|
-
assert.ok(src, "the badge store source block must be extractable verbatim");
|
|
1095
|
-
const calls = [];
|
|
1096
|
-
const resolvers = [];
|
|
1097
|
-
const timers = [];
|
|
1098
|
-
let cleared = 0;
|
|
1099
|
-
const makeStore = new Function("apiFetch", "setInterval", "clearInterval",
|
|
1100
|
-
`${src[0]}\nreturn conflictBadgeCount;`);
|
|
1101
|
-
const store = makeStore(
|
|
1102
|
-
(path) => {
|
|
1103
|
-
calls.push(path);
|
|
1104
|
-
return new Promise((resolve) => { resolvers.push(resolve); });
|
|
1105
|
-
},
|
|
1106
|
-
(fn, ms) => { timers.push({ fn, ms }); return timers.length; },
|
|
1107
|
-
() => { cleared += 1; }
|
|
1108
|
-
);
|
|
1109
|
-
const settle = async (payload) => {
|
|
1110
|
-
const batch = resolvers.splice(0, resolvers.length);
|
|
1111
|
-
batch.forEach((r) => r({ ok: true, json: () => Promise.resolve(payload) }));
|
|
1112
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
1113
|
-
};
|
|
1114
|
-
|
|
1115
|
-
const seen = [];
|
|
1116
|
-
const un1 = store.subscribe((v) => seen.push(v));
|
|
1117
|
-
assert.equal(calls.length, 1, "the first subscriber must trigger exactly one fetch");
|
|
1118
|
-
assert.equal(timers.length, 1, "and exactly one timer");
|
|
1119
|
-
assert.equal(timers[0].ms, 60_000, "the fallback cadence stays 60s");
|
|
1120
|
-
const un2 = store.subscribe((v) => seen.push(v));
|
|
1121
|
-
assert.equal(calls.length, 1, "a second subscriber must reuse the poll, not start another");
|
|
1122
|
-
assert.equal(timers.length, 1, "still one timer for both entry buttons");
|
|
1123
|
-
|
|
1124
|
-
await settle({ pendingConflicts: 3 });
|
|
1125
|
-
assert.deepEqual(seen, [0, 0, 3, 3], "both subscribers must see the same published value");
|
|
1126
|
-
|
|
1127
|
-
// 广播路径:冲突队列裁决成功后 dispatch mneme:conflicts-changed → hook 调 refresh()
|
|
1128
|
-
store.refresh();
|
|
1129
|
-
store.refresh();
|
|
1130
|
-
assert.equal(calls.length, 2, "concurrent refreshes collapse into a single in-flight request");
|
|
1131
|
-
await settle({ pendingConflicts: 0 });
|
|
1132
|
-
assert.equal(store.get(), 0, "the count must drop as soon as the refreshed value lands");
|
|
1133
|
-
assert.equal(seen[seen.length - 1], 0, "and both subscribers must be notified");
|
|
1134
|
-
|
|
1135
|
-
store.refresh();
|
|
1136
|
-
await settle({ pendingConflicts: 0 });
|
|
1137
|
-
assert.deepEqual(seen, [0, 0, 3, 3, 0, 0], "an unchanged value must not re-render subscribers");
|
|
1138
|
-
|
|
1139
|
-
un1();
|
|
1140
|
-
assert.equal(cleared, 0, "the timer must survive while one subscriber remains");
|
|
1141
|
-
un2();
|
|
1142
|
-
assert.equal(cleared, 1, "the timer must stop when the last subscriber leaves");
|
|
1143
|
-
});
|
|
1144
|
-
|
|
1145
|
-
// --- issue #179:注入预览(状态页卡片 + /inject-preview 端点透传的旁路快照) ---
|
|
1146
|
-
test("a11y+preview: inject preview card is wired on the status tab", () => {
|
|
1147
|
-
assert.ok(clientSource.includes('"/api/dsh-mneme/inject-preview"'), "card must fetch the preview endpoint");
|
|
1148
|
-
assert.ok(clientSource.includes("h(InjectPreviewCard, { t })"), "status grid must render the preview card");
|
|
1149
|
-
for (const key of [
|
|
1150
|
-
"memory.status.injectPreview",
|
|
1151
|
-
"memory.status.injectPreviewNone",
|
|
1152
|
-
"memory.status.injectPreview.chars",
|
|
1153
|
-
"memory.status.injectPreview.empty"
|
|
1154
|
-
]) {
|
|
1155
|
-
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1156
|
-
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1157
|
-
}
|
|
1158
|
-
});
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
assert.
|
|
1169
|
-
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const clientSource = readFileSync(join(root, "lib/client.js"), "utf8");
|
|
10
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
11
|
+
|
|
12
|
+
// The Web client bundle registers itself via __ModuleLoader__.load. DSH
|
|
13
|
+
// resolves the bundle by plugin package name, so the registered id must match
|
|
14
|
+
// package.json `name` exactly (a mismatch surfaces as "loaded without
|
|
15
|
+
// registering '@modusensus/dsh-mneme'" in the DSH client).
|
|
16
|
+
test("client bundle registers under the package name", () => {
|
|
17
|
+
const match = clientSource.match(/__ModuleLoader__\.load\(\{\s*id:\s*"([^"]+)"/);
|
|
18
|
+
assert.ok(match, "client bundle must call __ModuleLoader__.load with an id");
|
|
19
|
+
assert.equal(match[1], pkg.name, "registered id must equal package.json name");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// client.js is hand-authored under lib/ only (no src/ counterpart), so the
|
|
23
|
+
// src->lib sync must never prune it.
|
|
24
|
+
test("client bundle is lib-only with no src counterpart", () => {
|
|
25
|
+
assert.equal(existsSync(join(root, "src/client.js")), false, "src/ must not contain client.js");
|
|
26
|
+
assert.equal(existsSync(join(root, "lib/client.js")), true, "lib/client.js must exist");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// 归档侧第五指标(#275)自己按「归档区为空就整段省略」门控,但整卡还有一道 hasData 门:
|
|
30
|
+
// 那道门只认召回回执与活跃僵尸行时,「全归档 + 窗口内无回执」的库会直接 return null,
|
|
31
|
+
// 指标永远不显示(自动评审 #312 指出的回归)。这里锁死 hasData 必须把 archive.total 计进来。
|
|
32
|
+
test("status card: hasData counts the archive metric", () => {
|
|
33
|
+
const m = clientSource.match(/const hasData =[^;]+;/);
|
|
34
|
+
assert.ok(m, "the status card must declare hasData");
|
|
35
|
+
assert.match(m[0], /d\.archive\?\.total/, "hasData must count archive.total, not just runs/active rows");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// The memory entry lives at the sidebar foot, not in the settings modal: the
|
|
39
|
+
// migration must register into `sidebar.footer.action` (the list slot the
|
|
40
|
+
// sidebar shell renders beside Settings) and must not keep a `settings.section`
|
|
41
|
+
// registration, or the entry would appear twice under different hosts.
|
|
42
|
+
test("memory entry registers into the sidebar foot slot", () => {
|
|
43
|
+
assert.ok(
|
|
44
|
+
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
45
|
+
"client must inject into sidebar.footer.action"
|
|
46
|
+
);
|
|
47
|
+
assert.equal(
|
|
48
|
+
clientSource.includes('"settings.section"'),
|
|
49
|
+
false,
|
|
50
|
+
"the old settings.section registration must be gone"
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// The tab era is over: the in-conversation memory tab fought the floating
|
|
55
|
+
// composer and squeezed the session layout, so the library no longer
|
|
56
|
+
// registers as a conversation view. The sidebar entry opens the centered
|
|
57
|
+
// sheet directly — from any state, including the new-chat hero screen. A
|
|
58
|
+
// dialog role stays banned either way.
|
|
59
|
+
test("sidebar entry opens the sheet; conversation-view tab stays retired", () => {
|
|
60
|
+
assert.equal(
|
|
61
|
+
clientSource.includes("role: \"dialog\""),
|
|
62
|
+
false,
|
|
63
|
+
"no dialog surface should remain"
|
|
64
|
+
);
|
|
65
|
+
assert.ok(
|
|
66
|
+
/function openLibrary\(\) \{\s*setOverlayOpen\(true\);/.test(clientSource),
|
|
67
|
+
"the entry must open the overlay directly (no tab activation)"
|
|
68
|
+
);
|
|
69
|
+
assert.ok(
|
|
70
|
+
clientSource.includes("conflictBadgeCount.refresh();"),
|
|
71
|
+
"opening the library must re-read the conflict count (badge must not show a stale number)"
|
|
72
|
+
);
|
|
73
|
+
assert.equal(
|
|
74
|
+
clientSource.includes('ctx.slots.inject("conversation.view"'),
|
|
75
|
+
false,
|
|
76
|
+
"the conversation.view tab must stay retired"
|
|
77
|
+
);
|
|
78
|
+
assert.equal(
|
|
79
|
+
clientSource.includes("activateExplorerTab"),
|
|
80
|
+
false,
|
|
81
|
+
"the tab-click activation machinery must be gone"
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// The sheet is deliberately NOT fullscreen: a dimmed backdrop plus a rounded
|
|
86
|
+
// panel capped at 1180×880 keeps the conversation visible behind it. It
|
|
87
|
+
// reuses the full MemoryExplorer and closes on Esc, the close button, or a
|
|
88
|
+
// backdrop click.
|
|
89
|
+
test("memory library opens as a centered sheet with backdrop", () => {
|
|
90
|
+
assert.ok(
|
|
91
|
+
clientSource.includes(".mneme-backdrop{position:fixed;inset:0"),
|
|
92
|
+
"a dimmed backdrop must sit behind the sheet"
|
|
93
|
+
);
|
|
94
|
+
assert.ok(
|
|
95
|
+
/\.mneme-overlay\{position:fixed;z-index:1000;left:50%;top:50%;transform:translate\(-50%,-50%\)/.test(clientSource),
|
|
96
|
+
"the sheet must be centered, not viewport-filling"
|
|
97
|
+
);
|
|
98
|
+
assert.ok(
|
|
99
|
+
clientSource.includes("width:min(1240px,calc(100vw - 88px))"),
|
|
100
|
+
"the sheet must not occupy the full width and keeps friendly margins"
|
|
101
|
+
);
|
|
102
|
+
assert.ok(
|
|
103
|
+
clientSource.includes('h(MemoryExplorer, { t })'),
|
|
104
|
+
"the sheet renders the same MemoryExplorer component"
|
|
105
|
+
);
|
|
106
|
+
assert.ok(
|
|
107
|
+
clientSource.includes('e.key === "Escape"'),
|
|
108
|
+
"Esc must close the sheet"
|
|
109
|
+
);
|
|
110
|
+
assert.ok(
|
|
111
|
+
clientSource.includes('className: "mneme-backdrop"'),
|
|
112
|
+
"the backdrop is a clickable close surface"
|
|
113
|
+
);
|
|
114
|
+
assert.ok(
|
|
115
|
+
clientSource.includes('"memory.overlay.close"'),
|
|
116
|
+
"the close affordance keeps its localized label"
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// The sidebar hands each entry only its column state: a wide row (icon +
|
|
121
|
+
// label) when expanded, a bare rail icon when collapsed — for both the
|
|
122
|
+
// portalled top button and the footer fallback.
|
|
123
|
+
test("trigger renders a wide row or a rail icon from the wide flag", () => {
|
|
124
|
+
assert.ok(
|
|
125
|
+
/wide \? "mneme-trigger" : "mneme-trigger mneme-rail"/.test(clientSource),
|
|
126
|
+
"trigger must branch on the wide flag"
|
|
127
|
+
);
|
|
128
|
+
assert.ok(
|
|
129
|
+
/wide && h\("span", \{ className: "mneme-trigger-label" \}/.test(clientSource),
|
|
130
|
+
"the label span must render only when wide"
|
|
131
|
+
);
|
|
132
|
+
assert.ok(
|
|
133
|
+
/size: wide \? 16 : 18/.test(clientSource),
|
|
134
|
+
"the portalled icon must follow the host panel-row size (16) and the rail size (18)"
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// 入口的两态形状(宿主内核 0.2 侧边栏改版后重写,见 lib/client.js 的
|
|
139
|
+
// SidebarTopEntry 头注释):
|
|
140
|
+
// · 展开:混进宿主 nav.panelList 里「插件 / 自动化任务」那一组,套用宿主
|
|
141
|
+
// panelRow 的实时类名 → 一颗低调面板行,不再是第二个高亮「新会话」按钮;
|
|
142
|
+
// · 收起:宿主把面板列表整组 display:none,只有 fixed 到标题栏的开关与
|
|
143
|
+
// 「新会话」还可见,所以入口必须搬回那个位置(见下一条用例)。
|
|
144
|
+
// 本用例锁「展开态借的是宿主面板行」,回归表现是入口退回成大按钮或落回
|
|
145
|
+
// 新会话行后面变成孤立项。
|
|
146
|
+
test("sidebar entry portals into the host panel list with footer fallback", () => {
|
|
147
|
+
assert.ok(
|
|
148
|
+
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
149
|
+
"the footer slot registration must stay as anchor + fallback"
|
|
150
|
+
);
|
|
151
|
+
assert.ok(
|
|
152
|
+
clientSource.includes("SidebarTopEntry"),
|
|
153
|
+
"the portalled top entry must exist"
|
|
154
|
+
);
|
|
155
|
+
assert.ok(
|
|
156
|
+
clientSource.includes(`'[class*="regionArea"]'`),
|
|
157
|
+
"the portal must anchor at the host's regionArea container"
|
|
158
|
+
);
|
|
159
|
+
assert.ok(
|
|
160
|
+
clientSource.includes(`'[class*="panelList"]'`),
|
|
161
|
+
"the wide shape must target the host panel list (the 插件 / 自动化任务 group)"
|
|
162
|
+
);
|
|
163
|
+
assert.ok(
|
|
164
|
+
clientSource.includes(`'[class*="panelRow"]:not(.mneme-topentry-native)'`),
|
|
165
|
+
"the panel-row class must come from a host row, never from our own portalled button"
|
|
166
|
+
);
|
|
167
|
+
assert.ok(
|
|
168
|
+
clientSource.includes("list.appendChild(created)"),
|
|
169
|
+
"the wide entry must be appended into that group, below the host's own entries"
|
|
170
|
+
);
|
|
171
|
+
assert.ok(
|
|
172
|
+
clientSource.includes("insertBefore(created, target)"),
|
|
173
|
+
"the entry must keep the idempotent place() helper for hosts without a panel list"
|
|
174
|
+
);
|
|
175
|
+
assert.ok(
|
|
176
|
+
clientSource.includes("if (!host) return fallback;"),
|
|
177
|
+
"the portal entry persists across collapse (no footer jump); footer fallback only covers portal failure"
|
|
178
|
+
);
|
|
179
|
+
assert.ok(
|
|
180
|
+
clientSource.includes('"memory.view.label"'),
|
|
181
|
+
"the sheet aria-label must come from the memory.view.label dictionary key"
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Issue #130: ecosystem plugins (task board, skill explorer, …) inject their
|
|
186
|
+
// own entries right after the New-Session row, and late inserters land above
|
|
187
|
+
// earlier ones — a fixed "before regionArea" slot degrades into the tail of
|
|
188
|
+
// that group, detaching the memory button from its familiar top spot. The
|
|
189
|
+
// entry therefore (a) resolves the same anchor as the ecosystem's shared
|
|
190
|
+
// sidebar-entry-core (logoRow row, legacy direct-child button), (b) re-asserts
|
|
191
|
+
// its slot from the existing observer, and (c) stays idempotent — the anchor
|
|
192
|
+
// check short-circuits before any DOM write, so observers never ping-pong.
|
|
193
|
+
test("entry re-asserts its slot against ecosystem entries", () => {
|
|
194
|
+
assert.ok(
|
|
195
|
+
clientSource.includes(`btn.closest('[class*="logoRow"]')`),
|
|
196
|
+
"the anchor must resolve the New-Session logo row like the ecosystem core"
|
|
197
|
+
);
|
|
198
|
+
assert.ok(
|
|
199
|
+
clientSource.includes("const target = anchor ? anchor.nextSibling : region;"),
|
|
200
|
+
"the entry must sit right after the New-Session row, falling back before regionArea"
|
|
201
|
+
);
|
|
202
|
+
assert.ok(
|
|
203
|
+
clientSource.includes("if (created.parentElement === parent && created.nextSibling === target) return;"),
|
|
204
|
+
"placement must be idempotent so the observer never writes on steady state"
|
|
205
|
+
);
|
|
206
|
+
assert.ok(
|
|
207
|
+
/place\(cur\);\s*\n\s*sync\(cur\);/.test(clientSource),
|
|
208
|
+
"the observer must re-place the entry and re-read its classes on childList churn"
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// 用户实测确认的遮挡 bug(v0.8.10):收起侧边栏时入口沿用宿主给「新会话」的
|
|
213
|
+
// left:48px,正好压在「新会话」图标上把它盖掉,左上角只剩两颗图标。
|
|
214
|
+
// 修法:不再共用那个 left,用一条更具体的规则推到 84px(「新会话」右侧 =
|
|
215
|
+
// 宿主 48px + 钮宽 28px + 间距 8px),并把宿主的面板菜单变量
|
|
216
|
+
// --dsh-windows-menu-start(宿主收起态自己设 84px)顺延到 120px。
|
|
217
|
+
// 回归表现:左上角少一颗图标,或「应用 / 编辑」与记忆图标叠在一起。
|
|
218
|
+
test("collapsed entry vacates the New-Session slot instead of covering it", () => {
|
|
219
|
+
assert.ok(
|
|
220
|
+
clientSource.includes("html[data-windows-titlebar] .mneme-topentry .mneme-topentry-rail{left:84px}"),
|
|
221
|
+
"the collapsed entry must sit right of the host's 48px New-Session slot"
|
|
222
|
+
);
|
|
223
|
+
assert.ok(
|
|
224
|
+
// 源码里这段 CSS 含转义引号(注入 <style> 后才还原成普通引号),断言按源码字面量写。
|
|
225
|
+
clientSource.includes(':has([data-plugin-entry=\\"@modusensus/dsh-mneme\\"]){--dsh-windows-menu-start:120px}'),
|
|
226
|
+
"the caption menu must shift right so the memory icon never overlaps 应用 / 编辑"
|
|
227
|
+
);
|
|
228
|
+
assert.ok(
|
|
229
|
+
clientSource.includes(":has([data-sidebar-collapsed=true]):has("),
|
|
230
|
+
"the menu shift must apply only while the sidebar is collapsed"
|
|
231
|
+
);
|
|
232
|
+
assert.ok(
|
|
233
|
+
clientSource.includes("`${cls.row} mneme-topentry-rail`.trim()"),
|
|
234
|
+
"the collapsed button carries the host New-Session class plus our rail offset rule"
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// 宿主面板行的类名带着激活态修饰类(.xxx_panelActive),而我们的入口不是
|
|
239
|
+
// panel 插槽的成员、永远不该常亮;照抄整串类名会让记忆入口跟着宿主当前选中
|
|
240
|
+
// 的面板一起高亮。这里锁「读类名时必须剔掉激活态」。
|
|
241
|
+
test("panel-row highlight never leaks onto the memory entry", () => {
|
|
242
|
+
assert.ok(
|
|
243
|
+
clientSource.includes('!name.includes("panelActive")'),
|
|
244
|
+
"panelActive must be filtered out of the copied host row classes"
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Three alignment/softness guarantees born from field feedback: (a) the
|
|
249
|
+
// portalled entry re-reads the live host classes on every mutation — the host
|
|
250
|
+
// and skins rewrite hashed class names asynchronously, so a mount-time snapshot
|
|
251
|
+
// goes stale (收起态尤其依赖这条:搬迁后类名必须重新对齐宿主「新会话」按钮),
|
|
252
|
+
// (b) it never hardcodes a width, so whichever host class it carries decides the
|
|
253
|
+
// box model, (c) the toolbar dropdown escapes the transform stacking trap — the
|
|
254
|
+
// container needs the z-index because transform creates the context.
|
|
255
|
+
test("entry re-reads the live host classes and lifts the toolbar dropdown", () => {
|
|
256
|
+
assert.ok(
|
|
257
|
+
clientSource.includes("new MutationObserver"),
|
|
258
|
+
"the entry must re-sync the copied class via MutationObserver"
|
|
259
|
+
);
|
|
260
|
+
assert.ok(
|
|
261
|
+
clientSource.includes('attributeFilter: ["class"]'),
|
|
262
|
+
"the observer must watch class attribute changes"
|
|
263
|
+
);
|
|
264
|
+
assert.doesNotMatch(
|
|
265
|
+
clientSource,
|
|
266
|
+
/\.mneme-topentry-native\{(?:[^}]*;)?width:/,
|
|
267
|
+
"the entry must use the native button width, which reserves its horizontal margins"
|
|
268
|
+
);
|
|
269
|
+
assert.ok(
|
|
270
|
+
clientSource.includes(".mneme-topentry{display:flex;flex-direction:column;position:relative}"),
|
|
271
|
+
"the entry wrapper must stretch the native button within a column flex layout (position:relative hosts the #177 badge)"
|
|
272
|
+
);
|
|
273
|
+
assert.equal(
|
|
274
|
+
clientSource.includes(".mneme-topentry-native .mneme-topentry-label{flex:1;min-width:0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"),
|
|
275
|
+
false,
|
|
276
|
+
"the old left-aligned label override must go; native centering applies"
|
|
277
|
+
);
|
|
278
|
+
assert.ok(
|
|
279
|
+
/\.mneme-xtools\{[^}]*z-index:3\}/.test(clientSource),
|
|
280
|
+
"the toolbar container must carry z-index:3 (sticky month header is 2, drawer 6)"
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Importance renders as Lucide star glyphs (the morphicons-paired data set;
|
|
285
|
+
// the runtime cannot require the ESM-only morphicons engine, so the path
|
|
286
|
+
// ships inline like the other stroke icons). Raw text stars are fully
|
|
287
|
+
// retired: the drawer's edit-mode <option> select used to keep "★".repeat
|
|
288
|
+
// labels (SVG cannot render inside <option>) — it is now the SVG star
|
|
289
|
+
// button row, and the inline indicators (importance chips / entity meta /
|
|
290
|
+
// inject preview) share the same StarGlyph, so every star in the panel has
|
|
291
|
+
// one shape. (用户反馈:文本 ★ 过尖、与卡片 SVG 星不统一。)
|
|
292
|
+
test("importance renders as star glyphs, not raw text stars", () => {
|
|
293
|
+
assert.ok(
|
|
294
|
+
clientSource.includes("STAR_PATH_D"),
|
|
295
|
+
"the Lucide star path data must be inlined"
|
|
296
|
+
);
|
|
297
|
+
assert.ok(
|
|
298
|
+
clientSource.includes("const ImportanceStars"),
|
|
299
|
+
"the star-row component must exist"
|
|
300
|
+
);
|
|
301
|
+
assert.equal(
|
|
302
|
+
(clientSource.match(/★/g) || []).length,
|
|
303
|
+
0,
|
|
304
|
+
"no raw text star may remain anywhere — every star renders through the shared SVG path"
|
|
305
|
+
);
|
|
306
|
+
assert.ok(
|
|
307
|
+
/h\(ImportanceStars, \{ className: "mneme-dmetaval"/.test(clientSource),
|
|
308
|
+
"the drawer detail must render the star row"
|
|
309
|
+
);
|
|
310
|
+
assert.ok(
|
|
311
|
+
/h\(ImportanceStars, \{ value: m\.importance/.test(clientSource),
|
|
312
|
+
"the card foot must render the star row"
|
|
313
|
+
);
|
|
314
|
+
assert.ok(
|
|
315
|
+
/className: "mneme-staredit"/.test(clientSource),
|
|
316
|
+
"the drawer edit importance must be the SVG star button row, not a text-star <select>"
|
|
317
|
+
);
|
|
318
|
+
assert.ok(
|
|
319
|
+
/h\(StarGlyph, \{ size: 11 \}\)/.test(clientSource),
|
|
320
|
+
"inline star indicators must go through the shared StarGlyph"
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// better-sidebar ecosystem integration is an optional capability (official
|
|
325
|
+
// external-plugin-guide §2.2): 'betterSidebar' IS declared in inject (DSH's
|
|
326
|
+
// runtime gates ctx property access on the inject declaration — probing
|
|
327
|
+
// without declaring fails the whole loader entry, verified in the field) and
|
|
328
|
+
// better-sidebar 软集成(issue #88 修正):模块级 inject 声明 betterSidebar
|
|
329
|
+
// 是硬等待——未安装 bs 的环境整个 entry pending("1 entry did not activate",
|
|
330
|
+
// Failed to load plugins)。正确模式(dsh-server-deck 同款):外层入口零
|
|
331
|
+
// inject 立即激活(独立模式保底),tab 注册挂在内层动态子插件
|
|
332
|
+
// ctx.plugin({ inject: ['betterSidebar'] }) 由 cordis 原生等待服务——bs 未装
|
|
333
|
+
// 时该内层 fiber 永远 INACTIVE,静默无害。
|
|
334
|
+
test("better-sidebar tab mounts via an inner sub-plugin, standalone mode intact", () => {
|
|
335
|
+
assert.ok(
|
|
336
|
+
/const reg = bsCtx\.betterSidebar;[\s\S]{0,60}typeof reg\.registerTab !== "function"/.test(clientSource),
|
|
337
|
+
"the inner apply must still guard the service shape before registering"
|
|
338
|
+
);
|
|
339
|
+
assert.ok(
|
|
340
|
+
/(?:const TAB_ID = |id: )"dsh-mneme:memory"/.test(clientSource),
|
|
341
|
+
"the registered tab id must be package-prefixed"
|
|
342
|
+
);
|
|
343
|
+
assert.ok(
|
|
344
|
+
/title: \(\) => t\("memory\.view\.label"\)/.test(clientSource),
|
|
345
|
+
"the tab title must reuse the localized 记忆库 label"
|
|
346
|
+
);
|
|
347
|
+
assert.ok(
|
|
348
|
+
/component: \(\) => h\(MemoryExplorer, \{ t \}\)/.test(clientSource),
|
|
349
|
+
"the tab must reuse the MemoryExplorer views"
|
|
350
|
+
);
|
|
351
|
+
assert.ok(
|
|
352
|
+
clientSource.includes('"dsh-mneme: better-sidebar tab"'),
|
|
353
|
+
"the registration effect must carry a named label for scope cleanup"
|
|
354
|
+
);
|
|
355
|
+
// issue #88:模块级 inject 声明 betterSidebar 是硬等待——未安装 bs 的环境
|
|
356
|
+
// 整个 entry pending("1 entry did not activate")。tab 注册必须挂在内层
|
|
357
|
+
// 动态子插件(dsh-server-deck 同款模式),外层入口零 inject 立即激活。
|
|
358
|
+
assert.ok(
|
|
359
|
+
/const inject = \["slots", "locale"\]/.test(clientSource),
|
|
360
|
+
"the module inject must not declare betterSidebar (hard-wait regression)"
|
|
361
|
+
);
|
|
362
|
+
assert.ok(
|
|
363
|
+
/ctx\.plugin\?\.\(\{[\s\S]*?inject: \["betterSidebar"\][\s\S]*?apply: \(bsCtx\) =>/.test(clientSource),
|
|
364
|
+
"the tab registration must live in an inner dynamic sub-plugin waiting on cordis"
|
|
365
|
+
);
|
|
366
|
+
assert.ok(
|
|
367
|
+
/if \(\+\+tries <= 10\) timer = setTimeout\(attempt, 1000\);/.test(clientSource) === false,
|
|
368
|
+
"the old 10×1s probe must go — cordis waits for the inner inject natively"
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// The graph toggle must not read as "share": the primitives share icon is
|
|
373
|
+
// banned and a custom node-graph glyph takes its place.
|
|
374
|
+
test("graph toggle uses a node-graph glyph, not the share icon", () => {
|
|
375
|
+
assert.equal(
|
|
376
|
+
clientSource.includes("IconShareOutline16"),
|
|
377
|
+
false,
|
|
378
|
+
"IconShareOutline16 reads as share and must not appear"
|
|
379
|
+
);
|
|
380
|
+
assert.ok(
|
|
381
|
+
clientSource.includes("GraphNodesIcon"),
|
|
382
|
+
"the custom node-graph icon must back the graph toggle"
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// #287 的成因已经不存在了,但那条教训要留着:当年的崩法是「宿主图标名换代 →
|
|
387
|
+
// 取到 undefined → h(undefined) 即 React #130,整个 slot entry 崩掉」。现在的解法
|
|
388
|
+
// 不是继续探测多代名字,而是**不再依赖宿主图标库**——插件自己画 mneme mark。
|
|
389
|
+
// 这条用例锁三件事:(a) 客户端里不再有对宿主 IconArchive* 名字的探测(回到探测
|
|
390
|
+
// 就等于把 #287 那类耦合请回来);(b) mark 走一个内联 SVG 组件、自带路径常量;
|
|
391
|
+
// (c) 四个入口共用同一个 helper,避免某处漏改又出现"归档盒"。
|
|
392
|
+
test("mneme mark is self-drawn: no host icon-name probing left (#287 root cause)", () => {
|
|
393
|
+
assert.equal(
|
|
394
|
+
/primitives\.IconArchiveOutline(20|Regular|Medium)/.test(clientSource),
|
|
395
|
+
false,
|
|
396
|
+
"the client must not probe host archive glyph names any more"
|
|
397
|
+
);
|
|
398
|
+
assert.ok(
|
|
399
|
+
/const MNEME_MARK_D = "M[^"]+"/.test(clientSource),
|
|
400
|
+
"the mark must carry its own path constant"
|
|
401
|
+
);
|
|
402
|
+
assert.ok(
|
|
403
|
+
clientSource.includes('viewBox: "0 0 16 16"') && clientSource.includes('stroke: "currentColor"'),
|
|
404
|
+
"the mark must follow the host icon spec (16 grid, currentColor) so it reads as first-party"
|
|
405
|
+
);
|
|
406
|
+
assert.ok(
|
|
407
|
+
/const renderMnemeMark = \(props\) => h\(MnemeMark, props\)/.test(clientSource),
|
|
408
|
+
"the mark must render through one shared helper"
|
|
409
|
+
);
|
|
410
|
+
for (const site of [
|
|
411
|
+
"renderMnemeMark({ size: 15 })", // 浮层标题栏
|
|
412
|
+
"renderMnemeMark({ size: wide ? 16 : 18 })", // 侧栏 trigger + portal 入口(面板行档 16 / 标题栏档 18)
|
|
413
|
+
"renderMnemeMark({ size })" // better-sidebar tab 图标
|
|
414
|
+
]) {
|
|
415
|
+
assert.ok(clientSource.includes(site), `every render site must go through the helper: ${site}`);
|
|
416
|
+
}
|
|
417
|
+
assert.equal(
|
|
418
|
+
/renderArchiveIcon/.test(clientSource),
|
|
419
|
+
false,
|
|
420
|
+
"the retired archive-glyph helper must be gone, not left as a second path"
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// 插件列表那颗图标由宿主从 package.json 的 `icon` 读:必须是**包内相对路径**、
|
|
425
|
+
// 落在 files 白名单里、且宿主只接受 SVG/PNG/JPEG/WebP ≤256KiB。
|
|
426
|
+
// 宿主 `iconOf()` 的硬规则:绝对路径或带 scheme 直接报错、realpath 后不许跑出
|
|
427
|
+
// manifest 目录、超 256KiB 报错;缺文件/坏文件时静默回落成通用插画(等于用户
|
|
428
|
+
// 看到的不是自家图标)。这里把可静态检的部分全锁住。
|
|
429
|
+
test("plugin-list icon: declared, in-package, and shipped in files", () => {
|
|
430
|
+
const icon = pkg.icon;
|
|
431
|
+
assert.equal(typeof icon, "string", "package.json must declare an icon");
|
|
432
|
+
assert.equal(icon, "assets/icon.svg", "the icon must be the self-drawn mark");
|
|
433
|
+
assert.ok(!/^([A-Za-z]:|[A-Za-z][A-Za-z\d+.-]*:)/.test(icon), "the icon must be a relative path (host rejects absolute/URL)");
|
|
434
|
+
assert.ok(pkg.files.includes("assets"), "the assets dir must ship, or the published package has no icon");
|
|
435
|
+
const file = join(root, icon);
|
|
436
|
+
assert.ok(existsSync(file), `the declared icon must exist: ${icon}`);
|
|
437
|
+
const bytes = readFileSync(file);
|
|
438
|
+
assert.ok(bytes.length > 0 && bytes.length <= 256 * 1024, "the icon must be a non-empty file under the host's 256 KiB cap");
|
|
439
|
+
const svg = bytes.toString("utf8");
|
|
440
|
+
assert.ok(svg.includes("<svg") && svg.includes("viewBox"), "the icon must be an SVG with a viewBox");
|
|
441
|
+
// <img> 不能继承 currentColor:文件必须自带颜色,否则会渲染成黑块/不可见。
|
|
442
|
+
assert.ok(/stroke="#[0-9a-fA-F]{6}"/.test(svg), "the standalone file must bake its own colour (<img> cannot inherit currentColor)");
|
|
443
|
+
// 文件与客户端内联路径必须是同一颗:两边形状漂移是最容易悄悄发生的事。
|
|
444
|
+
const inline = clientSource.match(/const MNEME_MARK_D = "([^"]+)"/)[1];
|
|
445
|
+
assert.ok(svg.includes(inline), "assets/icon.svg must use the same path as the inline mark (no shape drift)");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// 插件列表的标题/描述读包内的 `locale/<语言码>.json`:宿主 readPluginMeta 先解析
|
|
449
|
+
// `<specifier>/locale/en.json`,再遍历同目录的每个 *.json(文件名必须过语言码正则),
|
|
450
|
+
// 取 meta.title / meta.description;一个都读不到就回落到 package.json 的 name——
|
|
451
|
+
// 也就是裸包名 "@modusensus/dsh-mneme"(用户看到的现状)。
|
|
452
|
+
// ⚠️ 真正的门槛是**能否被 Node 解析**:宿主用 ESM resolver 取这个文件,而 exports
|
|
453
|
+
// 字段会把没列出的子路径**全部挡掉**,且 optionalResourcePath 对"读不到"是静默的
|
|
454
|
+
// (返回 undefined 继续走回落),不报错。所以少了 "./locale/*" 这一行,文件乖乖躺在
|
|
455
|
+
// 包里也永远不生效——这条回归极难靠肉眼发现,必须锁死。
|
|
456
|
+
test("plugin-list title/description are localized through locale/*.json", () => {
|
|
457
|
+
assert.ok(
|
|
458
|
+
Object.keys(pkg.exports).includes("./locale/*"),
|
|
459
|
+
"exports must expose ./locale/* or the host resolver silently never reads the dictionaries"
|
|
460
|
+
);
|
|
461
|
+
assert.ok(pkg.files.includes("locale"), "the locale dir must ship in files, or the published package loses it");
|
|
462
|
+
const dir = join(root, "locale");
|
|
463
|
+
assert.ok(existsSync(dir), "locale/ must exist");
|
|
464
|
+
const files = readdirSync(dir).filter((name) => name.endsWith(".json"));
|
|
465
|
+
assert.ok(files.length >= 2, "at least an English and a Chinese dictionary must ship");
|
|
466
|
+
for (const name of files) {
|
|
467
|
+
const language = name.slice(0, -5);
|
|
468
|
+
assert.match(language, /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/, `locale/${name} must be named after a language id`);
|
|
469
|
+
const parsed = JSON.parse(readFileSync(join(dir, name), "utf8"));
|
|
470
|
+
assert.equal(typeof parsed.meta, "object", `locale/${name} must carry a meta block`);
|
|
471
|
+
assert.ok(typeof parsed.meta.title === "string" && parsed.meta.title.trim() !== "", `locale/${name}: meta.title must be a non-empty string`);
|
|
472
|
+
assert.ok(
|
|
473
|
+
typeof parsed.meta.description === "string" && parsed.meta.description.trim() !== "",
|
|
474
|
+
`locale/${name}: meta.description must be a non-empty string`
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
const en = JSON.parse(readFileSync(join(dir, "en.json"), "utf8"));
|
|
478
|
+
assert.ok(en.meta.title !== pkg.name, "the English title must not be the bare package name (that is the bug this fixes)");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// 方案 A:查询收敛。状态页只做仪表盘(小页预览 + 服务端 total + 查看全部),
|
|
482
|
+
// 沉淀/归档的完整浏览走记忆库的 deposited/archived 筛选视图(chip 预置 +
|
|
483
|
+
// 状态页入口跳转),详情抽屉给归档记忆一个反向的「恢复」。
|
|
484
|
+
test("status dashboard links into deposited/archived library views", () => {
|
|
485
|
+
assert.ok(
|
|
486
|
+
clientSource.includes('"/api/dsh-mneme/list?deposited=only&limit=8&order=chrono"'),
|
|
487
|
+
"the workbench deposited preview must read the server-side deposited view"
|
|
488
|
+
);
|
|
489
|
+
assert.ok(
|
|
490
|
+
clientSource.includes('"/api/dsh-mneme/list?archived=only&limit=3&order=chrono"'),
|
|
491
|
+
"the workbench archived preview must cap at 3 rows backed by a server total"
|
|
492
|
+
);
|
|
493
|
+
assert.ok(
|
|
494
|
+
clientSource.includes("browseWithFilter"),
|
|
495
|
+
"the status page must jump into the library with preset filters"
|
|
496
|
+
);
|
|
497
|
+
assert.ok(
|
|
498
|
+
/view === "status" && h\(StatusPanel, \{ t, onBrowse: browseWithFilter \}\)/.test(clientSource),
|
|
499
|
+
"the status panel must receive the browse-jump callback"
|
|
500
|
+
);
|
|
501
|
+
assert.ok(
|
|
502
|
+
clientSource.includes('(depositedOnly ? "&deposited=only" : "")'),
|
|
503
|
+
"the library filterQS must carry the deposited chip"
|
|
504
|
+
);
|
|
505
|
+
assert.ok(
|
|
506
|
+
clientSource.includes('(archivedOnly ? "&archived=only" : "")'),
|
|
507
|
+
"the library filterQS must carry the archived chip"
|
|
508
|
+
);
|
|
509
|
+
assert.ok(
|
|
510
|
+
clientSource.includes('postUpdate({ archived: false }, { restored: true })'),
|
|
511
|
+
"the drawer must offer restore for archived memories"
|
|
512
|
+
);
|
|
513
|
+
assert.ok(
|
|
514
|
+
clientSource.includes('"memory.status.viewAll"'),
|
|
515
|
+
"the view-all entries must come from the dictionary"
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Every memory feature lives in the main-area library now: the explorer
|
|
520
|
+
// hosts three sub-views (browse / entities / settings) switched by tabs whose
|
|
521
|
+
// labels come from dedicated dictionary keys.
|
|
522
|
+
test("explorer hosts browse, entities and settings sub-views", () => {
|
|
523
|
+
for (const key of ["tabMemory", "tabEntities", "tabSettings"]) {
|
|
524
|
+
assert.ok(
|
|
525
|
+
clientSource.includes(`"memory.explorer.${key}"`),
|
|
526
|
+
`sub-view labels must come from memory.explorer.${key}`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
assert.ok(
|
|
530
|
+
clientSource.includes("h(EntityPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory })"),
|
|
531
|
+
"the entity panel must be embedded as a sub-view"
|
|
532
|
+
);
|
|
533
|
+
assert.ok(
|
|
534
|
+
clientSource.includes('h(SettingsContent, { t })'),
|
|
535
|
+
"the settings forms must be embedded as a sub-view"
|
|
536
|
+
);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// The graph panel jumps back into the browser: related-memory rows and the
|
|
540
|
+
// edge source button must land on the browse tab with the target selected.
|
|
541
|
+
test("graph jump lands on the selected memory in the browser", () => {
|
|
542
|
+
assert.ok(
|
|
543
|
+
/onClick: \(\) => onJumpMemory && onJumpMemory\(m\)/.test(clientSource),
|
|
544
|
+
"related-memory rows must jump via onJumpMemory(m)"
|
|
545
|
+
);
|
|
546
|
+
assert.ok(
|
|
547
|
+
clientSource.includes("onJumpMemory({ id: selected.edge.memory_id })"),
|
|
548
|
+
"the edge source button must jump to the origin memory by id"
|
|
549
|
+
);
|
|
550
|
+
assert.ok(
|
|
551
|
+
/const jumpToMemory = \(target\) => \{/.test(clientSource),
|
|
552
|
+
"jumpToMemory must reset filters and select the target"
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// "entity:" in the browser search is the graph entry grammar: it must offer
|
|
557
|
+
// a jump chip instead of filtering the list.
|
|
558
|
+
test("entity: search grammar offers a graph jump", () => {
|
|
559
|
+
assert.ok(
|
|
560
|
+
clientSource.includes('query.trim().startsWith("entity:")'),
|
|
561
|
+
"the entity: prefix must be recognized"
|
|
562
|
+
);
|
|
563
|
+
assert.ok(
|
|
564
|
+
/onClick: \(\) => openGraphFor\(entityQuery\)/.test(clientSource),
|
|
565
|
+
"the jump chip must switch to the graph sub-view"
|
|
566
|
+
);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// The explorer is a card-grid / timeline browse with a right-hand detail
|
|
570
|
+
// drawer: types with counts, a month→day time tree (timeline mode), and the
|
|
571
|
+
// drawer rendering the untruncated content.
|
|
572
|
+
test("explorer lays out types, timeline, and full-text detail", () => {
|
|
573
|
+
assert.ok(
|
|
574
|
+
clientSource.includes('className: "mneme-xmain"'),
|
|
575
|
+
"the three-column grid must be present"
|
|
576
|
+
);
|
|
577
|
+
assert.ok(
|
|
578
|
+
clientSource.includes('className: "mneme-dcontent"'),
|
|
579
|
+
"the detail drawer must render the full content"
|
|
580
|
+
);
|
|
581
|
+
assert.ok(
|
|
582
|
+
clientSource.includes('className: "mneme-cards"'),
|
|
583
|
+
"the card-grid view must exist alongside the timeline"
|
|
584
|
+
);
|
|
585
|
+
assert.ok(
|
|
586
|
+
/toLocaleDateString\(undefined, \{ year: "numeric", month: "long" \}\)/.test(clientSource),
|
|
587
|
+
"month groups must format via the host locale, not hardcoded strings"
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// The library page must read as a first-party view: the chrome resolves to
|
|
592
|
+
// the host's design tokens (layer-1 canvas, brand-blue active states) and
|
|
593
|
+
// the boxed-panel / pill-chip patterns of the drawer era must stay gone.
|
|
594
|
+
test("explorer chrome aligns with the host design system", () => {
|
|
595
|
+
assert.ok(
|
|
596
|
+
clientSource.includes("background:var(--dsw-alias-bg-layer-1)"),
|
|
597
|
+
"the page canvas must sit on the host bg-layer-1 token"
|
|
598
|
+
);
|
|
599
|
+
assert.ok(
|
|
600
|
+
/\.mneme-vtab\.mneme-active::after/.test(clientSource),
|
|
601
|
+
"active sub-tabs use the host underline treatment"
|
|
602
|
+
);
|
|
603
|
+
assert.ok(
|
|
604
|
+
clientSource.includes(".mneme-vtab.mneme-active{color:var(--dsw-alias-state-business-primary)}"),
|
|
605
|
+
"active sub-tab text must turn the host brand blue"
|
|
606
|
+
);
|
|
607
|
+
assert.equal(
|
|
608
|
+
/\.mneme-xmain\{[^}]*border:1px/.test(clientSource),
|
|
609
|
+
false,
|
|
610
|
+
"the three-column layout must not wrap itself in a boxed panel"
|
|
611
|
+
);
|
|
612
|
+
assert.equal(
|
|
613
|
+
clientSource.includes("border-radius:999px"),
|
|
614
|
+
false,
|
|
615
|
+
"pill chips belong to the drawer era and must stay gone"
|
|
616
|
+
);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// heat 阶段二:/list 仅在 heatEnabled=true 时下发逐条 heat,前端徽章三档
|
|
620
|
+
// 配色且自门控(字段缺省自动隐藏)——卡片页脚、抽屉 meta、状态分布卡共用
|
|
621
|
+
// 同一数据源,前端不感知开关状态。
|
|
622
|
+
test("heat badges render from the /list projection and self-hide when off", () => {
|
|
623
|
+
assert.ok(
|
|
624
|
+
/const HeatBadge = \(\{ value, size = 12 \}\) =>/.test(clientSource),
|
|
625
|
+
"the heat badge component must exist"
|
|
626
|
+
);
|
|
627
|
+
assert.ok(
|
|
628
|
+
clientSource.includes("flame:"),
|
|
629
|
+
"the Lucide flame glyph must back the badge"
|
|
630
|
+
);
|
|
631
|
+
assert.ok(
|
|
632
|
+
/h\(HeatBadge, \{ value: m\.heat \}\)/.test(clientSource),
|
|
633
|
+
"the card foot must render the heat badge"
|
|
634
|
+
);
|
|
635
|
+
assert.ok(
|
|
636
|
+
clientSource.includes('t("memory.explorer.heat")'),
|
|
637
|
+
"the drawer must show a localized heat meta row"
|
|
638
|
+
);
|
|
639
|
+
assert.ok(
|
|
640
|
+
clientSource.includes("function HeatStatusCard"),
|
|
641
|
+
"the status grid must include the heat distribution card"
|
|
642
|
+
);
|
|
643
|
+
assert.ok(
|
|
644
|
+
clientSource.includes('typeof items[0].heat !== "number"'),
|
|
645
|
+
"the distribution card must self-hide when /list omits heat"
|
|
646
|
+
);
|
|
647
|
+
assert.ok(
|
|
648
|
+
/\.mneme-heat--hot\{/.test(clientSource),
|
|
649
|
+
"the three-tier heat colors must be styled"
|
|
650
|
+
);
|
|
651
|
+
// order=heat 的前端补口:热度是运行时投影无存储序,SQL 排不了——页内
|
|
652
|
+
// 对已加载条目降序,时间树保持 chrono;chip 自门控(heat 缺省不出现)。
|
|
653
|
+
assert.ok(
|
|
654
|
+
/const heatAvailable = visible\.some\(\(m\) => typeof m\.heat === "number"\);/.test(clientSource),
|
|
655
|
+
"the heat-sort chip must self-gate on the /list heat field"
|
|
656
|
+
);
|
|
657
|
+
assert.ok(
|
|
658
|
+
/const gridItems = heatSort[\s\S]{0,80}\(b\.heat \?\? 0\) - \(a\.heat \?\? 0\)/.test(clientSource),
|
|
659
|
+
"the cards grid must sort loaded items by heat in-page"
|
|
660
|
+
);
|
|
661
|
+
assert.ok(
|
|
662
|
+
clientSource.includes("if (!heatSort) switchViewMode(\"cards\")"),
|
|
663
|
+
"toggling heat sort must land on the cards view (sort does not apply to the month tree)"
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// 巩固/睡眠模型路由 UI:下拉数据来自宿主侧已注册适配器(/llm-providers,
|
|
668
|
+
// 云端插件侧端点),「测试连通性」走 POST /test-model 真实最小调用。旧后端
|
|
669
|
+
// 端点 404 时必须回退纯文本输入——前端自门控,不挡旧版本。
|
|
670
|
+
test("consolidation/sleep model routing: provider dropdowns from /llm-providers, connectivity test via /test-model, graceful fallback", () => {
|
|
671
|
+
// 1. 端点探测与降级
|
|
672
|
+
assert.ok(
|
|
673
|
+
clientSource.includes('apiFetch("/api/dsh-mneme/llm-providers")'),
|
|
674
|
+
"the features card must probe GET /llm-providers for the route dropdowns"
|
|
675
|
+
);
|
|
676
|
+
assert.ok(
|
|
677
|
+
/setRoutes\(Array\.isArray\(j && j\.providers\) \? j\.providers : \[\]\)/.test(clientSource),
|
|
678
|
+
"the probe must only accept a {providers: []} shape"
|
|
679
|
+
);
|
|
680
|
+
assert.ok(
|
|
681
|
+
/Array\.isArray\(routes\)\s*\?\s*routeSelects\("dreamProvider", "dreamModel", dreamTest, setDreamTest, "dreamReasoningEffort"\)/.test(clientSource),
|
|
682
|
+
"dream routing must upgrade to dropdowns only when the probe succeeded (and pass its own effort key)"
|
|
683
|
+
);
|
|
684
|
+
assert.ok(
|
|
685
|
+
/:\s*h\(react\.Fragment, null, strRow\("dreamProvider"\), strRow\("dreamModel"\)\)/.test(clientSource),
|
|
686
|
+
"when /llm-providers is unavailable the plain text inputs must remain (old-backend fallback)"
|
|
687
|
+
);
|
|
688
|
+
// 2. 睡眠侧行:跟随 sleepModeEnabled 门控,与巩固共用数据源
|
|
689
|
+
assert.ok(
|
|
690
|
+
/const sleepSub = eff\.sleepModeEnabled && Array\.isArray\(routes\) && h\("div", \{ className: "mneme-featsub" \},\s*\n\s*routeSelects\("sleepProvider", "sleepModel", sleepTest, setSleepTest, "sleepReasoningEffort"\)/.test(clientSource),
|
|
691
|
+
"the sleep route row must gate on sleepModeEnabled and share the providers source"
|
|
692
|
+
);
|
|
693
|
+
// 2b. 睡眠路由的值必须进初始化草稿:FEATURE_STRINGS 漏键会导致重挂载后
|
|
694
|
+
// 下拉永远显示「跟随默认路由」(后端存了但前端读不回来)。
|
|
695
|
+
assert.ok(
|
|
696
|
+
/const FEATURE_STRINGS = \["dreamProvider", "dreamModel", "sleepProvider", "sleepModel",/.test(clientSource),
|
|
697
|
+
"sleepProvider/sleepModel must be restored into the draft on mount, not only written"
|
|
698
|
+
);
|
|
699
|
+
// 2c. 连通性测试状态按路由各持一份:共享单份会让点一个按钮两个组同时
|
|
700
|
+
// 显示「测试中/结果」且互相禁用。
|
|
701
|
+
assert.ok(
|
|
702
|
+
/const \[dreamTest, setDreamTest\] = useState\(null\);/.test(clientSource)
|
|
703
|
+
&& /const \[sleepTest, setSleepTest\] = useState\(null\);/.test(clientSource),
|
|
704
|
+
"the connectivity test state must be per-route, not shared between dream and sleep"
|
|
705
|
+
);
|
|
706
|
+
assert.ok(
|
|
707
|
+
/runModelTest\(curP, curM, setTestState, effort\)/.test(clientSource),
|
|
708
|
+
"each route's test button must target its own test state and pass the route's configured effort"
|
|
709
|
+
);
|
|
710
|
+
// 3. 连通性测试:真实最小调用 + 结果展示(成功/失败 + 耗时 + 报错原因)
|
|
711
|
+
assert.ok(
|
|
712
|
+
clientSource.includes('apiFetch("/api/dsh-mneme/test-model", {'),
|
|
713
|
+
"the connectivity test must POST /test-model"
|
|
714
|
+
);
|
|
715
|
+
assert.ok(
|
|
716
|
+
/body: JSON\.stringify\(\{\s*provider, model,\s*\.\.\.\(reasoningEffort && reasoningEffort !== "none" \? \{ reasoningEffort \} : \{\}\)\s*\}\)/.test(clientSource),
|
|
717
|
+
"the test payload must carry provider/model plus the configured reasoningEffort, omitted for 'none'/unset (issue #215, same convention as the backend)"
|
|
718
|
+
);
|
|
719
|
+
assert.ok(
|
|
720
|
+
/typeof j\.durationMs === "number" \? j\.durationMs : Date\.now\(\) - started/.test(clientSource),
|
|
721
|
+
"the result duration must prefer the backend's durationMs and fall back to client timing"
|
|
722
|
+
);
|
|
723
|
+
assert.ok(
|
|
724
|
+
/detail: \[j\.modelId, j\.reply \? "「" \+ j\.reply \+ "」" : ""\]\.filter\(Boolean\)\.join\(" · "\)/.test(clientSource),
|
|
725
|
+
"the success line must surface the model's actual reply (backend caps it at 100 chars)"
|
|
726
|
+
);
|
|
727
|
+
assert.ok(
|
|
728
|
+
/detail: \[j\.modelId, j\.error \|\| "HTTP " \+ res\.status\]\.filter\(Boolean\)\.join\(" · "\)/.test(clientSource),
|
|
729
|
+
"the failure line must carry the tested modelId plus the backend error (502/400 bodies)"
|
|
730
|
+
);
|
|
731
|
+
assert.ok(
|
|
732
|
+
clientSource.includes("memory.features.modelTestOk") && clientSource.includes("memory.features.modelTestFail"),
|
|
733
|
+
"the result line must render success/failure with the localized labels"
|
|
734
|
+
);
|
|
735
|
+
// 4. 下拉改动即提交(与 embedProvider 一致),当前值不在枚举时保留为额外选项
|
|
736
|
+
assert.ok(
|
|
737
|
+
/setStrs\(\(c\) => \(\{ \.\.\.c, \[key\]: v \}\)\);\s*\n\s*put\(\{ \[key\]: v \}\)/.test(clientSource),
|
|
738
|
+
"select changes must commit through the features PUT like the embed provider select"
|
|
739
|
+
);
|
|
740
|
+
assert.ok(
|
|
741
|
+
/curM && !mVals\.includes\(curM\) \? h\("option", \{ key: "current", value: curM \},\s*\n\s*mStale \? curM \+ t\("memory\.features\.routeStaleMark"\) : curM\) : null/.test(clientSource),
|
|
742
|
+
"a configured value missing from the provider's model list must survive as an extra option, marked when stale (issue #191)"
|
|
743
|
+
);
|
|
744
|
+
// 5. 双语 i18n 与样式
|
|
745
|
+
for (const key of ["routeFollowDefault", "modelTest", "modelTesting", "modelTestOk", "modelTestFail", "modelTestHint", "routeStaleMark", "routeStaleHint", "sleepModelHint"]) {
|
|
746
|
+
const occurrences = clientSource.split(`"memory.features.${key}"`).length - 1;
|
|
747
|
+
assert.ok(occurrences >= 2, `i18n key memory.features.${key} must exist in both zh and en (got ${occurrences})`);
|
|
748
|
+
}
|
|
749
|
+
assert.ok(
|
|
750
|
+
clientSource.includes(".mneme-routeselect{width:240px;max-width:60%}"),
|
|
751
|
+
"the route selects must share the string-input width budget"
|
|
752
|
+
);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
// issue #191:级联下拉刻意保留不在列表里的旧值(不丢配置),但此前与正常
|
|
756
|
+
// 选项无差别展示——切 Provider 后残留的中继风格 model id 照常保存,用户分
|
|
757
|
+
// 不清「改错了」还是「还没生效」。旧值必须显式标记 + 行级提示(改选或点连
|
|
758
|
+
// 通性测试当场验证 + 重启生效);适配器列表为空或 provider 未注册时前端无
|
|
759
|
+
// 从校验 model,一律不标记防误报。
|
|
760
|
+
test("stale route values are marked and explained at the row level (issue #191)", () => {
|
|
761
|
+
// 1. 旧值判定:仅在适配器列表非空时比对;model 只在 provider 已注册时校验
|
|
762
|
+
assert.ok(
|
|
763
|
+
/const hasProviders = entries\.length > 0;/.test(clientSource),
|
|
764
|
+
"stale checks must be gated on a non-empty provider registry (no false alarms)"
|
|
765
|
+
);
|
|
766
|
+
assert.ok(
|
|
767
|
+
/const pStale = hasProviders && !!curP && !entry;/.test(clientSource),
|
|
768
|
+
"a saved provider missing from /llm-providers must be flagged"
|
|
769
|
+
);
|
|
770
|
+
assert.ok(
|
|
771
|
+
/const mStale = hasProviders && !!entry && !!curM && !mVals\.includes\(curM\);/.test(clientSource),
|
|
772
|
+
"a saved model outside the selected provider's list must be flagged only when that provider is registered"
|
|
773
|
+
);
|
|
774
|
+
// 2. 标记落在选项文本上:下拉收起时也能看出旧值不在列表
|
|
775
|
+
assert.ok(
|
|
776
|
+
/pStale && p === curP \? p \+ t\("memory\.features\.routeStaleMark"\) : p/.test(clientSource),
|
|
777
|
+
"the preserved provider option must carry the stale mark"
|
|
778
|
+
);
|
|
779
|
+
// 3. 行级提示:⚠ 警示 + 改选/测试引导 + 重启生效,一条提示说完
|
|
780
|
+
assert.ok(
|
|
781
|
+
/\(pStale \|\| mStale\) && h\("div", \{ className: "mneme-featsubhint" \},\s*\n\s*"⚠ " \+ t\("memory\.features\.routeStaleHint"\)/.test(clientSource),
|
|
782
|
+
"the stale warning hint must render at the route row with the warning prefix"
|
|
783
|
+
);
|
|
784
|
+
// 3b. 旧值保留必须无条件:列表为空(端点刚起 / 零适配器)时也不得把已存
|
|
785
|
+
// provider 静默显示成「跟随默认路由」——只有标记带门,保留不带门。
|
|
786
|
+
assert.ok(
|
|
787
|
+
/curP && !entries\.some\(\(p\) => p\.provider === curP\) \? \[curP\] : \[\]/.test(clientSource),
|
|
788
|
+
"the preserved provider option must stay unconditional (empty list must not hide the saved value)"
|
|
789
|
+
);
|
|
790
|
+
// 3c. 英文提示不得把「不在列表」限定在 model 维度——provider 本身不在
|
|
791
|
+
// 列表时同样渲染此行(CodeRabbit #213 review)。
|
|
792
|
+
assert.ok(
|
|
793
|
+
!clientSource.includes("provider's model list"),
|
|
794
|
+
"the en stale hint must cover provider-absence too, not only the model case"
|
|
795
|
+
);
|
|
796
|
+
// 4. issue #215:三条路由各自透传自己的档位键——routeSelects 按路由行
|
|
797
|
+
// 传入键名、从生效配置取值;sleep 无回退重试,档位被拒时测试是事前唯一
|
|
798
|
+
// 的暴露口。
|
|
799
|
+
assert.ok(
|
|
800
|
+
/const routeSelects = \(providerKey, modelKey, testState, setTestState, effortKey\) => \{/.test(clientSource)
|
|
801
|
+
&& /const effort = effortKey \? \(eff\[effortKey\] \|\| ""\) : ""/.test(clientSource),
|
|
802
|
+
"routeSelects must take the route's effort key and read the configured value from effective settings"
|
|
803
|
+
);
|
|
804
|
+
assert.ok(
|
|
805
|
+
/routeSelects\("dreamProvider", "dreamModel", dreamTest, setDreamTest, "dreamReasoningEffort"\)/.test(clientSource)
|
|
806
|
+
&& /routeSelects\("sleepProvider", "sleepModel", sleepTest, setSleepTest, "sleepReasoningEffort"\)/.test(clientSource)
|
|
807
|
+
&& /routeSelects\("entityExtractionProvider", "entityExtractionModel", entityTest, setEntityTest, "entityExtractionReasoning"\)/.test(clientSource),
|
|
808
|
+
"each route row must pass its own reasoning effort key (dream/sleep/entityExtraction)"
|
|
809
|
+
);
|
|
810
|
+
// 4. 连通性测试按钮的说明与结果互斥(同一行槽位二选一,不堆叠)
|
|
811
|
+
assert.ok(
|
|
812
|
+
/\(testState && !testState\.running\s*\n\s*\? h\("div", \{ className: "mneme-featsubhint" \},/.test(clientSource)
|
|
813
|
+
&& clientSource.includes('t("memory.features.modelTestHint")'),
|
|
814
|
+
"the test row must show either the result or the standing explanation, never both"
|
|
815
|
+
);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// 帮助与反馈入口(v0.8):设置页底部三个反馈链接——GitHub 新建 issue 预填
|
|
819
|
+
// (环境信息)、邮件反馈、浏览已知问题。纯前端链接零后端成本;插件版本从
|
|
820
|
+
// /info 拉取(version 只读,不铺任何 token/凭据)。公开链接不得带个人邮箱。
|
|
821
|
+
test("settings feedback card: prefilled issue + mailto + browse, version from /info", () => {
|
|
822
|
+
// 1. 版本预填端点
|
|
823
|
+
assert.ok(
|
|
824
|
+
clientSource.includes('apiFetch("/api/dsh-mneme/info")'),
|
|
825
|
+
"the feedback card must fetch the plugin version from /info"
|
|
826
|
+
);
|
|
827
|
+
assert.ok(
|
|
828
|
+
clientSource.includes("setPkgVersion"),
|
|
829
|
+
"the fetched version must land in component state"
|
|
830
|
+
);
|
|
831
|
+
// 2. GitHub 新建 issue:issues/new?title=&body= 预填环境信息(当前仓库无模板)
|
|
832
|
+
assert.ok(
|
|
833
|
+
clientSource.includes("https://github.com/slow-stack/mneme/issues/new?title="),
|
|
834
|
+
"the issue link must prefill title+body on issues/new"
|
|
835
|
+
);
|
|
836
|
+
assert.ok(
|
|
837
|
+
clientSource.includes("**插件版本**") && clientSource.includes("**平台**"),
|
|
838
|
+
"the prefill body must carry plugin version and platform"
|
|
839
|
+
);
|
|
840
|
+
// 3. 邮件反馈:官方邮箱(对外不写个人邮箱),mailto 预填 subject+body
|
|
841
|
+
assert.ok(
|
|
842
|
+
clientSource.includes("mailto:work@modusensus.space?subject="),
|
|
843
|
+
"the mailto link must point at the public support address"
|
|
844
|
+
);
|
|
845
|
+
// 4. 浏览已知问题:跳仓库 issues 列表页(去重前置步骤)
|
|
846
|
+
assert.ok(
|
|
847
|
+
clientSource.includes('href: "https://github.com/slow-stack/mneme/issues"'),
|
|
848
|
+
"the browse link must open the repo issues list"
|
|
849
|
+
);
|
|
850
|
+
// 5. 双语 i18n
|
|
851
|
+
for (const key of ["feedback.title", "feedback.newIssue", "feedback.email", "feedback.browse", "feedback.hint"]) {
|
|
852
|
+
const occurrences = clientSource.split(`"memory.settings.${key}"`).length - 1;
|
|
853
|
+
assert.ok(occurrences >= 2, `i18n key memory.settings.${key} must exist in both zh and en (got ${occurrences})`);
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// UI 文案必须双语齐全(中英各一条)。用户明确要求所有 UI 文字都做 i18n,
|
|
858
|
+
// 这条守卫用「每个键恰好出现两次」兜住:出现奇数次就是漏译,>2 次说明重复。
|
|
859
|
+
test("i18n keys are bilingual: every key appears exactly twice (zh + en)", () => {
|
|
860
|
+
const counts = new Map();
|
|
861
|
+
for (const match of clientSource.matchAll(/^\s*"([a-zA-Z0-9_.]+)"\s*:\s*"/gm)) {
|
|
862
|
+
counts.set(match[1], (counts.get(match[1]) ?? 0) + 1);
|
|
863
|
+
}
|
|
864
|
+
const odd = [...counts.entries()].filter(([, n]) => n !== 2).map(([key, n]) => `${key}×${n}`);
|
|
865
|
+
assert.deepEqual(odd, [], `这些 i18n 键没有做到中英各一条:${odd.join(", ")}`);
|
|
866
|
+
assert.ok(counts.size > 100, `解析到的键太少(${counts.size}),正则可能没匹配到 i18n 表`);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// issue #135:向量状态卡必须区分「没配」与「配了却一条都没嵌上」。此前卡片只看
|
|
870
|
+
// ready,而未配置的 legacy embedder(ready 恒 true)一路显示成正常 provider。
|
|
871
|
+
test("vector status card surfaces the configured/degraded split", () => {
|
|
872
|
+
// 1. 读后端新字段,缺失(旧服务端)时退化成旧行为而不是误报未配置
|
|
873
|
+
assert.ok(
|
|
874
|
+
clientSource.includes('typeof j?.configured === "boolean" ? j.configured : null'),
|
|
875
|
+
"the card must read `configured` and tolerate older servers that omit it"
|
|
876
|
+
);
|
|
877
|
+
assert.ok(
|
|
878
|
+
clientSource.includes("j?.degraded === true"),
|
|
879
|
+
"the card must read `degraded`"
|
|
880
|
+
);
|
|
881
|
+
// 2. 三态互斥:未配置优先于初始化中,降级只在既非未配置也非初始化时成立
|
|
882
|
+
assert.ok(
|
|
883
|
+
clientSource.includes("const unconfigured = !off && state.configured === false;"),
|
|
884
|
+
"unconfigured must be its own state, not folded into ready"
|
|
885
|
+
);
|
|
886
|
+
assert.ok(
|
|
887
|
+
clientSource.includes("const pending = !off && !unconfigured && state.ready !== true;"),
|
|
888
|
+
"an unconfigured embedder must not be reported as initializing"
|
|
889
|
+
);
|
|
890
|
+
assert.ok(
|
|
891
|
+
clientSource.includes("const degraded = !off && !unconfigured && !pending && state.degraded === true;"),
|
|
892
|
+
"degraded must only apply to a configured, settled embedder"
|
|
893
|
+
);
|
|
894
|
+
// 3. 三个状态各自的标签/说明走 i18n,双语齐备
|
|
895
|
+
for (const key of ["vectorUnconfigured", "vectorUnconfiguredHint", "vectorDegradedHint"]) {
|
|
896
|
+
const occurrences = clientSource.split(`"memory.status.${key}"`).length - 1;
|
|
897
|
+
assert.ok(occurrences >= 2, `i18n key memory.status.${key} must exist in both zh and en (got ${occurrences})`);
|
|
898
|
+
}
|
|
899
|
+
assert.ok(
|
|
900
|
+
clientSource.includes('t("memory.status.vectorUnconfigured")'),
|
|
901
|
+
"the unconfigured state must render its own label"
|
|
902
|
+
);
|
|
903
|
+
assert.ok(
|
|
904
|
+
clientSource.includes('t("memory.status.vectorDegradedHint")'),
|
|
905
|
+
"the degraded state must render its own caption (0 of {m} indexed)"
|
|
906
|
+
);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
// v0.8.0 A4(issue #17):作用域隔离的面板面——设置卡新组(scopeEnabled /
|
|
910
|
+
// strictScope 开关)+ 记忆条目的 scope provenance 展示。i18n 键必须中英双语
|
|
911
|
+
// 齐全;徽章/详情行沿用 heat 徽章的「字段缺省即不渲染」模式,前端不感知开关。
|
|
912
|
+
/**
|
|
913
|
+
* A4(issue #17)面板面回归:作用域隔离的设置卡分组、十个 i18n 键的中英双语
|
|
914
|
+
* 齐备(沿用 occurrences ≥ 2 模式),以及 ScopeBadge 组件与三处接线(cards
|
|
915
|
+
* 脚部 / timeline 行 / 详情抽屉 dmeta)的真实存在性——只做源码断言,不挂
|
|
916
|
+
* React 渲染环境,与文件内其他 clientSource 断言同一风格。
|
|
917
|
+
*/
|
|
918
|
+
test("scope isolation ships panel switches and provenance surfaces (A4)", () => {
|
|
919
|
+
assert.ok(
|
|
920
|
+
/key: "group\.scope", items: \["scopeEnabled", "strictScope"\]/.test(clientSource),
|
|
921
|
+
"FEATURE_GROUPS must carry a dedicated scope group"
|
|
922
|
+
);
|
|
923
|
+
for (const key of [
|
|
924
|
+
"memory.features.group.scope",
|
|
925
|
+
"memory.features.scopeEnabled",
|
|
926
|
+
"memory.features.scopeEnabled.hint",
|
|
927
|
+
"memory.features.strictScope",
|
|
928
|
+
"memory.features.strictScope.hint",
|
|
929
|
+
"memory.explorer.scopeBadge",
|
|
930
|
+
"memory.explorer.detail.agentScope",
|
|
931
|
+
"memory.explorer.detail.workspaceScope",
|
|
932
|
+
"memory.explorer.detail.sensitivity",
|
|
933
|
+
"memory.explorer.detail.occurred"
|
|
934
|
+
]) {
|
|
935
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
936
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
937
|
+
}
|
|
938
|
+
assert.ok(/const ScopeBadge = \(\{ m, t \}\)/.test(clientSource), "ScopeBadge component must exist");
|
|
939
|
+
assert.ok(clientSource.includes("h(ScopeBadge, { m, t })"), "ScopeBadge must be wired into browse views");
|
|
940
|
+
assert.ok(clientSource.includes('t("memory.explorer.detail.agentScope")'), "drawer must render agent scope row");
|
|
941
|
+
assert.ok(clientSource.includes('t("memory.explorer.detail.occurred")'), "drawer must render occurred row");
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// v0.8.0 冲突集中处理:状态页的待确认冲突队列(reason + 双方内容对比 +
|
|
945
|
+
// 保留 A/B/仅标记动作),resolveConflictPending 的第一个前端出口。双语齐备。
|
|
946
|
+
test("conflict queue ships a central review surface on the status tab", () => {
|
|
947
|
+
for (const key of [
|
|
948
|
+
"memory.status.conflictQueue.reason",
|
|
949
|
+
"memory.status.conflictQueue.sideA",
|
|
950
|
+
"memory.status.conflictQueue.sideB",
|
|
951
|
+
"memory.status.conflictQueue.keepA",
|
|
952
|
+
"memory.status.conflictQueue.keepB",
|
|
953
|
+
"memory.status.conflictQueue.markReviewed",
|
|
954
|
+
"memory.status.conflictQueue.applyHint",
|
|
955
|
+
"memory.status.conflictQueue.missing",
|
|
956
|
+
"memory.status.conflictQueue.refresh"
|
|
957
|
+
]) {
|
|
958
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
959
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
960
|
+
}
|
|
961
|
+
assert.ok(/function ConflictsQueue\(\{ t \}\)/.test(clientSource), "ConflictsQueue component must exist");
|
|
962
|
+
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts"'), "queue must fetch the conflicts endpoint");
|
|
963
|
+
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts/resolve"'), "queue must call the resolve endpoint");
|
|
964
|
+
assert.ok(clientSource.includes("h(ConflictsQueue, { t })"), "status tab must render the queue");
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
// --- issue #178 批次一:无障碍(aria-live 网络 / 语义标题 / 焦点管理 / 图摘要) ---
|
|
968
|
+
|
|
969
|
+
// 面板瞬时反馈(保存/裁决/刷新/复制)不能只靠纯视觉 span:announce() 单例
|
|
970
|
+
// polite live region 是唯一的播报通道,所有接线点都必须走它。
|
|
971
|
+
test("a11y: announce() live region exists and every transient feedback routes through it", () => {
|
|
972
|
+
assert.ok(clientSource.includes('function announce(text)'), "module-level announce() must exist");
|
|
973
|
+
assert.ok(clientSource.includes('"aria-live"'), "live region must set aria-live");
|
|
974
|
+
assert.ok(clientSource.includes('"role", "status"'), "live region must carry role=status");
|
|
975
|
+
// 接线点:功能开关保存、画像、向量、token、模式、extapi 保存+复制、
|
|
976
|
+
// 记忆编辑保存、队列刷新、裁决完成 —— 至少 9 处。
|
|
977
|
+
const wired = (clientSource.match(/\bannounce\(t\(/g) || []).length;
|
|
978
|
+
assert.ok(wired >= 9, `announce() wiring points expected >= 9, got ${wired}`);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// 状态卡标题原来是 div,读屏无法按标题导航;统一 h3(CSS margin 归零防回归)。
|
|
982
|
+
test("a11y: status card titles are real headings", () => {
|
|
983
|
+
assert.ok(clientSource.includes('h("h3", { className: "mneme-xcolhead" }'), "StatusCard title must be an h3");
|
|
984
|
+
const css = clientSource.match(/\.mneme-xcolhead\{[^}]*\}/);
|
|
985
|
+
assert.ok(css && css[0].includes("margin:0"), "h3 default margin must be neutralized in CSS");
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// 弹层焦点管理:Tab 圈在面板内,关闭后焦点还给触发元素(两个 opener 都要标记)。
|
|
989
|
+
test("a11y: overlay focus trap and focus restore are wired", () => {
|
|
990
|
+
const openers = (clientSource.match(/"data-mneme-overlay-opener"/g) || []).length;
|
|
991
|
+
assert.equal(openers, 2, "both overlay entry buttons must carry the opener marker");
|
|
992
|
+
assert.ok(clientSource.includes('querySelectorAll(\'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])\')'),
|
|
993
|
+
"overlay must enumerate focusables for the Tab cycle");
|
|
994
|
+
assert.ok(/querySelector\('\[data-mneme-overlay-opener\]'\)/.test(clientSource), "closing must restore focus to the opener");
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
// 冲突裁决按钮「保留 A/B」在同文案多卡片下不可区分,必须带条目标题的 aria-label。
|
|
998
|
+
test("a11y: conflict keep buttons carry item-title aria-labels", () => {
|
|
999
|
+
const labels = (clientSource.match(/"aria-label": `\$\{t\("memory\.status\.conflictQueue\.keep/g) || []).length;
|
|
1000
|
+
assert.equal(labels, 2, "keepA/keepB buttons must both set aria-label");
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
// ego 关系图对读屏是一块不可达 SVG,必须 role=img + 计数摘要(键中英各一)。
|
|
1004
|
+
test("a11y: entity graph svg exposes a count summary", () => {
|
|
1005
|
+
assert.ok(clientSource.includes('role: "img"'), "graph svg must be role=img");
|
|
1006
|
+
for (const key of ["memory.graph.summary"]) {
|
|
1007
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1008
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// --- issue #177:冲突队列交互与视觉重做 ---
|
|
1013
|
+
|
|
1014
|
+
// 相似度进度条:只从「相似度 X / similarity X」锚定短语回收数字(防把年份当
|
|
1015
|
+
// 相似度),A/B 卡片带侧色类,冻结徽章替代预裁决阶段的「已归档」徽章。
|
|
1016
|
+
test("conflict queue: similarity bar, side colors and frozen badge", () => {
|
|
1017
|
+
assert.ok(clientSource.includes("similarityOf(it.reason)"), "reason must be parsed for similarity");
|
|
1018
|
+
assert.ok(clientSource.includes("/(?:相似度|similarity)\\s*([01](?:\\.\\d+)?)/i"), "parse must anchor on the similarity phrase");
|
|
1019
|
+
for (const key of ["memory.status.conflictQueue.similarity", "memory.status.conflictQueue.frozenBadge"]) {
|
|
1020
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1021
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1022
|
+
}
|
|
1023
|
+
assert.ok(clientSource.includes('"mneme-conflict-side--a"'), "side A must carry the A color class");
|
|
1024
|
+
assert.ok(clientSource.includes('"mneme-conflict-side--b"'), "side B must carry the B color class");
|
|
1025
|
+
assert.ok(!/s\.archived && h\("span", \{ className: "mneme-badge mneme-badge--archived"/.test(clientSource),
|
|
1026
|
+
"the pre-ruling archived badge must be replaced by the frozen badge");
|
|
1027
|
+
assert.ok(clientSource.includes("mneme-badge--frozen"), "frozen badge class must exist");
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// 词级 diff:LCS 对齐是纯函数,锁它的存在、上限护栏与渲染接线(无损性由
|
|
1031
|
+
// difftest 独立验证,此处只锁形状)。
|
|
1032
|
+
test("conflict queue: word diff is wired with size guards", () => {
|
|
1033
|
+
assert.ok(clientSource.includes("function wordDiff(aText, bText)"), "wordDiff must exist");
|
|
1034
|
+
assert.ok(clientSource.includes("mneme-conflict-mark--del"), "del highlight class must exist");
|
|
1035
|
+
assert.ok(clientSource.includes("mneme-conflict-mark--ins"), "ins highlight class must exist");
|
|
1036
|
+
assert.ok(clientSource.includes("n > 800 || m > 800"), "DP must bail on oversized inputs");
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// 空态教育卡:0 冲突时也渲染(原来整块 return null),解释冻结是什么。
|
|
1040
|
+
test("conflict queue: empty-state explainer card", () => {
|
|
1041
|
+
assert.ok(!/if \(!items \|\| items\.length === 0\) return null;/.test(clientSource),
|
|
1042
|
+
"empty queue must render the explainer instead of nothing");
|
|
1043
|
+
for (const key of ["memory.status.conflictQueue.emptyTitle", "memory.status.conflictQueue.emptyBody"]) {
|
|
1044
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1045
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
// 状态卡待处理高亮 + 侧边栏入口 badge(挂 portal 与回退两个按钮)。
|
|
1050
|
+
test("conflict queue: actionable status card and sidebar entry badge", () => {
|
|
1051
|
+
assert.ok(clientSource.includes('"mneme-conflict-jump"'), "pending>0 card must carry the highlight class");
|
|
1052
|
+
assert.ok(clientSource.includes("mneme-statuscard--actionable"), "highlight CSS must exist");
|
|
1053
|
+
assert.ok((clientSource.match(/h\(ConflictBadge, \{ pending \}\)/g) || []).length === 2,
|
|
1054
|
+
"badge must be wired into both entry buttons");
|
|
1055
|
+
assert.ok((clientSource.match(/= useConflictBadgeCount\(\);/g) || []).length === 2,
|
|
1056
|
+
"both entry components must subscribe to the shared badge count");
|
|
1057
|
+
assert.ok(clientSource.includes('"/api/dsh-mneme/dream-status"'), "badge must reuse the dream-status endpoint");
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
// 用户反馈「裁决完红点消失得很慢」的根因:裁决成功时冲突队列广播
|
|
1061
|
+
// mneme:conflicts-changed,但只有状态卡订阅了它,侧边栏红点漏订——红点于是只能
|
|
1062
|
+
// 等 60s 兜底轮询。本用例锁三类回归:(a) 红点订阅该广播并立即重取;(b) 计数走
|
|
1063
|
+
// 模块级单例(原先 portal 与 footer 回退各起一个 60s 轮询 → 两份请求且互不同步);
|
|
1064
|
+
// (c) 兜底轮询保留(后台 autoDream 自己产生/消解冲突时没人广播,靠它浮现)。
|
|
1065
|
+
test("sidebar badge clears on the conflicts-changed broadcast, not on the next poll", () => {
|
|
1066
|
+
assert.ok(
|
|
1067
|
+
/window\.addEventListener\("mneme:conflicts-changed", onChanged\)/.test(clientSource),
|
|
1068
|
+
"the badge must subscribe to the broadcast the conflict queue already emits"
|
|
1069
|
+
);
|
|
1070
|
+
assert.ok(
|
|
1071
|
+
clientSource.includes("const conflictBadgeCount = (() => {"),
|
|
1072
|
+
"the count must live in one module-level store shared by both entry components"
|
|
1073
|
+
);
|
|
1074
|
+
assert.ok(
|
|
1075
|
+
clientSource.includes("if (listeners.size === 1) {"),
|
|
1076
|
+
"exactly one poll timer per store, refcounted by its subscribers"
|
|
1077
|
+
);
|
|
1078
|
+
assert.ok(
|
|
1079
|
+
/setInterval\(refresh, 60_000\)/.test(clientSource),
|
|
1080
|
+
"the 60s fallback poll must stay: background autoDream resolves conflicts without broadcasting"
|
|
1081
|
+
);
|
|
1082
|
+
assert.ok(
|
|
1083
|
+
clientSource.includes("if (inflight) return inflight;"),
|
|
1084
|
+
"concurrent refreshes (broadcast + open + first subscriber) must collapse into one request"
|
|
1085
|
+
);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
// 上一条锁的是"接线在不在",这条锁"接线真的按预期跑"。把源码里那段 store 抠出来
|
|
1089
|
+
// 用受控的 apiFetch / setInterval 真跑一遍——用户抱怨的正是时序(慢),而
|
|
1090
|
+
// "存在某个字符串" 证明不了任何时序。回归表现:第二个订阅者又开一条轮询、
|
|
1091
|
+
// 广播后不立刻反映、并发 refresh 打两次请求、订阅者走光后定时器还在跑。
|
|
1092
|
+
test("badge store: one poll for both entries, immediate publish after a refresh", async () => {
|
|
1093
|
+
const src = clientSource.match(/const conflictBadgeCount = \(\(\) => \{[\s\S]*?\}\)\(\);/);
|
|
1094
|
+
assert.ok(src, "the badge store source block must be extractable verbatim");
|
|
1095
|
+
const calls = [];
|
|
1096
|
+
const resolvers = [];
|
|
1097
|
+
const timers = [];
|
|
1098
|
+
let cleared = 0;
|
|
1099
|
+
const makeStore = new Function("apiFetch", "setInterval", "clearInterval",
|
|
1100
|
+
`${src[0]}\nreturn conflictBadgeCount;`);
|
|
1101
|
+
const store = makeStore(
|
|
1102
|
+
(path) => {
|
|
1103
|
+
calls.push(path);
|
|
1104
|
+
return new Promise((resolve) => { resolvers.push(resolve); });
|
|
1105
|
+
},
|
|
1106
|
+
(fn, ms) => { timers.push({ fn, ms }); return timers.length; },
|
|
1107
|
+
() => { cleared += 1; }
|
|
1108
|
+
);
|
|
1109
|
+
const settle = async (payload) => {
|
|
1110
|
+
const batch = resolvers.splice(0, resolvers.length);
|
|
1111
|
+
batch.forEach((r) => r({ ok: true, json: () => Promise.resolve(payload) }));
|
|
1112
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
const seen = [];
|
|
1116
|
+
const un1 = store.subscribe((v) => seen.push(v));
|
|
1117
|
+
assert.equal(calls.length, 1, "the first subscriber must trigger exactly one fetch");
|
|
1118
|
+
assert.equal(timers.length, 1, "and exactly one timer");
|
|
1119
|
+
assert.equal(timers[0].ms, 60_000, "the fallback cadence stays 60s");
|
|
1120
|
+
const un2 = store.subscribe((v) => seen.push(v));
|
|
1121
|
+
assert.equal(calls.length, 1, "a second subscriber must reuse the poll, not start another");
|
|
1122
|
+
assert.equal(timers.length, 1, "still one timer for both entry buttons");
|
|
1123
|
+
|
|
1124
|
+
await settle({ pendingConflicts: 3 });
|
|
1125
|
+
assert.deepEqual(seen, [0, 0, 3, 3], "both subscribers must see the same published value");
|
|
1126
|
+
|
|
1127
|
+
// 广播路径:冲突队列裁决成功后 dispatch mneme:conflicts-changed → hook 调 refresh()
|
|
1128
|
+
store.refresh();
|
|
1129
|
+
store.refresh();
|
|
1130
|
+
assert.equal(calls.length, 2, "concurrent refreshes collapse into a single in-flight request");
|
|
1131
|
+
await settle({ pendingConflicts: 0 });
|
|
1132
|
+
assert.equal(store.get(), 0, "the count must drop as soon as the refreshed value lands");
|
|
1133
|
+
assert.equal(seen[seen.length - 1], 0, "and both subscribers must be notified");
|
|
1134
|
+
|
|
1135
|
+
store.refresh();
|
|
1136
|
+
await settle({ pendingConflicts: 0 });
|
|
1137
|
+
assert.deepEqual(seen, [0, 0, 3, 3, 0, 0], "an unchanged value must not re-render subscribers");
|
|
1138
|
+
|
|
1139
|
+
un1();
|
|
1140
|
+
assert.equal(cleared, 0, "the timer must survive while one subscriber remains");
|
|
1141
|
+
un2();
|
|
1142
|
+
assert.equal(cleared, 1, "the timer must stop when the last subscriber leaves");
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
// --- issue #179:注入预览(状态页卡片 + /inject-preview 端点透传的旁路快照) ---
|
|
1146
|
+
test("a11y+preview: inject preview card is wired on the status tab", () => {
|
|
1147
|
+
assert.ok(clientSource.includes('"/api/dsh-mneme/inject-preview"'), "card must fetch the preview endpoint");
|
|
1148
|
+
assert.ok(clientSource.includes("h(InjectPreviewCard, { t })"), "status grid must render the preview card");
|
|
1149
|
+
for (const key of [
|
|
1150
|
+
"memory.status.injectPreview",
|
|
1151
|
+
"memory.status.injectPreviewNone",
|
|
1152
|
+
"memory.status.injectPreview.chars",
|
|
1153
|
+
"memory.status.injectPreview.empty"
|
|
1154
|
+
]) {
|
|
1155
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1156
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
// --- issue #386 第 3 条:Markdown 镜像健康卡 ---
|
|
1160
|
+
// 镜像写失败(磁盘满/权限)在记忆库侧完全静默,状态页这张卡是最早的可见信号。
|
|
1161
|
+
// 关键不变量是 fail-closed:fetch 失败或 status 不是 ok/degraded 时必须渲染
|
|
1162
|
+
// 「未知」——这里锁死派生表达式,防止未来被「简化」成把未知当正常显示。
|
|
1163
|
+
test("mirror health: card wired with fail-closed unknown rendering", () => {
|
|
1164
|
+
assert.ok(clientSource.includes('"/api/dsh-mneme/health"'), "mirror card must fetch the health endpoint");
|
|
1165
|
+
assert.ok(clientSource.includes("h(MirrorHealthCard, { t })"), "overview grid must render the mirror card");
|
|
1166
|
+
const status = clientSource.match(/const status = m && \(m\.status === "ok" \|\| m\.status === "degraded"\) \? m\.status : "unknown";/);
|
|
1167
|
+
assert.ok(status, "non-ok/non-degraded status (incl. fetch failure) must collapse to unknown, never ok");
|
|
1168
|
+
assert.ok(clientSource.includes('m.last_error === "no-space"'), "last_error no-space must be mapped to human text");
|
|
1169
|
+
assert.ok(clientSource.includes('m.last_error === "permission"'), "last_error permission must be mapped to human text");
|
|
1170
|
+
for (const key of [
|
|
1171
|
+
"memory.status.mirror.ok",
|
|
1172
|
+
"memory.status.mirror.degraded",
|
|
1173
|
+
"memory.status.mirror.unknown",
|
|
1174
|
+
"memory.status.mirror.err.noSpace",
|
|
1175
|
+
"memory.status.mirror.err.permission",
|
|
1176
|
+
"memory.status.mirror.err.syncFailed"
|
|
1177
|
+
]) {
|
|
1178
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1179
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
// --- issue #386 第 1 条:独立服务只读卡片 ---
|
|
1184
|
+
// 卡片展示 dsh-mneme-serve 示例命令并支持复制。关键锁:命令不能写成
|
|
1185
|
+
// `npx dsh-mneme-serve …` —— bin 名与包名不同,裸 npx 会尝试安装一个不存在
|
|
1186
|
+
// 的 npm 包「dsh-mneme-serve」,用户复制出去必然报错。
|
|
1187
|
+
test("serve card: sample command is npx-installable and card is wired", () => {
|
|
1188
|
+
assert.ok(clientSource.includes('"memory.settings.serve.title"'), "serve card must exist in the settings view");
|
|
1189
|
+
assert.ok(clientSource.includes("copyServeCmd"), "serve command must have a copy affordance");
|
|
1190
|
+
assert.ok(clientSource.includes("npx -p @modusensus/dsh-mneme dsh-mneme-serve"), "sample command must name the real package via -p");
|
|
1191
|
+
assert.equal(clientSource.includes("npx dsh-mneme-serve"), false, "bare `npx dsh-mneme-serve` would install a nonexistent package");
|
|
1192
|
+
for (const key of [
|
|
1193
|
+
"memory.settings.serve.title",
|
|
1194
|
+
"memory.settings.serve.cmd",
|
|
1195
|
+
"memory.settings.serve.hint"
|
|
1196
|
+
]) {
|
|
1197
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1198
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
// --- issue #386 第 2 条:MCP 接入卡 ---
|
|
1203
|
+
// 可发现性缺口:dsh-mneme-mcp 此前面板一个字都没有。锁三件事:片段必须带
|
|
1204
|
+
// DSH_MNEME_TOKEN(MCP 数据面走外部访问 API 的 Bearer,不是面板本地
|
|
1205
|
+
// localStorage 那把)、复制入口存在、i18n 中英成对。
|
|
1206
|
+
test("mcp card: mount snippet is copyable and token-keyed to the external API", () => {
|
|
1207
|
+
assert.ok(clientSource.includes('"memory.settings.mcp.title"'), "mcp card must exist in the settings view");
|
|
1208
|
+
assert.ok(clientSource.includes("copyMcpSnippet"), "mcp snippet must have a copy affordance");
|
|
1209
|
+
assert.ok(clientSource.includes("DSH_MNEME_TOKEN"), "snippet must key the external-API Bearer token");
|
|
1210
|
+
assert.ok(clientSource.includes("mcpServers"), "snippet must be an .mcp.json fragment");
|
|
1211
|
+
for (const key of [
|
|
1212
|
+
"memory.settings.mcp.title",
|
|
1213
|
+
"memory.settings.mcp.snippet",
|
|
1214
|
+
"memory.settings.mcp.hint"
|
|
1215
|
+
]) {
|
|
1216
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1217
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
// 面板 bundle 在本文件里只被当**文本**读(上面的断言全是正则/字符串包含),
|
|
1221
|
+
// 而仓库的 CI 里没有任何一步**解析**它:于是重复声明这类语法错误能一路绿灯进
|
|
1222
|
+
// 主干,后果却是整个面板加载失败(__ModuleLoader__ 拿到的模块一执行就抛
|
|
1223
|
+
// SyntaxError)。lib/client.js 无 src 对应物、不参与 sync,也就没有别的闸门
|
|
1224
|
+
// 覆盖它——这里补一道解析闸(PR #320 rebase 时真实踩到:与上游新增的
|
|
1225
|
+
// summarizeSub 撞名,node --check 报 "Identifier 'summarizeSub' has already
|
|
1226
|
+
// been declared",而当时 CI 全绿)。
|
|
1227
|
+
test("client bundle parses: 面板产物必须是合法 JS(无重复声明等语法错误)", () => {
|
|
1228
|
+
const result = spawnSync(process.execPath, ["--check", join(root, "lib/client.js")], { encoding: "utf8" });
|
|
1229
|
+
assert.equal(result.status, 0, `lib/client.js 解析失败:\n${result.stderr || result.stdout}`);
|
|
1230
|
+
});
|