@modusensus/dsh-mneme 0.8.2 → 0.8.4
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 +718 -633
- package/bin/cli.mjs +0 -0
- package/bin/dsh-mneme-mcp.mjs +502 -0
- package/cordis.patch.yml +21 -21
- package/lib/api-standalone.js +495 -316
- package/lib/api.js +1382 -1316
- package/lib/bootstrap.js +211 -0
- package/lib/client.js +4475 -4362
- package/lib/commands.js +95 -95
- package/lib/config.js +533 -500
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +658 -658
- package/lib/dream/narratives.js +44 -0
- package/lib/dream/sleep.js +700 -700
- package/lib/dream.js +1319 -1216
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +249 -249
- package/lib/heat.js +142 -136
- package/lib/hot-memory.js +46 -46
- package/lib/index.js +515 -513
- package/lib/inject.js +299 -253
- package/lib/lang.js +202 -179
- package/lib/local-embedder.js +320 -308
- package/lib/mirror.js +198 -198
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +103 -0
- package/lib/reranker.js +269 -260
- 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 +235 -235
- package/lib/runtime/loader.js +249 -249
- package/lib/runtime/model-fetch.js +361 -0
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +191 -191
- package/lib/scope.js +118 -118
- package/lib/search/adaptive.js +22 -22
- package/lib/search/bm25.js +96 -96
- package/lib/service.js +2171 -2071
- package/lib/settings.js +430 -419
- package/lib/store.js +2448 -2367
- package/lib/summarize.js +575 -449
- package/lib/tools.js +574 -574
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/package.json +109 -108
- package/runtime-manifest.json +596 -596
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +199 -199
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +217 -217
- 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 +61 -61
- package/src/api-standalone.js +495 -316
- package/src/api.js +1382 -1316
- package/src/bootstrap.js +211 -0
- package/src/commands.js +95 -95
- package/src/config.js +533 -500
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +658 -658
- package/src/dream/narratives.js +44 -0
- package/src/dream/sleep.js +700 -700
- package/src/dream.js +1319 -1216
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +249 -249
- package/src/heat.js +142 -136
- package/src/hot-memory.js +46 -46
- package/src/index.js +515 -513
- package/src/inject.js +299 -253
- package/src/lang.js +202 -179
- package/src/local-embedder.js +320 -308
- package/src/mirror.js +198 -198
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +103 -0
- package/src/reranker.js +269 -260
- 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 +235 -235
- package/src/runtime/loader.js +249 -249
- package/src/runtime/model-fetch.js +361 -0
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +191 -191
- package/src/scope.js +118 -118
- package/src/search/adaptive.js +22 -22
- package/src/search/bm25.js +96 -96
- package/src/service.js +2171 -2071
- package/src/settings.js +430 -419
- package/src/store.js +2448 -2367
- package/src/summarize.js +575 -449
- package/src/tools.js +574 -574
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/test/api-routes-gaps.test.js +218 -218
- package/test/api.test.js +1578 -1481
- package/test/audit.test.js +448 -448
- package/test/benchmark.test.js +94 -94
- package/test/bootstrap.test.js +220 -0
- package/test/client.test.js +799 -736
- 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/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.test.js +1257 -1254
- package/test/embedding-ready.test.js +196 -196
- package/test/entities.test.js +547 -547
- package/test/entity-recall.test.js +179 -0
- package/test/epistemic.test.js +298 -298
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-api.test.js +175 -175
- package/test/heat.test.js +175 -148
- 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/hot-memory.test.js +145 -145
- package/test/inject-content-cap.test.js +89 -0
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +61 -0
- package/test/inject-rotation.test.js +161 -0
- package/test/inject.test.js +249 -249
- package/test/lang.test.js +87 -87
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +309 -309
- package/test/llm-message-source.test.js +177 -177
- package/test/local-embedder.test.js +249 -229
- package/test/mcp-stdio.test.js +385 -0
- 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.test.js +249 -249
- package/test/narrative-bars.test.js +162 -0
- package/test/peer-blockers.test.js +193 -193
- package/test/policy-epoch.test.js +259 -259
- package/test/quality-filter.test.js +186 -186
- package/test/reasoning-effort.test.js +746 -746
- package/test/recall-evals.test.js +235 -235
- package/test/recall-layer.test.js +326 -326
- package/test/recall-stats.test.js +117 -0
- 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 -0
- 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 +237 -237
- package/test/runtime-manifest.test.js +158 -158
- package/test/runtime-model-fetch.test.js +348 -0
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +214 -214
- package/test/scope-candidates.test.js +239 -239
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +231 -231
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- 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 -125
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +373 -373
- package/test/standalone-api.test.js +544 -359
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +383 -383
- package/test/stress.test.js +209 -209
- package/test/summarize.test.js +884 -575
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +341 -341
- 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/client.test.js
CHANGED
|
@@ -1,737 +1,800 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
6
|
-
|
|
7
|
-
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
-
const clientSource = readFileSync(join(root, "lib/client.js"), "utf8");
|
|
9
|
-
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
10
|
-
|
|
11
|
-
// The Web client bundle registers itself via __ModuleLoader__.load. DSH
|
|
12
|
-
// resolves the bundle by plugin package name, so the registered id must match
|
|
13
|
-
// package.json `name` exactly (a mismatch surfaces as "loaded without
|
|
14
|
-
// registering '@modusensus/dsh-mneme'" in the DSH client).
|
|
15
|
-
test("client bundle registers under the package name", () => {
|
|
16
|
-
const match = clientSource.match(/__ModuleLoader__\.load\(\{\s*id:\s*"([^"]+)"/);
|
|
17
|
-
assert.ok(match, "client bundle must call __ModuleLoader__.load with an id");
|
|
18
|
-
assert.equal(match[1], pkg.name, "registered id must equal package.json name");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
// client.js is hand-authored under lib/ only (no src/ counterpart), so the
|
|
22
|
-
// src->lib sync must never prune it.
|
|
23
|
-
test("client bundle is lib-only with no src counterpart", () => {
|
|
24
|
-
assert.equal(existsSync(join(root, "src/client.js")), false, "src/ must not contain client.js");
|
|
25
|
-
assert.equal(existsSync(join(root, "lib/client.js")), true, "lib/client.js must exist");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
// The memory entry lives at the sidebar foot, not in the settings modal: the
|
|
29
|
-
// migration must register into `sidebar.footer.action` (the list slot the
|
|
30
|
-
// sidebar shell renders beside Settings) and must not keep a `settings.section`
|
|
31
|
-
// registration, or the entry would appear twice under different hosts.
|
|
32
|
-
test("memory entry registers into the sidebar foot slot", () => {
|
|
33
|
-
assert.ok(
|
|
34
|
-
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
35
|
-
"client must inject into sidebar.footer.action"
|
|
36
|
-
);
|
|
37
|
-
assert.equal(
|
|
38
|
-
clientSource.includes('"settings.section"'),
|
|
39
|
-
false,
|
|
40
|
-
"the old settings.section registration must be gone"
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
// The tab era is over: the in-conversation memory tab fought the floating
|
|
45
|
-
// composer and squeezed the session layout, so the library no longer
|
|
46
|
-
// registers as a conversation view. The sidebar entry opens the centered
|
|
47
|
-
// sheet directly — from any state, including the new-chat hero screen. A
|
|
48
|
-
// dialog role stays banned either way.
|
|
49
|
-
test("sidebar entry opens the sheet; conversation-view tab stays retired", () => {
|
|
50
|
-
assert.equal(
|
|
51
|
-
clientSource.includes("role: \"dialog\""),
|
|
52
|
-
false,
|
|
53
|
-
"no dialog surface should remain"
|
|
54
|
-
);
|
|
55
|
-
assert.ok(
|
|
56
|
-
/function openLibrary\(\) \{\s*setOverlayOpen\(true\);\s*\}/.test(clientSource),
|
|
57
|
-
"the entry must open the overlay directly (no tab activation)"
|
|
58
|
-
);
|
|
59
|
-
assert.equal(
|
|
60
|
-
clientSource.includes('ctx.slots.inject("conversation.view"'),
|
|
61
|
-
false,
|
|
62
|
-
"the conversation.view tab must stay retired"
|
|
63
|
-
);
|
|
64
|
-
assert.equal(
|
|
65
|
-
clientSource.includes("activateExplorerTab"),
|
|
66
|
-
false,
|
|
67
|
-
"the tab-click activation machinery must be gone"
|
|
68
|
-
);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// The sheet is deliberately NOT fullscreen: a dimmed backdrop plus a rounded
|
|
72
|
-
// panel capped at 1180×880 keeps the conversation visible behind it. It
|
|
73
|
-
// reuses the full MemoryExplorer and closes on Esc, the close button, or a
|
|
74
|
-
// backdrop click.
|
|
75
|
-
test("memory library opens as a centered sheet with backdrop", () => {
|
|
76
|
-
assert.ok(
|
|
77
|
-
clientSource.includes(".mneme-backdrop{position:fixed;inset:0"),
|
|
78
|
-
"a dimmed backdrop must sit behind the sheet"
|
|
79
|
-
);
|
|
80
|
-
assert.ok(
|
|
81
|
-
/\.mneme-overlay\{position:fixed;z-index:1000;left:50%;top:50%;transform:translate\(-50%,-50%\)/.test(clientSource),
|
|
82
|
-
"the sheet must be centered, not viewport-filling"
|
|
83
|
-
);
|
|
84
|
-
assert.ok(
|
|
85
|
-
clientSource.includes("width:min(1240px,calc(100vw - 88px))"),
|
|
86
|
-
"the sheet must not occupy the full width and keeps friendly margins"
|
|
87
|
-
);
|
|
88
|
-
assert.ok(
|
|
89
|
-
clientSource.includes('h(MemoryExplorer, { t })'),
|
|
90
|
-
"the sheet renders the same MemoryExplorer component"
|
|
91
|
-
);
|
|
92
|
-
assert.ok(
|
|
93
|
-
clientSource.includes('e.key === "Escape"'),
|
|
94
|
-
"Esc must close the sheet"
|
|
95
|
-
);
|
|
96
|
-
assert.ok(
|
|
97
|
-
clientSource.includes('className: "mneme-backdrop"'),
|
|
98
|
-
"the backdrop is a clickable close surface"
|
|
99
|
-
);
|
|
100
|
-
assert.ok(
|
|
101
|
-
clientSource.includes('"memory.overlay.close"'),
|
|
102
|
-
"the close affordance keeps its localized label"
|
|
103
|
-
);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
// The sidebar hands each entry only its column state: a wide row (icon +
|
|
107
|
-
// label) when expanded, a bare rail icon when collapsed — for both the
|
|
108
|
-
// portalled top button and the footer fallback.
|
|
109
|
-
test("trigger renders a wide row or a rail icon from the wide flag", () => {
|
|
110
|
-
assert.ok(
|
|
111
|
-
/wide \? "mneme-trigger" : "mneme-trigger mneme-rail"/.test(clientSource),
|
|
112
|
-
"trigger must branch on the wide flag"
|
|
113
|
-
);
|
|
114
|
-
assert.ok(
|
|
115
|
-
/wide && h\("span", \{ className: "mneme-trigger-label" \}/.test(clientSource),
|
|
116
|
-
"the label span must render only when wide"
|
|
117
|
-
);
|
|
118
|
-
assert.ok(
|
|
119
|
-
/size: wide \? 15 : 18/.test(clientSource),
|
|
120
|
-
"the portalled icon must follow the native rail sizing convention"
|
|
121
|
-
);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// The entry lives at the top of the plugin-entry group: the real button is
|
|
125
|
-
// portalled right after the host's New-Session row (above the entries other
|
|
126
|
-
// plugins inject there), while the sidebar.footer.action registration remains
|
|
127
|
-
// as the React anchor and the in-place fallback when the host markup changes.
|
|
128
|
-
test("sidebar entry portals above the workspaces region with footer fallback", () => {
|
|
129
|
-
assert.ok(
|
|
130
|
-
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
131
|
-
"the footer slot registration must stay as anchor + fallback"
|
|
132
|
-
);
|
|
133
|
-
assert.ok(
|
|
134
|
-
clientSource.includes("SidebarTopEntry"),
|
|
135
|
-
"the portalled top entry must exist"
|
|
136
|
-
);
|
|
137
|
-
assert.ok(
|
|
138
|
-
clientSource.includes(`'[class*="regionArea"]'`),
|
|
139
|
-
"the portal must anchor at the host's regionArea container"
|
|
140
|
-
);
|
|
141
|
-
assert.ok(
|
|
142
|
-
clientSource.includes("insertBefore(created, target)"),
|
|
143
|
-
"the entry must be placed through the idempotent place() helper"
|
|
144
|
-
);
|
|
145
|
-
assert.ok(
|
|
146
|
-
clientSource.includes("if (!host) return fallback;"),
|
|
147
|
-
"the portal entry persists across collapse (no footer jump); footer fallback only covers portal failure"
|
|
148
|
-
);
|
|
149
|
-
assert.ok(
|
|
150
|
-
clientSource.includes('className: `${nativeCls} mneme-topentry-native`'),
|
|
151
|
-
"the entry must reuse the host New-Session button class for native geometry alignment"
|
|
152
|
-
);
|
|
153
|
-
assert.ok(
|
|
154
|
-
/querySelector\('\[class\*="newSession"\]'\)/.test(clientSource),
|
|
155
|
-
"the native class must be read from the live New-Session button, not hardcoded"
|
|
156
|
-
);
|
|
157
|
-
assert.ok(
|
|
158
|
-
clientSource.includes('"memory.view.label"'),
|
|
159
|
-
"the sheet aria-label must come from the memory.view.label dictionary key"
|
|
160
|
-
);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// Issue #130: ecosystem plugins (task board, skill explorer, …) inject their
|
|
164
|
-
// own entries right after the New-Session row, and late inserters land above
|
|
165
|
-
// earlier ones — a fixed "before regionArea" slot degrades into the tail of
|
|
166
|
-
// that group, detaching the memory button from its familiar top spot. The
|
|
167
|
-
// entry therefore (a) resolves the same anchor as the ecosystem's shared
|
|
168
|
-
// sidebar-entry-core (logoRow row, legacy direct-child button), (b) re-asserts
|
|
169
|
-
// its slot from the existing observer, and (c) stays idempotent — the anchor
|
|
170
|
-
// check short-circuits before any DOM write, so observers never ping-pong.
|
|
171
|
-
test("entry re-asserts the New-Session-adjacent slot against ecosystem entries", () => {
|
|
172
|
-
assert.ok(
|
|
173
|
-
clientSource.includes(`btn.closest('[class*="logoRow"]')`),
|
|
174
|
-
"the anchor must resolve the New-Session logo row like the ecosystem core"
|
|
175
|
-
);
|
|
176
|
-
assert.ok(
|
|
177
|
-
clientSource.includes("const target = anchor ? anchor.nextSibling : region;"),
|
|
178
|
-
"the entry must sit right after the New-Session row, falling back before regionArea"
|
|
179
|
-
);
|
|
180
|
-
assert.ok(
|
|
181
|
-
clientSource.includes("if (created.parentElement === parent && created.nextSibling === target) return;"),
|
|
182
|
-
"placement must be idempotent so the observer never writes on steady state"
|
|
183
|
-
);
|
|
184
|
-
assert.ok(
|
|
185
|
-
/place\(cur\);\s*\n\s*const cls = findNative\(cur\)/.test(clientSource),
|
|
186
|
-
"the observer must re-place the entry on childList churn, not just re-sync the class"
|
|
187
|
-
);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// Three alignment/softness guarantees born from field feedback: (a) the
|
|
191
|
-
// portalled entry keeps tracking the live New-Session class (host and skin
|
|
192
|
-
// rewrite it asynchronously, so a mount-time snapshot goes stale), (b) it
|
|
193
|
-
// inherits that button's width and margins along with its native centering,
|
|
194
|
-
// (c) the toolbar dropdown escapes the transform stacking trap — the
|
|
195
|
-
// container needs the z-index because transform creates the context.
|
|
196
|
-
test("entry tracks native class, inherits native sizing, and lifts the toolbar dropdown", () => {
|
|
197
|
-
assert.ok(
|
|
198
|
-
clientSource.includes("new MutationObserver"),
|
|
199
|
-
"the entry must re-sync the copied class via MutationObserver"
|
|
200
|
-
);
|
|
201
|
-
assert.ok(
|
|
202
|
-
clientSource.includes('attributeFilter: ["class"]'),
|
|
203
|
-
"the observer must watch class attribute changes"
|
|
204
|
-
);
|
|
205
|
-
assert.doesNotMatch(
|
|
206
|
-
clientSource,
|
|
207
|
-
/\.mneme-topentry-native\{(?:[^}]*;)?width:/,
|
|
208
|
-
"the entry must use the native button width, which reserves its horizontal margins"
|
|
209
|
-
);
|
|
210
|
-
assert.ok(
|
|
211
|
-
clientSource.includes(".mneme-topentry{display:flex;flex-direction:column}"),
|
|
212
|
-
"the entry wrapper must stretch the native button within a column flex layout"
|
|
213
|
-
);
|
|
214
|
-
assert.equal(
|
|
215
|
-
clientSource.includes(".mneme-topentry-native .mneme-topentry-label{flex:1;min-width:0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"),
|
|
216
|
-
false,
|
|
217
|
-
"the old left-aligned label override must go; native centering applies"
|
|
218
|
-
);
|
|
219
|
-
assert.ok(
|
|
220
|
-
/\.mneme-xtools\{[^}]*z-index:3\}/.test(clientSource),
|
|
221
|
-
"the toolbar container must carry z-index:3 (sticky month header is 2, drawer 6)"
|
|
222
|
-
);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
// Importance renders as Lucide star glyphs (the morphicons-paired data set;
|
|
226
|
-
// the runtime cannot require the ESM-only morphicons engine, so the path
|
|
227
|
-
// ships inline like the other stroke icons), not raw ★ text. Only the
|
|
228
|
-
// drawer's edit-mode <option> labels keep the text form — SVG cannot render
|
|
229
|
-
// inside <option>.
|
|
230
|
-
test("importance renders as star glyphs, not raw text stars", () => {
|
|
231
|
-
assert.ok(
|
|
232
|
-
clientSource.includes("STAR_PATH_D"),
|
|
233
|
-
"the Lucide star path data must be inlined"
|
|
234
|
-
);
|
|
235
|
-
assert.ok(
|
|
236
|
-
clientSource.includes("const ImportanceStars"),
|
|
237
|
-
"the star-row component must exist"
|
|
238
|
-
);
|
|
239
|
-
assert.equal(
|
|
240
|
-
(clientSource.match(/"★"\.repeat/g) || []).length,
|
|
241
|
-
1,
|
|
242
|
-
"only the drawer edit <option> labels may keep the ★ text form"
|
|
243
|
-
);
|
|
244
|
-
assert.ok(
|
|
245
|
-
/h\(ImportanceStars, \{ className: "mneme-dmetaval"/.test(clientSource),
|
|
246
|
-
"the drawer detail must render the star row"
|
|
247
|
-
);
|
|
248
|
-
assert.ok(
|
|
249
|
-
/h\(ImportanceStars, \{ value: m\.importance/.test(clientSource),
|
|
250
|
-
"the card foot must render the star row"
|
|
251
|
-
);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
// better-sidebar ecosystem integration is an optional capability (official
|
|
255
|
-
// external-plugin-guide §2.2): 'betterSidebar' IS declared in inject (DSH's
|
|
256
|
-
// runtime gates ctx property access on the inject declaration — probing
|
|
257
|
-
// without declaring fails the whole loader entry, verified in the field) and
|
|
258
|
-
// better-sidebar 软集成(issue #88 修正):模块级 inject 声明 betterSidebar
|
|
259
|
-
// 是硬等待——未安装 bs 的环境整个 entry pending("1 entry did not activate",
|
|
260
|
-
// Failed to load plugins)。正确模式(dsh-server-deck 同款):外层入口零
|
|
261
|
-
// inject 立即激活(独立模式保底),tab 注册挂在内层动态子插件
|
|
262
|
-
// ctx.plugin({ inject: ['betterSidebar'] }) 由 cordis 原生等待服务——bs 未装
|
|
263
|
-
// 时该内层 fiber 永远 INACTIVE,静默无害。
|
|
264
|
-
test("better-sidebar tab mounts via an inner sub-plugin, standalone mode intact", () => {
|
|
265
|
-
assert.ok(
|
|
266
|
-
/const reg = bsCtx\.betterSidebar;[\s\S]{0,60}typeof reg\.registerTab !== "function"/.test(clientSource),
|
|
267
|
-
"the inner apply must still guard the service shape before registering"
|
|
268
|
-
);
|
|
269
|
-
assert.ok(
|
|
270
|
-
/(?:const TAB_ID = |id: )"dsh-mneme:memory"/.test(clientSource),
|
|
271
|
-
"the registered tab id must be package-prefixed"
|
|
272
|
-
);
|
|
273
|
-
assert.ok(
|
|
274
|
-
/title: \(\) => t\("memory\.view\.label"\)/.test(clientSource),
|
|
275
|
-
"the tab title must reuse the localized 记忆库 label"
|
|
276
|
-
);
|
|
277
|
-
assert.ok(
|
|
278
|
-
/component: \(\) => h\(MemoryExplorer, \{ t \}\)/.test(clientSource),
|
|
279
|
-
"the tab must reuse the MemoryExplorer views"
|
|
280
|
-
);
|
|
281
|
-
assert.ok(
|
|
282
|
-
clientSource.includes('"dsh-mneme: better-sidebar tab"'),
|
|
283
|
-
"the registration effect must carry a named label for scope cleanup"
|
|
284
|
-
);
|
|
285
|
-
// issue #88:模块级 inject 声明 betterSidebar 是硬等待——未安装 bs 的环境
|
|
286
|
-
// 整个 entry pending("1 entry did not activate")。tab 注册必须挂在内层
|
|
287
|
-
// 动态子插件(dsh-server-deck 同款模式),外层入口零 inject 立即激活。
|
|
288
|
-
assert.ok(
|
|
289
|
-
/const inject = \["slots", "locale"\]/.test(clientSource),
|
|
290
|
-
"the module inject must not declare betterSidebar (hard-wait regression)"
|
|
291
|
-
);
|
|
292
|
-
assert.ok(
|
|
293
|
-
/ctx\.plugin\?\.\(\{[\s\S]*?inject: \["betterSidebar"\][\s\S]*?apply: \(bsCtx\) =>/.test(clientSource),
|
|
294
|
-
"the tab registration must live in an inner dynamic sub-plugin waiting on cordis"
|
|
295
|
-
);
|
|
296
|
-
assert.ok(
|
|
297
|
-
/if \(\+\+tries <= 10\) timer = setTimeout\(attempt, 1000\);/.test(clientSource) === false,
|
|
298
|
-
"the old 10×1s probe must go — cordis waits for the inner inject natively"
|
|
299
|
-
);
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
// The graph toggle must not read as "share": the primitives share icon is
|
|
303
|
-
// banned and a custom node-graph glyph takes its place.
|
|
304
|
-
test("graph toggle uses a node-graph glyph, not the share icon", () => {
|
|
305
|
-
assert.equal(
|
|
306
|
-
clientSource.includes("IconShareOutline16"),
|
|
307
|
-
false,
|
|
308
|
-
"IconShareOutline16 reads as share and must not appear"
|
|
309
|
-
);
|
|
310
|
-
assert.ok(
|
|
311
|
-
clientSource.includes("GraphNodesIcon"),
|
|
312
|
-
"the custom node-graph icon must back the graph toggle"
|
|
313
|
-
);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
// 方案 A:查询收敛。状态页只做仪表盘(小页预览 + 服务端 total + 查看全部),
|
|
317
|
-
// 沉淀/归档的完整浏览走记忆库的 deposited/archived 筛选视图(chip 预置 +
|
|
318
|
-
// 状态页入口跳转),详情抽屉给归档记忆一个反向的「恢复」。
|
|
319
|
-
test("status dashboard links into deposited/archived library views", () => {
|
|
320
|
-
assert.ok(
|
|
321
|
-
clientSource.includes('"/api/dsh-mneme/list?deposited=only&limit=8&order=chrono"'),
|
|
322
|
-
"the workbench deposited preview must read the server-side deposited view"
|
|
323
|
-
);
|
|
324
|
-
assert.ok(
|
|
325
|
-
clientSource.includes('"/api/dsh-mneme/list?archived=only&limit=3&order=chrono"'),
|
|
326
|
-
"the workbench archived preview must cap at 3 rows backed by a server total"
|
|
327
|
-
);
|
|
328
|
-
assert.ok(
|
|
329
|
-
clientSource.includes("browseWithFilter"),
|
|
330
|
-
"the status page must jump into the library with preset filters"
|
|
331
|
-
);
|
|
332
|
-
assert.ok(
|
|
333
|
-
/view === "status" && h\(StatusPanel, \{ t, onBrowse: browseWithFilter \}\)/.test(clientSource),
|
|
334
|
-
"the status panel must receive the browse-jump callback"
|
|
335
|
-
);
|
|
336
|
-
assert.ok(
|
|
337
|
-
clientSource.includes('(depositedOnly ? "&deposited=only" : "")'),
|
|
338
|
-
"the library filterQS must carry the deposited chip"
|
|
339
|
-
);
|
|
340
|
-
assert.ok(
|
|
341
|
-
clientSource.includes('(archivedOnly ? "&archived=only" : "")'),
|
|
342
|
-
"the library filterQS must carry the archived chip"
|
|
343
|
-
);
|
|
344
|
-
assert.ok(
|
|
345
|
-
clientSource.includes('postUpdate({ archived: false }, { restored: true })'),
|
|
346
|
-
"the drawer must offer restore for archived memories"
|
|
347
|
-
);
|
|
348
|
-
assert.ok(
|
|
349
|
-
clientSource.includes('"memory.status.viewAll"'),
|
|
350
|
-
"the view-all entries must come from the dictionary"
|
|
351
|
-
);
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
// Every memory feature lives in the main-area library now: the explorer
|
|
355
|
-
// hosts three sub-views (browse / entities / settings) switched by tabs whose
|
|
356
|
-
// labels come from dedicated dictionary keys.
|
|
357
|
-
test("explorer hosts browse, entities and settings sub-views", () => {
|
|
358
|
-
for (const key of ["tabMemory", "tabEntities", "tabSettings"]) {
|
|
359
|
-
assert.ok(
|
|
360
|
-
clientSource.includes(`"memory.explorer.${key}"`),
|
|
361
|
-
`sub-view labels must come from memory.explorer.${key}`
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
assert.ok(
|
|
365
|
-
clientSource.includes("h(EntityPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory })"),
|
|
366
|
-
"the entity panel must be embedded as a sub-view"
|
|
367
|
-
);
|
|
368
|
-
assert.ok(
|
|
369
|
-
clientSource.includes('h(SettingsContent, { t })'),
|
|
370
|
-
"the settings forms must be embedded as a sub-view"
|
|
371
|
-
);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
// The graph panel jumps back into the browser: related-memory rows and the
|
|
375
|
-
// edge source button must land on the browse tab with the target selected.
|
|
376
|
-
test("graph jump lands on the selected memory in the browser", () => {
|
|
377
|
-
assert.ok(
|
|
378
|
-
/onClick: \(\) => onJumpMemory && onJumpMemory\(m\)/.test(clientSource),
|
|
379
|
-
"related-memory rows must jump via onJumpMemory(m)"
|
|
380
|
-
);
|
|
381
|
-
assert.ok(
|
|
382
|
-
clientSource.includes("onJumpMemory({ id: selected.edge.memory_id })"),
|
|
383
|
-
"the edge source button must jump to the origin memory by id"
|
|
384
|
-
);
|
|
385
|
-
assert.ok(
|
|
386
|
-
/const jumpToMemory = \(target\) => \{/.test(clientSource),
|
|
387
|
-
"jumpToMemory must reset filters and select the target"
|
|
388
|
-
);
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
// "entity:" in the browser search is the graph entry grammar: it must offer
|
|
392
|
-
// a jump chip instead of filtering the list.
|
|
393
|
-
test("entity: search grammar offers a graph jump", () => {
|
|
394
|
-
assert.ok(
|
|
395
|
-
clientSource.includes('query.trim().startsWith("entity:")'),
|
|
396
|
-
"the entity: prefix must be recognized"
|
|
397
|
-
);
|
|
398
|
-
assert.ok(
|
|
399
|
-
/onClick: \(\) => openGraphFor\(entityQuery\)/.test(clientSource),
|
|
400
|
-
"the jump chip must switch to the graph sub-view"
|
|
401
|
-
);
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
// The explorer is a card-grid / timeline browse with a right-hand detail
|
|
405
|
-
// drawer: types with counts, a month→day time tree (timeline mode), and the
|
|
406
|
-
// drawer rendering the untruncated content.
|
|
407
|
-
test("explorer lays out types, timeline, and full-text detail", () => {
|
|
408
|
-
assert.ok(
|
|
409
|
-
clientSource.includes('className: "mneme-xmain"'),
|
|
410
|
-
"the three-column grid must be present"
|
|
411
|
-
);
|
|
412
|
-
assert.ok(
|
|
413
|
-
clientSource.includes('className: "mneme-dcontent"'),
|
|
414
|
-
"the detail drawer must render the full content"
|
|
415
|
-
);
|
|
416
|
-
assert.ok(
|
|
417
|
-
clientSource.includes('className: "mneme-cards"'),
|
|
418
|
-
"the card-grid view must exist alongside the timeline"
|
|
419
|
-
);
|
|
420
|
-
assert.ok(
|
|
421
|
-
/toLocaleDateString\(undefined, \{ year: "numeric", month: "long" \}\)/.test(clientSource),
|
|
422
|
-
"month groups must format via the host locale, not hardcoded strings"
|
|
423
|
-
);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
// The library page must read as a first-party view: the chrome resolves to
|
|
427
|
-
// the host's design tokens (layer-1 canvas, brand-blue active states) and
|
|
428
|
-
// the boxed-panel / pill-chip patterns of the drawer era must stay gone.
|
|
429
|
-
test("explorer chrome aligns with the host design system", () => {
|
|
430
|
-
assert.ok(
|
|
431
|
-
clientSource.includes("background:var(--dsw-alias-bg-layer-1)"),
|
|
432
|
-
"the page canvas must sit on the host bg-layer-1 token"
|
|
433
|
-
);
|
|
434
|
-
assert.ok(
|
|
435
|
-
/\.mneme-vtab\.mneme-active::after/.test(clientSource),
|
|
436
|
-
"active sub-tabs use the host underline treatment"
|
|
437
|
-
);
|
|
438
|
-
assert.ok(
|
|
439
|
-
clientSource.includes(".mneme-vtab.mneme-active{color:var(--dsw-alias-state-business-primary)}"),
|
|
440
|
-
"active sub-tab text must turn the host brand blue"
|
|
441
|
-
);
|
|
442
|
-
assert.equal(
|
|
443
|
-
/\.mneme-xmain\{[^}]*border:1px/.test(clientSource),
|
|
444
|
-
false,
|
|
445
|
-
"the three-column layout must not wrap itself in a boxed panel"
|
|
446
|
-
);
|
|
447
|
-
assert.equal(
|
|
448
|
-
clientSource.includes("border-radius:999px"),
|
|
449
|
-
false,
|
|
450
|
-
"pill chips belong to the drawer era and must stay gone"
|
|
451
|
-
);
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
// heat 阶段二:/list 仅在 heatEnabled=true 时下发逐条 heat,前端徽章三档
|
|
455
|
-
// 配色且自门控(字段缺省自动隐藏)——卡片页脚、抽屉 meta、状态分布卡共用
|
|
456
|
-
// 同一数据源,前端不感知开关状态。
|
|
457
|
-
test("heat badges render from the /list projection and self-hide when off", () => {
|
|
458
|
-
assert.ok(
|
|
459
|
-
/const HeatBadge = \(\{ value, size = 12 \}\) =>/.test(clientSource),
|
|
460
|
-
"the heat badge component must exist"
|
|
461
|
-
);
|
|
462
|
-
assert.ok(
|
|
463
|
-
clientSource.includes("flame:"),
|
|
464
|
-
"the Lucide flame glyph must back the badge"
|
|
465
|
-
);
|
|
466
|
-
assert.ok(
|
|
467
|
-
/h\(HeatBadge, \{ value: m\.heat \}\)/.test(clientSource),
|
|
468
|
-
"the card foot must render the heat badge"
|
|
469
|
-
);
|
|
470
|
-
assert.ok(
|
|
471
|
-
clientSource.includes('t("memory.explorer.heat")'),
|
|
472
|
-
"the drawer must show a localized heat meta row"
|
|
473
|
-
);
|
|
474
|
-
assert.ok(
|
|
475
|
-
clientSource.includes("function HeatStatusCard"),
|
|
476
|
-
"the status grid must include the heat distribution card"
|
|
477
|
-
);
|
|
478
|
-
assert.ok(
|
|
479
|
-
clientSource.includes('typeof items[0].heat !== "number"'),
|
|
480
|
-
"the distribution card must self-hide when /list omits heat"
|
|
481
|
-
);
|
|
482
|
-
assert.ok(
|
|
483
|
-
/\.mneme-heat--hot\{/.test(clientSource),
|
|
484
|
-
"the three-tier heat colors must be styled"
|
|
485
|
-
);
|
|
486
|
-
// order=heat 的前端补口:热度是运行时投影无存储序,SQL 排不了——页内
|
|
487
|
-
// 对已加载条目降序,时间树保持 chrono;chip 自门控(heat 缺省不出现)。
|
|
488
|
-
assert.ok(
|
|
489
|
-
/const heatAvailable = visible\.some\(\(m\) => typeof m\.heat === "number"\);/.test(clientSource),
|
|
490
|
-
"the heat-sort chip must self-gate on the /list heat field"
|
|
491
|
-
);
|
|
492
|
-
assert.ok(
|
|
493
|
-
/const gridItems = heatSort[\s\S]{0,80}\(b\.heat \?\? 0\) - \(a\.heat \?\? 0\)/.test(clientSource),
|
|
494
|
-
"the cards grid must sort loaded items by heat in-page"
|
|
495
|
-
);
|
|
496
|
-
assert.ok(
|
|
497
|
-
clientSource.includes("if (!heatSort) switchViewMode(\"cards\")"),
|
|
498
|
-
"toggling heat sort must land on the cards view (sort does not apply to the month tree)"
|
|
499
|
-
);
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
// 巩固/睡眠模型路由 UI:下拉数据来自宿主侧已注册适配器(/llm-providers,
|
|
503
|
-
// 云端插件侧端点),「测试连通性」走 POST /test-model 真实最小调用。旧后端
|
|
504
|
-
// 端点 404 时必须回退纯文本输入——前端自门控,不挡旧版本。
|
|
505
|
-
test("consolidation/sleep model routing: provider dropdowns from /llm-providers, connectivity test via /test-model, graceful fallback", () => {
|
|
506
|
-
// 1. 端点探测与降级
|
|
507
|
-
assert.ok(
|
|
508
|
-
clientSource.includes('apiFetch("/api/dsh-mneme/llm-providers")'),
|
|
509
|
-
"the features card must probe GET /llm-providers for the route dropdowns"
|
|
510
|
-
);
|
|
511
|
-
assert.ok(
|
|
512
|
-
/setRoutes\(Array\.isArray\(j && j\.providers\) \? j\.providers : \[\]\)/.test(clientSource),
|
|
513
|
-
"the probe must only accept a {providers: []} shape"
|
|
514
|
-
);
|
|
515
|
-
assert.ok(
|
|
516
|
-
/Array\.isArray\(routes\)\s*\?\s*routeSelects\("dreamProvider", "dreamModel", dreamTest, setDreamTest\)/.test(clientSource),
|
|
517
|
-
"dream routing must upgrade to dropdowns only when the probe succeeded"
|
|
518
|
-
);
|
|
519
|
-
assert.ok(
|
|
520
|
-
/:\s*h\(react\.Fragment, null, strRow\("dreamProvider"\), strRow\("dreamModel"\)\)/.test(clientSource),
|
|
521
|
-
"when /llm-providers is unavailable the plain text inputs must remain (old-backend fallback)"
|
|
522
|
-
);
|
|
523
|
-
// 2. 睡眠侧行:跟随 sleepModeEnabled 门控,与巩固共用数据源
|
|
524
|
-
assert.ok(
|
|
525
|
-
/const sleepSub = eff\.sleepModeEnabled && Array\.isArray\(routes\) && h\("div", \{ className: "mneme-featsub" \},\s*\n\s*routeSelects\("sleepProvider", "sleepModel", sleepTest, setSleepTest\)/.test(clientSource),
|
|
526
|
-
"the sleep route row must gate on sleepModeEnabled and share the providers source"
|
|
527
|
-
);
|
|
528
|
-
// 2b. 睡眠路由的值必须进初始化草稿:FEATURE_STRINGS 漏键会导致重挂载后
|
|
529
|
-
// 下拉永远显示「跟随默认路由」(后端存了但前端读不回来)。
|
|
530
|
-
assert.ok(
|
|
531
|
-
/const FEATURE_STRINGS = \["dreamProvider", "dreamModel", "sleepProvider", "sleepModel",/.test(clientSource),
|
|
532
|
-
"sleepProvider/sleepModel must be restored into the draft on mount, not only written"
|
|
533
|
-
);
|
|
534
|
-
// 2c. 连通性测试状态按路由各持一份:共享单份会让点一个按钮两个组同时
|
|
535
|
-
// 显示「测试中/结果」且互相禁用。
|
|
536
|
-
assert.ok(
|
|
537
|
-
/const \[dreamTest, setDreamTest\] = useState\(null\);/.test(clientSource)
|
|
538
|
-
&& /const \[sleepTest, setSleepTest\] = useState\(null\);/.test(clientSource),
|
|
539
|
-
"the connectivity test state must be per-route, not shared between dream and sleep"
|
|
540
|
-
);
|
|
541
|
-
assert.ok(
|
|
542
|
-
/runModelTest\(curP, curM, setTestState\)/.test(clientSource),
|
|
543
|
-
"each route's test button must target its own test state"
|
|
544
|
-
);
|
|
545
|
-
// 3. 连通性测试:真实最小调用 + 结果展示(成功/失败 + 耗时 + 报错原因)
|
|
546
|
-
assert.ok(
|
|
547
|
-
clientSource.includes('apiFetch("/api/dsh-mneme/test-model", {'),
|
|
548
|
-
"the connectivity test must POST /test-model"
|
|
549
|
-
);
|
|
550
|
-
assert.ok(
|
|
551
|
-
/body: JSON\.stringify\(\{
|
|
552
|
-
"the test payload must carry the
|
|
553
|
-
);
|
|
554
|
-
assert.ok(
|
|
555
|
-
/typeof j\.durationMs === "number" \? j\.durationMs : Date\.now\(\) - started/.test(clientSource),
|
|
556
|
-
"the result duration must prefer the backend's durationMs and fall back to client timing"
|
|
557
|
-
);
|
|
558
|
-
assert.ok(
|
|
559
|
-
/detail: \[j\.modelId, j\.reply \? "「" \+ j\.reply \+ "」" : ""\]\.filter\(Boolean\)\.join\(" · "\)/.test(clientSource),
|
|
560
|
-
"the success line must surface the model's actual reply (backend caps it at 100 chars)"
|
|
561
|
-
);
|
|
562
|
-
assert.ok(
|
|
563
|
-
/detail: \[j\.modelId, j\.error \|\| "HTTP " \+ res\.status\]\.filter\(Boolean\)\.join\(" · "\)/.test(clientSource),
|
|
564
|
-
"the failure line must carry the tested modelId plus the backend error (502/400 bodies)"
|
|
565
|
-
);
|
|
566
|
-
assert.ok(
|
|
567
|
-
clientSource.includes("memory.features.modelTestOk") && clientSource.includes("memory.features.modelTestFail"),
|
|
568
|
-
"the result line must render success/failure with the localized labels"
|
|
569
|
-
);
|
|
570
|
-
// 4. 下拉改动即提交(与 embedProvider 一致),当前值不在枚举时保留为额外选项
|
|
571
|
-
assert.ok(
|
|
572
|
-
/setStrs\(\(c\) => \(\{ \.\.\.c, \[key\]: v \}\)\);\s*\n\s*put\(\{ \[key\]: v \}\)/.test(clientSource),
|
|
573
|
-
"select changes must commit through the features PUT like the embed provider select"
|
|
574
|
-
);
|
|
575
|
-
assert.ok(
|
|
576
|
-
/curM && !mVals\.includes\(curM\) \? h\("option", \{ key: "current", value: curM \}
|
|
577
|
-
"a configured value missing from the provider's model list must survive as an extra option"
|
|
578
|
-
);
|
|
579
|
-
// 5. 双语 i18n 与样式
|
|
580
|
-
for (const key of ["routeFollowDefault", "modelTest", "modelTesting", "modelTestOk", "modelTestFail", "sleepModelHint"]) {
|
|
581
|
-
const occurrences = clientSource.split(`"memory.features.${key}"`).length - 1;
|
|
582
|
-
assert.ok(occurrences >= 2, `i18n key memory.features.${key} must exist in both zh and en (got ${occurrences})`);
|
|
583
|
-
}
|
|
584
|
-
assert.ok(
|
|
585
|
-
clientSource.includes(".mneme-routeselect{width:240px;max-width:60%}"),
|
|
586
|
-
"the route selects must share the string-input width budget"
|
|
587
|
-
);
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
assert.ok(
|
|
659
|
-
clientSource.includes("
|
|
660
|
-
"
|
|
661
|
-
);
|
|
662
|
-
assert.ok(
|
|
663
|
-
clientSource.includes("
|
|
664
|
-
"
|
|
665
|
-
);
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
assert.ok(
|
|
672
|
-
clientSource.includes(
|
|
673
|
-
"the
|
|
674
|
-
);
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
"
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
assert.ok(
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
"
|
|
723
|
-
"
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
"
|
|
727
|
-
"
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
assert.ok(
|
|
735
|
-
|
|
736
|
-
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const clientSource = readFileSync(join(root, "lib/client.js"), "utf8");
|
|
9
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
10
|
+
|
|
11
|
+
// The Web client bundle registers itself via __ModuleLoader__.load. DSH
|
|
12
|
+
// resolves the bundle by plugin package name, so the registered id must match
|
|
13
|
+
// package.json `name` exactly (a mismatch surfaces as "loaded without
|
|
14
|
+
// registering '@modusensus/dsh-mneme'" in the DSH client).
|
|
15
|
+
test("client bundle registers under the package name", () => {
|
|
16
|
+
const match = clientSource.match(/__ModuleLoader__\.load\(\{\s*id:\s*"([^"]+)"/);
|
|
17
|
+
assert.ok(match, "client bundle must call __ModuleLoader__.load with an id");
|
|
18
|
+
assert.equal(match[1], pkg.name, "registered id must equal package.json name");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// client.js is hand-authored under lib/ only (no src/ counterpart), so the
|
|
22
|
+
// src->lib sync must never prune it.
|
|
23
|
+
test("client bundle is lib-only with no src counterpart", () => {
|
|
24
|
+
assert.equal(existsSync(join(root, "src/client.js")), false, "src/ must not contain client.js");
|
|
25
|
+
assert.equal(existsSync(join(root, "lib/client.js")), true, "lib/client.js must exist");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// The memory entry lives at the sidebar foot, not in the settings modal: the
|
|
29
|
+
// migration must register into `sidebar.footer.action` (the list slot the
|
|
30
|
+
// sidebar shell renders beside Settings) and must not keep a `settings.section`
|
|
31
|
+
// registration, or the entry would appear twice under different hosts.
|
|
32
|
+
test("memory entry registers into the sidebar foot slot", () => {
|
|
33
|
+
assert.ok(
|
|
34
|
+
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
35
|
+
"client must inject into sidebar.footer.action"
|
|
36
|
+
);
|
|
37
|
+
assert.equal(
|
|
38
|
+
clientSource.includes('"settings.section"'),
|
|
39
|
+
false,
|
|
40
|
+
"the old settings.section registration must be gone"
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The tab era is over: the in-conversation memory tab fought the floating
|
|
45
|
+
// composer and squeezed the session layout, so the library no longer
|
|
46
|
+
// registers as a conversation view. The sidebar entry opens the centered
|
|
47
|
+
// sheet directly — from any state, including the new-chat hero screen. A
|
|
48
|
+
// dialog role stays banned either way.
|
|
49
|
+
test("sidebar entry opens the sheet; conversation-view tab stays retired", () => {
|
|
50
|
+
assert.equal(
|
|
51
|
+
clientSource.includes("role: \"dialog\""),
|
|
52
|
+
false,
|
|
53
|
+
"no dialog surface should remain"
|
|
54
|
+
);
|
|
55
|
+
assert.ok(
|
|
56
|
+
/function openLibrary\(\) \{\s*setOverlayOpen\(true\);\s*\}/.test(clientSource),
|
|
57
|
+
"the entry must open the overlay directly (no tab activation)"
|
|
58
|
+
);
|
|
59
|
+
assert.equal(
|
|
60
|
+
clientSource.includes('ctx.slots.inject("conversation.view"'),
|
|
61
|
+
false,
|
|
62
|
+
"the conversation.view tab must stay retired"
|
|
63
|
+
);
|
|
64
|
+
assert.equal(
|
|
65
|
+
clientSource.includes("activateExplorerTab"),
|
|
66
|
+
false,
|
|
67
|
+
"the tab-click activation machinery must be gone"
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The sheet is deliberately NOT fullscreen: a dimmed backdrop plus a rounded
|
|
72
|
+
// panel capped at 1180×880 keeps the conversation visible behind it. It
|
|
73
|
+
// reuses the full MemoryExplorer and closes on Esc, the close button, or a
|
|
74
|
+
// backdrop click.
|
|
75
|
+
test("memory library opens as a centered sheet with backdrop", () => {
|
|
76
|
+
assert.ok(
|
|
77
|
+
clientSource.includes(".mneme-backdrop{position:fixed;inset:0"),
|
|
78
|
+
"a dimmed backdrop must sit behind the sheet"
|
|
79
|
+
);
|
|
80
|
+
assert.ok(
|
|
81
|
+
/\.mneme-overlay\{position:fixed;z-index:1000;left:50%;top:50%;transform:translate\(-50%,-50%\)/.test(clientSource),
|
|
82
|
+
"the sheet must be centered, not viewport-filling"
|
|
83
|
+
);
|
|
84
|
+
assert.ok(
|
|
85
|
+
clientSource.includes("width:min(1240px,calc(100vw - 88px))"),
|
|
86
|
+
"the sheet must not occupy the full width and keeps friendly margins"
|
|
87
|
+
);
|
|
88
|
+
assert.ok(
|
|
89
|
+
clientSource.includes('h(MemoryExplorer, { t })'),
|
|
90
|
+
"the sheet renders the same MemoryExplorer component"
|
|
91
|
+
);
|
|
92
|
+
assert.ok(
|
|
93
|
+
clientSource.includes('e.key === "Escape"'),
|
|
94
|
+
"Esc must close the sheet"
|
|
95
|
+
);
|
|
96
|
+
assert.ok(
|
|
97
|
+
clientSource.includes('className: "mneme-backdrop"'),
|
|
98
|
+
"the backdrop is a clickable close surface"
|
|
99
|
+
);
|
|
100
|
+
assert.ok(
|
|
101
|
+
clientSource.includes('"memory.overlay.close"'),
|
|
102
|
+
"the close affordance keeps its localized label"
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// The sidebar hands each entry only its column state: a wide row (icon +
|
|
107
|
+
// label) when expanded, a bare rail icon when collapsed — for both the
|
|
108
|
+
// portalled top button and the footer fallback.
|
|
109
|
+
test("trigger renders a wide row or a rail icon from the wide flag", () => {
|
|
110
|
+
assert.ok(
|
|
111
|
+
/wide \? "mneme-trigger" : "mneme-trigger mneme-rail"/.test(clientSource),
|
|
112
|
+
"trigger must branch on the wide flag"
|
|
113
|
+
);
|
|
114
|
+
assert.ok(
|
|
115
|
+
/wide && h\("span", \{ className: "mneme-trigger-label" \}/.test(clientSource),
|
|
116
|
+
"the label span must render only when wide"
|
|
117
|
+
);
|
|
118
|
+
assert.ok(
|
|
119
|
+
/size: wide \? 15 : 18/.test(clientSource),
|
|
120
|
+
"the portalled icon must follow the native rail sizing convention"
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// The entry lives at the top of the plugin-entry group: the real button is
|
|
125
|
+
// portalled right after the host's New-Session row (above the entries other
|
|
126
|
+
// plugins inject there), while the sidebar.footer.action registration remains
|
|
127
|
+
// as the React anchor and the in-place fallback when the host markup changes.
|
|
128
|
+
test("sidebar entry portals above the workspaces region with footer fallback", () => {
|
|
129
|
+
assert.ok(
|
|
130
|
+
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
131
|
+
"the footer slot registration must stay as anchor + fallback"
|
|
132
|
+
);
|
|
133
|
+
assert.ok(
|
|
134
|
+
clientSource.includes("SidebarTopEntry"),
|
|
135
|
+
"the portalled top entry must exist"
|
|
136
|
+
);
|
|
137
|
+
assert.ok(
|
|
138
|
+
clientSource.includes(`'[class*="regionArea"]'`),
|
|
139
|
+
"the portal must anchor at the host's regionArea container"
|
|
140
|
+
);
|
|
141
|
+
assert.ok(
|
|
142
|
+
clientSource.includes("insertBefore(created, target)"),
|
|
143
|
+
"the entry must be placed through the idempotent place() helper"
|
|
144
|
+
);
|
|
145
|
+
assert.ok(
|
|
146
|
+
clientSource.includes("if (!host) return fallback;"),
|
|
147
|
+
"the portal entry persists across collapse (no footer jump); footer fallback only covers portal failure"
|
|
148
|
+
);
|
|
149
|
+
assert.ok(
|
|
150
|
+
clientSource.includes('className: `${nativeCls} mneme-topentry-native`'),
|
|
151
|
+
"the entry must reuse the host New-Session button class for native geometry alignment"
|
|
152
|
+
);
|
|
153
|
+
assert.ok(
|
|
154
|
+
/querySelector\('\[class\*="newSession"\]'\)/.test(clientSource),
|
|
155
|
+
"the native class must be read from the live New-Session button, not hardcoded"
|
|
156
|
+
);
|
|
157
|
+
assert.ok(
|
|
158
|
+
clientSource.includes('"memory.view.label"'),
|
|
159
|
+
"the sheet aria-label must come from the memory.view.label dictionary key"
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Issue #130: ecosystem plugins (task board, skill explorer, …) inject their
|
|
164
|
+
// own entries right after the New-Session row, and late inserters land above
|
|
165
|
+
// earlier ones — a fixed "before regionArea" slot degrades into the tail of
|
|
166
|
+
// that group, detaching the memory button from its familiar top spot. The
|
|
167
|
+
// entry therefore (a) resolves the same anchor as the ecosystem's shared
|
|
168
|
+
// sidebar-entry-core (logoRow row, legacy direct-child button), (b) re-asserts
|
|
169
|
+
// its slot from the existing observer, and (c) stays idempotent — the anchor
|
|
170
|
+
// check short-circuits before any DOM write, so observers never ping-pong.
|
|
171
|
+
test("entry re-asserts the New-Session-adjacent slot against ecosystem entries", () => {
|
|
172
|
+
assert.ok(
|
|
173
|
+
clientSource.includes(`btn.closest('[class*="logoRow"]')`),
|
|
174
|
+
"the anchor must resolve the New-Session logo row like the ecosystem core"
|
|
175
|
+
);
|
|
176
|
+
assert.ok(
|
|
177
|
+
clientSource.includes("const target = anchor ? anchor.nextSibling : region;"),
|
|
178
|
+
"the entry must sit right after the New-Session row, falling back before regionArea"
|
|
179
|
+
);
|
|
180
|
+
assert.ok(
|
|
181
|
+
clientSource.includes("if (created.parentElement === parent && created.nextSibling === target) return;"),
|
|
182
|
+
"placement must be idempotent so the observer never writes on steady state"
|
|
183
|
+
);
|
|
184
|
+
assert.ok(
|
|
185
|
+
/place\(cur\);\s*\n\s*const cls = findNative\(cur\)/.test(clientSource),
|
|
186
|
+
"the observer must re-place the entry on childList churn, not just re-sync the class"
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Three alignment/softness guarantees born from field feedback: (a) the
|
|
191
|
+
// portalled entry keeps tracking the live New-Session class (host and skin
|
|
192
|
+
// rewrite it asynchronously, so a mount-time snapshot goes stale), (b) it
|
|
193
|
+
// inherits that button's width and margins along with its native centering,
|
|
194
|
+
// (c) the toolbar dropdown escapes the transform stacking trap — the
|
|
195
|
+
// container needs the z-index because transform creates the context.
|
|
196
|
+
test("entry tracks native class, inherits native sizing, and lifts the toolbar dropdown", () => {
|
|
197
|
+
assert.ok(
|
|
198
|
+
clientSource.includes("new MutationObserver"),
|
|
199
|
+
"the entry must re-sync the copied class via MutationObserver"
|
|
200
|
+
);
|
|
201
|
+
assert.ok(
|
|
202
|
+
clientSource.includes('attributeFilter: ["class"]'),
|
|
203
|
+
"the observer must watch class attribute changes"
|
|
204
|
+
);
|
|
205
|
+
assert.doesNotMatch(
|
|
206
|
+
clientSource,
|
|
207
|
+
/\.mneme-topentry-native\{(?:[^}]*;)?width:/,
|
|
208
|
+
"the entry must use the native button width, which reserves its horizontal margins"
|
|
209
|
+
);
|
|
210
|
+
assert.ok(
|
|
211
|
+
clientSource.includes(".mneme-topentry{display:flex;flex-direction:column}"),
|
|
212
|
+
"the entry wrapper must stretch the native button within a column flex layout"
|
|
213
|
+
);
|
|
214
|
+
assert.equal(
|
|
215
|
+
clientSource.includes(".mneme-topentry-native .mneme-topentry-label{flex:1;min-width:0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"),
|
|
216
|
+
false,
|
|
217
|
+
"the old left-aligned label override must go; native centering applies"
|
|
218
|
+
);
|
|
219
|
+
assert.ok(
|
|
220
|
+
/\.mneme-xtools\{[^}]*z-index:3\}/.test(clientSource),
|
|
221
|
+
"the toolbar container must carry z-index:3 (sticky month header is 2, drawer 6)"
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Importance renders as Lucide star glyphs (the morphicons-paired data set;
|
|
226
|
+
// the runtime cannot require the ESM-only morphicons engine, so the path
|
|
227
|
+
// ships inline like the other stroke icons), not raw ★ text. Only the
|
|
228
|
+
// drawer's edit-mode <option> labels keep the text form — SVG cannot render
|
|
229
|
+
// inside <option>.
|
|
230
|
+
test("importance renders as star glyphs, not raw text stars", () => {
|
|
231
|
+
assert.ok(
|
|
232
|
+
clientSource.includes("STAR_PATH_D"),
|
|
233
|
+
"the Lucide star path data must be inlined"
|
|
234
|
+
);
|
|
235
|
+
assert.ok(
|
|
236
|
+
clientSource.includes("const ImportanceStars"),
|
|
237
|
+
"the star-row component must exist"
|
|
238
|
+
);
|
|
239
|
+
assert.equal(
|
|
240
|
+
(clientSource.match(/"★"\.repeat/g) || []).length,
|
|
241
|
+
1,
|
|
242
|
+
"only the drawer edit <option> labels may keep the ★ text form"
|
|
243
|
+
);
|
|
244
|
+
assert.ok(
|
|
245
|
+
/h\(ImportanceStars, \{ className: "mneme-dmetaval"/.test(clientSource),
|
|
246
|
+
"the drawer detail must render the star row"
|
|
247
|
+
);
|
|
248
|
+
assert.ok(
|
|
249
|
+
/h\(ImportanceStars, \{ value: m\.importance/.test(clientSource),
|
|
250
|
+
"the card foot must render the star row"
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// better-sidebar ecosystem integration is an optional capability (official
|
|
255
|
+
// external-plugin-guide §2.2): 'betterSidebar' IS declared in inject (DSH's
|
|
256
|
+
// runtime gates ctx property access on the inject declaration — probing
|
|
257
|
+
// without declaring fails the whole loader entry, verified in the field) and
|
|
258
|
+
// better-sidebar 软集成(issue #88 修正):模块级 inject 声明 betterSidebar
|
|
259
|
+
// 是硬等待——未安装 bs 的环境整个 entry pending("1 entry did not activate",
|
|
260
|
+
// Failed to load plugins)。正确模式(dsh-server-deck 同款):外层入口零
|
|
261
|
+
// inject 立即激活(独立模式保底),tab 注册挂在内层动态子插件
|
|
262
|
+
// ctx.plugin({ inject: ['betterSidebar'] }) 由 cordis 原生等待服务——bs 未装
|
|
263
|
+
// 时该内层 fiber 永远 INACTIVE,静默无害。
|
|
264
|
+
test("better-sidebar tab mounts via an inner sub-plugin, standalone mode intact", () => {
|
|
265
|
+
assert.ok(
|
|
266
|
+
/const reg = bsCtx\.betterSidebar;[\s\S]{0,60}typeof reg\.registerTab !== "function"/.test(clientSource),
|
|
267
|
+
"the inner apply must still guard the service shape before registering"
|
|
268
|
+
);
|
|
269
|
+
assert.ok(
|
|
270
|
+
/(?:const TAB_ID = |id: )"dsh-mneme:memory"/.test(clientSource),
|
|
271
|
+
"the registered tab id must be package-prefixed"
|
|
272
|
+
);
|
|
273
|
+
assert.ok(
|
|
274
|
+
/title: \(\) => t\("memory\.view\.label"\)/.test(clientSource),
|
|
275
|
+
"the tab title must reuse the localized 记忆库 label"
|
|
276
|
+
);
|
|
277
|
+
assert.ok(
|
|
278
|
+
/component: \(\) => h\(MemoryExplorer, \{ t \}\)/.test(clientSource),
|
|
279
|
+
"the tab must reuse the MemoryExplorer views"
|
|
280
|
+
);
|
|
281
|
+
assert.ok(
|
|
282
|
+
clientSource.includes('"dsh-mneme: better-sidebar tab"'),
|
|
283
|
+
"the registration effect must carry a named label for scope cleanup"
|
|
284
|
+
);
|
|
285
|
+
// issue #88:模块级 inject 声明 betterSidebar 是硬等待——未安装 bs 的环境
|
|
286
|
+
// 整个 entry pending("1 entry did not activate")。tab 注册必须挂在内层
|
|
287
|
+
// 动态子插件(dsh-server-deck 同款模式),外层入口零 inject 立即激活。
|
|
288
|
+
assert.ok(
|
|
289
|
+
/const inject = \["slots", "locale"\]/.test(clientSource),
|
|
290
|
+
"the module inject must not declare betterSidebar (hard-wait regression)"
|
|
291
|
+
);
|
|
292
|
+
assert.ok(
|
|
293
|
+
/ctx\.plugin\?\.\(\{[\s\S]*?inject: \["betterSidebar"\][\s\S]*?apply: \(bsCtx\) =>/.test(clientSource),
|
|
294
|
+
"the tab registration must live in an inner dynamic sub-plugin waiting on cordis"
|
|
295
|
+
);
|
|
296
|
+
assert.ok(
|
|
297
|
+
/if \(\+\+tries <= 10\) timer = setTimeout\(attempt, 1000\);/.test(clientSource) === false,
|
|
298
|
+
"the old 10×1s probe must go — cordis waits for the inner inject natively"
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// The graph toggle must not read as "share": the primitives share icon is
|
|
303
|
+
// banned and a custom node-graph glyph takes its place.
|
|
304
|
+
test("graph toggle uses a node-graph glyph, not the share icon", () => {
|
|
305
|
+
assert.equal(
|
|
306
|
+
clientSource.includes("IconShareOutline16"),
|
|
307
|
+
false,
|
|
308
|
+
"IconShareOutline16 reads as share and must not appear"
|
|
309
|
+
);
|
|
310
|
+
assert.ok(
|
|
311
|
+
clientSource.includes("GraphNodesIcon"),
|
|
312
|
+
"the custom node-graph icon must back the graph toggle"
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// 方案 A:查询收敛。状态页只做仪表盘(小页预览 + 服务端 total + 查看全部),
|
|
317
|
+
// 沉淀/归档的完整浏览走记忆库的 deposited/archived 筛选视图(chip 预置 +
|
|
318
|
+
// 状态页入口跳转),详情抽屉给归档记忆一个反向的「恢复」。
|
|
319
|
+
test("status dashboard links into deposited/archived library views", () => {
|
|
320
|
+
assert.ok(
|
|
321
|
+
clientSource.includes('"/api/dsh-mneme/list?deposited=only&limit=8&order=chrono"'),
|
|
322
|
+
"the workbench deposited preview must read the server-side deposited view"
|
|
323
|
+
);
|
|
324
|
+
assert.ok(
|
|
325
|
+
clientSource.includes('"/api/dsh-mneme/list?archived=only&limit=3&order=chrono"'),
|
|
326
|
+
"the workbench archived preview must cap at 3 rows backed by a server total"
|
|
327
|
+
);
|
|
328
|
+
assert.ok(
|
|
329
|
+
clientSource.includes("browseWithFilter"),
|
|
330
|
+
"the status page must jump into the library with preset filters"
|
|
331
|
+
);
|
|
332
|
+
assert.ok(
|
|
333
|
+
/view === "status" && h\(StatusPanel, \{ t, onBrowse: browseWithFilter \}\)/.test(clientSource),
|
|
334
|
+
"the status panel must receive the browse-jump callback"
|
|
335
|
+
);
|
|
336
|
+
assert.ok(
|
|
337
|
+
clientSource.includes('(depositedOnly ? "&deposited=only" : "")'),
|
|
338
|
+
"the library filterQS must carry the deposited chip"
|
|
339
|
+
);
|
|
340
|
+
assert.ok(
|
|
341
|
+
clientSource.includes('(archivedOnly ? "&archived=only" : "")'),
|
|
342
|
+
"the library filterQS must carry the archived chip"
|
|
343
|
+
);
|
|
344
|
+
assert.ok(
|
|
345
|
+
clientSource.includes('postUpdate({ archived: false }, { restored: true })'),
|
|
346
|
+
"the drawer must offer restore for archived memories"
|
|
347
|
+
);
|
|
348
|
+
assert.ok(
|
|
349
|
+
clientSource.includes('"memory.status.viewAll"'),
|
|
350
|
+
"the view-all entries must come from the dictionary"
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Every memory feature lives in the main-area library now: the explorer
|
|
355
|
+
// hosts three sub-views (browse / entities / settings) switched by tabs whose
|
|
356
|
+
// labels come from dedicated dictionary keys.
|
|
357
|
+
test("explorer hosts browse, entities and settings sub-views", () => {
|
|
358
|
+
for (const key of ["tabMemory", "tabEntities", "tabSettings"]) {
|
|
359
|
+
assert.ok(
|
|
360
|
+
clientSource.includes(`"memory.explorer.${key}"`),
|
|
361
|
+
`sub-view labels must come from memory.explorer.${key}`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
assert.ok(
|
|
365
|
+
clientSource.includes("h(EntityPanel, { t, focusEntity: graphFocus, onJumpMemory: jumpToMemory })"),
|
|
366
|
+
"the entity panel must be embedded as a sub-view"
|
|
367
|
+
);
|
|
368
|
+
assert.ok(
|
|
369
|
+
clientSource.includes('h(SettingsContent, { t })'),
|
|
370
|
+
"the settings forms must be embedded as a sub-view"
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// The graph panel jumps back into the browser: related-memory rows and the
|
|
375
|
+
// edge source button must land on the browse tab with the target selected.
|
|
376
|
+
test("graph jump lands on the selected memory in the browser", () => {
|
|
377
|
+
assert.ok(
|
|
378
|
+
/onClick: \(\) => onJumpMemory && onJumpMemory\(m\)/.test(clientSource),
|
|
379
|
+
"related-memory rows must jump via onJumpMemory(m)"
|
|
380
|
+
);
|
|
381
|
+
assert.ok(
|
|
382
|
+
clientSource.includes("onJumpMemory({ id: selected.edge.memory_id })"),
|
|
383
|
+
"the edge source button must jump to the origin memory by id"
|
|
384
|
+
);
|
|
385
|
+
assert.ok(
|
|
386
|
+
/const jumpToMemory = \(target\) => \{/.test(clientSource),
|
|
387
|
+
"jumpToMemory must reset filters and select the target"
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// "entity:" in the browser search is the graph entry grammar: it must offer
|
|
392
|
+
// a jump chip instead of filtering the list.
|
|
393
|
+
test("entity: search grammar offers a graph jump", () => {
|
|
394
|
+
assert.ok(
|
|
395
|
+
clientSource.includes('query.trim().startsWith("entity:")'),
|
|
396
|
+
"the entity: prefix must be recognized"
|
|
397
|
+
);
|
|
398
|
+
assert.ok(
|
|
399
|
+
/onClick: \(\) => openGraphFor\(entityQuery\)/.test(clientSource),
|
|
400
|
+
"the jump chip must switch to the graph sub-view"
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// The explorer is a card-grid / timeline browse with a right-hand detail
|
|
405
|
+
// drawer: types with counts, a month→day time tree (timeline mode), and the
|
|
406
|
+
// drawer rendering the untruncated content.
|
|
407
|
+
test("explorer lays out types, timeline, and full-text detail", () => {
|
|
408
|
+
assert.ok(
|
|
409
|
+
clientSource.includes('className: "mneme-xmain"'),
|
|
410
|
+
"the three-column grid must be present"
|
|
411
|
+
);
|
|
412
|
+
assert.ok(
|
|
413
|
+
clientSource.includes('className: "mneme-dcontent"'),
|
|
414
|
+
"the detail drawer must render the full content"
|
|
415
|
+
);
|
|
416
|
+
assert.ok(
|
|
417
|
+
clientSource.includes('className: "mneme-cards"'),
|
|
418
|
+
"the card-grid view must exist alongside the timeline"
|
|
419
|
+
);
|
|
420
|
+
assert.ok(
|
|
421
|
+
/toLocaleDateString\(undefined, \{ year: "numeric", month: "long" \}\)/.test(clientSource),
|
|
422
|
+
"month groups must format via the host locale, not hardcoded strings"
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// The library page must read as a first-party view: the chrome resolves to
|
|
427
|
+
// the host's design tokens (layer-1 canvas, brand-blue active states) and
|
|
428
|
+
// the boxed-panel / pill-chip patterns of the drawer era must stay gone.
|
|
429
|
+
test("explorer chrome aligns with the host design system", () => {
|
|
430
|
+
assert.ok(
|
|
431
|
+
clientSource.includes("background:var(--dsw-alias-bg-layer-1)"),
|
|
432
|
+
"the page canvas must sit on the host bg-layer-1 token"
|
|
433
|
+
);
|
|
434
|
+
assert.ok(
|
|
435
|
+
/\.mneme-vtab\.mneme-active::after/.test(clientSource),
|
|
436
|
+
"active sub-tabs use the host underline treatment"
|
|
437
|
+
);
|
|
438
|
+
assert.ok(
|
|
439
|
+
clientSource.includes(".mneme-vtab.mneme-active{color:var(--dsw-alias-state-business-primary)}"),
|
|
440
|
+
"active sub-tab text must turn the host brand blue"
|
|
441
|
+
);
|
|
442
|
+
assert.equal(
|
|
443
|
+
/\.mneme-xmain\{[^}]*border:1px/.test(clientSource),
|
|
444
|
+
false,
|
|
445
|
+
"the three-column layout must not wrap itself in a boxed panel"
|
|
446
|
+
);
|
|
447
|
+
assert.equal(
|
|
448
|
+
clientSource.includes("border-radius:999px"),
|
|
449
|
+
false,
|
|
450
|
+
"pill chips belong to the drawer era and must stay gone"
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// heat 阶段二:/list 仅在 heatEnabled=true 时下发逐条 heat,前端徽章三档
|
|
455
|
+
// 配色且自门控(字段缺省自动隐藏)——卡片页脚、抽屉 meta、状态分布卡共用
|
|
456
|
+
// 同一数据源,前端不感知开关状态。
|
|
457
|
+
test("heat badges render from the /list projection and self-hide when off", () => {
|
|
458
|
+
assert.ok(
|
|
459
|
+
/const HeatBadge = \(\{ value, size = 12 \}\) =>/.test(clientSource),
|
|
460
|
+
"the heat badge component must exist"
|
|
461
|
+
);
|
|
462
|
+
assert.ok(
|
|
463
|
+
clientSource.includes("flame:"),
|
|
464
|
+
"the Lucide flame glyph must back the badge"
|
|
465
|
+
);
|
|
466
|
+
assert.ok(
|
|
467
|
+
/h\(HeatBadge, \{ value: m\.heat \}\)/.test(clientSource),
|
|
468
|
+
"the card foot must render the heat badge"
|
|
469
|
+
);
|
|
470
|
+
assert.ok(
|
|
471
|
+
clientSource.includes('t("memory.explorer.heat")'),
|
|
472
|
+
"the drawer must show a localized heat meta row"
|
|
473
|
+
);
|
|
474
|
+
assert.ok(
|
|
475
|
+
clientSource.includes("function HeatStatusCard"),
|
|
476
|
+
"the status grid must include the heat distribution card"
|
|
477
|
+
);
|
|
478
|
+
assert.ok(
|
|
479
|
+
clientSource.includes('typeof items[0].heat !== "number"'),
|
|
480
|
+
"the distribution card must self-hide when /list omits heat"
|
|
481
|
+
);
|
|
482
|
+
assert.ok(
|
|
483
|
+
/\.mneme-heat--hot\{/.test(clientSource),
|
|
484
|
+
"the three-tier heat colors must be styled"
|
|
485
|
+
);
|
|
486
|
+
// order=heat 的前端补口:热度是运行时投影无存储序,SQL 排不了——页内
|
|
487
|
+
// 对已加载条目降序,时间树保持 chrono;chip 自门控(heat 缺省不出现)。
|
|
488
|
+
assert.ok(
|
|
489
|
+
/const heatAvailable = visible\.some\(\(m\) => typeof m\.heat === "number"\);/.test(clientSource),
|
|
490
|
+
"the heat-sort chip must self-gate on the /list heat field"
|
|
491
|
+
);
|
|
492
|
+
assert.ok(
|
|
493
|
+
/const gridItems = heatSort[\s\S]{0,80}\(b\.heat \?\? 0\) - \(a\.heat \?\? 0\)/.test(clientSource),
|
|
494
|
+
"the cards grid must sort loaded items by heat in-page"
|
|
495
|
+
);
|
|
496
|
+
assert.ok(
|
|
497
|
+
clientSource.includes("if (!heatSort) switchViewMode(\"cards\")"),
|
|
498
|
+
"toggling heat sort must land on the cards view (sort does not apply to the month tree)"
|
|
499
|
+
);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// 巩固/睡眠模型路由 UI:下拉数据来自宿主侧已注册适配器(/llm-providers,
|
|
503
|
+
// 云端插件侧端点),「测试连通性」走 POST /test-model 真实最小调用。旧后端
|
|
504
|
+
// 端点 404 时必须回退纯文本输入——前端自门控,不挡旧版本。
|
|
505
|
+
test("consolidation/sleep model routing: provider dropdowns from /llm-providers, connectivity test via /test-model, graceful fallback", () => {
|
|
506
|
+
// 1. 端点探测与降级
|
|
507
|
+
assert.ok(
|
|
508
|
+
clientSource.includes('apiFetch("/api/dsh-mneme/llm-providers")'),
|
|
509
|
+
"the features card must probe GET /llm-providers for the route dropdowns"
|
|
510
|
+
);
|
|
511
|
+
assert.ok(
|
|
512
|
+
/setRoutes\(Array\.isArray\(j && j\.providers\) \? j\.providers : \[\]\)/.test(clientSource),
|
|
513
|
+
"the probe must only accept a {providers: []} shape"
|
|
514
|
+
);
|
|
515
|
+
assert.ok(
|
|
516
|
+
/Array\.isArray\(routes\)\s*\?\s*routeSelects\("dreamProvider", "dreamModel", dreamTest, setDreamTest, "dreamReasoningEffort"\)/.test(clientSource),
|
|
517
|
+
"dream routing must upgrade to dropdowns only when the probe succeeded (and pass its own effort key)"
|
|
518
|
+
);
|
|
519
|
+
assert.ok(
|
|
520
|
+
/:\s*h\(react\.Fragment, null, strRow\("dreamProvider"\), strRow\("dreamModel"\)\)/.test(clientSource),
|
|
521
|
+
"when /llm-providers is unavailable the plain text inputs must remain (old-backend fallback)"
|
|
522
|
+
);
|
|
523
|
+
// 2. 睡眠侧行:跟随 sleepModeEnabled 门控,与巩固共用数据源
|
|
524
|
+
assert.ok(
|
|
525
|
+
/const sleepSub = eff\.sleepModeEnabled && Array\.isArray\(routes\) && h\("div", \{ className: "mneme-featsub" \},\s*\n\s*routeSelects\("sleepProvider", "sleepModel", sleepTest, setSleepTest, "sleepReasoningEffort"\)/.test(clientSource),
|
|
526
|
+
"the sleep route row must gate on sleepModeEnabled and share the providers source"
|
|
527
|
+
);
|
|
528
|
+
// 2b. 睡眠路由的值必须进初始化草稿:FEATURE_STRINGS 漏键会导致重挂载后
|
|
529
|
+
// 下拉永远显示「跟随默认路由」(后端存了但前端读不回来)。
|
|
530
|
+
assert.ok(
|
|
531
|
+
/const FEATURE_STRINGS = \["dreamProvider", "dreamModel", "sleepProvider", "sleepModel",/.test(clientSource),
|
|
532
|
+
"sleepProvider/sleepModel must be restored into the draft on mount, not only written"
|
|
533
|
+
);
|
|
534
|
+
// 2c. 连通性测试状态按路由各持一份:共享单份会让点一个按钮两个组同时
|
|
535
|
+
// 显示「测试中/结果」且互相禁用。
|
|
536
|
+
assert.ok(
|
|
537
|
+
/const \[dreamTest, setDreamTest\] = useState\(null\);/.test(clientSource)
|
|
538
|
+
&& /const \[sleepTest, setSleepTest\] = useState\(null\);/.test(clientSource),
|
|
539
|
+
"the connectivity test state must be per-route, not shared between dream and sleep"
|
|
540
|
+
);
|
|
541
|
+
assert.ok(
|
|
542
|
+
/runModelTest\(curP, curM, setTestState, effort\)/.test(clientSource),
|
|
543
|
+
"each route's test button must target its own test state and pass the route's configured effort"
|
|
544
|
+
);
|
|
545
|
+
// 3. 连通性测试:真实最小调用 + 结果展示(成功/失败 + 耗时 + 报错原因)
|
|
546
|
+
assert.ok(
|
|
547
|
+
clientSource.includes('apiFetch("/api/dsh-mneme/test-model", {'),
|
|
548
|
+
"the connectivity test must POST /test-model"
|
|
549
|
+
);
|
|
550
|
+
assert.ok(
|
|
551
|
+
/body: JSON\.stringify\(\{\s*provider, model,\s*\.\.\.\(reasoningEffort && reasoningEffort !== "none" \? \{ reasoningEffort \} : \{\}\)\s*\}\)/.test(clientSource),
|
|
552
|
+
"the test payload must carry provider/model plus the configured reasoningEffort, omitted for 'none'/unset (issue #215, same convention as the backend)"
|
|
553
|
+
);
|
|
554
|
+
assert.ok(
|
|
555
|
+
/typeof j\.durationMs === "number" \? j\.durationMs : Date\.now\(\) - started/.test(clientSource),
|
|
556
|
+
"the result duration must prefer the backend's durationMs and fall back to client timing"
|
|
557
|
+
);
|
|
558
|
+
assert.ok(
|
|
559
|
+
/detail: \[j\.modelId, j\.reply \? "「" \+ j\.reply \+ "」" : ""\]\.filter\(Boolean\)\.join\(" · "\)/.test(clientSource),
|
|
560
|
+
"the success line must surface the model's actual reply (backend caps it at 100 chars)"
|
|
561
|
+
);
|
|
562
|
+
assert.ok(
|
|
563
|
+
/detail: \[j\.modelId, j\.error \|\| "HTTP " \+ res\.status\]\.filter\(Boolean\)\.join\(" · "\)/.test(clientSource),
|
|
564
|
+
"the failure line must carry the tested modelId plus the backend error (502/400 bodies)"
|
|
565
|
+
);
|
|
566
|
+
assert.ok(
|
|
567
|
+
clientSource.includes("memory.features.modelTestOk") && clientSource.includes("memory.features.modelTestFail"),
|
|
568
|
+
"the result line must render success/failure with the localized labels"
|
|
569
|
+
);
|
|
570
|
+
// 4. 下拉改动即提交(与 embedProvider 一致),当前值不在枚举时保留为额外选项
|
|
571
|
+
assert.ok(
|
|
572
|
+
/setStrs\(\(c\) => \(\{ \.\.\.c, \[key\]: v \}\)\);\s*\n\s*put\(\{ \[key\]: v \}\)/.test(clientSource),
|
|
573
|
+
"select changes must commit through the features PUT like the embed provider select"
|
|
574
|
+
);
|
|
575
|
+
assert.ok(
|
|
576
|
+
/curM && !mVals\.includes\(curM\) \? h\("option", \{ key: "current", value: curM \},\s*\n\s*mStale \? curM \+ t\("memory\.features\.routeStaleMark"\) : curM\) : null/.test(clientSource),
|
|
577
|
+
"a configured value missing from the provider's model list must survive as an extra option, marked when stale (issue #191)"
|
|
578
|
+
);
|
|
579
|
+
// 5. 双语 i18n 与样式
|
|
580
|
+
for (const key of ["routeFollowDefault", "modelTest", "modelTesting", "modelTestOk", "modelTestFail", "modelTestHint", "routeStaleMark", "routeStaleHint", "sleepModelHint"]) {
|
|
581
|
+
const occurrences = clientSource.split(`"memory.features.${key}"`).length - 1;
|
|
582
|
+
assert.ok(occurrences >= 2, `i18n key memory.features.${key} must exist in both zh and en (got ${occurrences})`);
|
|
583
|
+
}
|
|
584
|
+
assert.ok(
|
|
585
|
+
clientSource.includes(".mneme-routeselect{width:240px;max-width:60%}"),
|
|
586
|
+
"the route selects must share the string-input width budget"
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// issue #191:级联下拉刻意保留不在列表里的旧值(不丢配置),但此前与正常
|
|
591
|
+
// 选项无差别展示——切 Provider 后残留的中继风格 model id 照常保存,用户分
|
|
592
|
+
// 不清「改错了」还是「还没生效」。旧值必须显式标记 + 行级提示(改选或点连
|
|
593
|
+
// 通性测试当场验证 + 重启生效);适配器列表为空或 provider 未注册时前端无
|
|
594
|
+
// 从校验 model,一律不标记防误报。
|
|
595
|
+
test("stale route values are marked and explained at the row level (issue #191)", () => {
|
|
596
|
+
// 1. 旧值判定:仅在适配器列表非空时比对;model 只在 provider 已注册时校验
|
|
597
|
+
assert.ok(
|
|
598
|
+
/const hasProviders = entries\.length > 0;/.test(clientSource),
|
|
599
|
+
"stale checks must be gated on a non-empty provider registry (no false alarms)"
|
|
600
|
+
);
|
|
601
|
+
assert.ok(
|
|
602
|
+
/const pStale = hasProviders && !!curP && !entry;/.test(clientSource),
|
|
603
|
+
"a saved provider missing from /llm-providers must be flagged"
|
|
604
|
+
);
|
|
605
|
+
assert.ok(
|
|
606
|
+
/const mStale = hasProviders && !!entry && !!curM && !mVals\.includes\(curM\);/.test(clientSource),
|
|
607
|
+
"a saved model outside the selected provider's list must be flagged only when that provider is registered"
|
|
608
|
+
);
|
|
609
|
+
// 2. 标记落在选项文本上:下拉收起时也能看出旧值不在列表
|
|
610
|
+
assert.ok(
|
|
611
|
+
/pStale && p === curP \? p \+ t\("memory\.features\.routeStaleMark"\) : p/.test(clientSource),
|
|
612
|
+
"the preserved provider option must carry the stale mark"
|
|
613
|
+
);
|
|
614
|
+
// 3. 行级提示:⚠ 警示 + 改选/测试引导 + 重启生效,一条提示说完
|
|
615
|
+
assert.ok(
|
|
616
|
+
/\(pStale \|\| mStale\) && h\("div", \{ className: "mneme-featsubhint" \},\s*\n\s*"⚠ " \+ t\("memory\.features\.routeStaleHint"\)/.test(clientSource),
|
|
617
|
+
"the stale warning hint must render at the route row with the warning prefix"
|
|
618
|
+
);
|
|
619
|
+
// 3b. 旧值保留必须无条件:列表为空(端点刚起 / 零适配器)时也不得把已存
|
|
620
|
+
// provider 静默显示成「跟随默认路由」——只有标记带门,保留不带门。
|
|
621
|
+
assert.ok(
|
|
622
|
+
/curP && !entries\.some\(\(p\) => p\.provider === curP\) \? \[curP\] : \[\]/.test(clientSource),
|
|
623
|
+
"the preserved provider option must stay unconditional (empty list must not hide the saved value)"
|
|
624
|
+
);
|
|
625
|
+
// 3c. 英文提示不得把「不在列表」限定在 model 维度——provider 本身不在
|
|
626
|
+
// 列表时同样渲染此行(CodeRabbit #213 review)。
|
|
627
|
+
assert.ok(
|
|
628
|
+
!clientSource.includes("provider's model list"),
|
|
629
|
+
"the en stale hint must cover provider-absence too, not only the model case"
|
|
630
|
+
);
|
|
631
|
+
// 4. issue #215:三条路由各自透传自己的档位键——routeSelects 按路由行
|
|
632
|
+
// 传入键名、从生效配置取值;sleep 无回退重试,档位被拒时测试是事前唯一
|
|
633
|
+
// 的暴露口。
|
|
634
|
+
assert.ok(
|
|
635
|
+
/const routeSelects = \(providerKey, modelKey, testState, setTestState, effortKey\) => \{/.test(clientSource)
|
|
636
|
+
&& /const effort = effortKey \? \(eff\[effortKey\] \|\| ""\) : ""/.test(clientSource),
|
|
637
|
+
"routeSelects must take the route's effort key and read the configured value from effective settings"
|
|
638
|
+
);
|
|
639
|
+
assert.ok(
|
|
640
|
+
/routeSelects\("dreamProvider", "dreamModel", dreamTest, setDreamTest, "dreamReasoningEffort"\)/.test(clientSource)
|
|
641
|
+
&& /routeSelects\("sleepProvider", "sleepModel", sleepTest, setSleepTest, "sleepReasoningEffort"\)/.test(clientSource)
|
|
642
|
+
&& /routeSelects\("entityExtractionProvider", "entityExtractionModel", entityTest, setEntityTest, "entityExtractionReasoning"\)/.test(clientSource),
|
|
643
|
+
"each route row must pass its own reasoning effort key (dream/sleep/entityExtraction)"
|
|
644
|
+
);
|
|
645
|
+
// 4. 连通性测试按钮的说明与结果互斥(同一行槽位二选一,不堆叠)
|
|
646
|
+
assert.ok(
|
|
647
|
+
/\(testState && !testState\.running\s*\n\s*\? h\("div", \{ className: "mneme-featsubhint" \},/.test(clientSource)
|
|
648
|
+
&& clientSource.includes('t("memory.features.modelTestHint")'),
|
|
649
|
+
"the test row must show either the result or the standing explanation, never both"
|
|
650
|
+
);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// 帮助与反馈入口(v0.8):设置页底部三个反馈链接——GitHub 新建 issue 预填
|
|
654
|
+
// (环境信息)、邮件反馈、浏览已知问题。纯前端链接零后端成本;插件版本从
|
|
655
|
+
// /info 拉取(version 只读,不铺任何 token/凭据)。公开链接不得带个人邮箱。
|
|
656
|
+
test("settings feedback card: prefilled issue + mailto + browse, version from /info", () => {
|
|
657
|
+
// 1. 版本预填端点
|
|
658
|
+
assert.ok(
|
|
659
|
+
clientSource.includes('apiFetch("/api/dsh-mneme/info")'),
|
|
660
|
+
"the feedback card must fetch the plugin version from /info"
|
|
661
|
+
);
|
|
662
|
+
assert.ok(
|
|
663
|
+
clientSource.includes("setPkgVersion"),
|
|
664
|
+
"the fetched version must land in component state"
|
|
665
|
+
);
|
|
666
|
+
// 2. GitHub 新建 issue:issues/new?title=&body= 预填环境信息(当前仓库无模板)
|
|
667
|
+
assert.ok(
|
|
668
|
+
clientSource.includes("https://github.com/modusensus/dsh-mneme/issues/new?title="),
|
|
669
|
+
"the issue link must prefill title+body on issues/new"
|
|
670
|
+
);
|
|
671
|
+
assert.ok(
|
|
672
|
+
clientSource.includes("**插件版本**") && clientSource.includes("**平台**"),
|
|
673
|
+
"the prefill body must carry plugin version and platform"
|
|
674
|
+
);
|
|
675
|
+
// 3. 邮件反馈:官方邮箱(对外不写个人邮箱),mailto 预填 subject+body
|
|
676
|
+
assert.ok(
|
|
677
|
+
clientSource.includes("mailto:work@modusensus.space?subject="),
|
|
678
|
+
"the mailto link must point at the public support address"
|
|
679
|
+
);
|
|
680
|
+
// 4. 浏览已知问题:跳仓库 issues 列表页(去重前置步骤)
|
|
681
|
+
assert.ok(
|
|
682
|
+
clientSource.includes('href: "https://github.com/modusensus/dsh-mneme/issues"'),
|
|
683
|
+
"the browse link must open the repo issues list"
|
|
684
|
+
);
|
|
685
|
+
// 5. 双语 i18n
|
|
686
|
+
for (const key of ["feedback.title", "feedback.newIssue", "feedback.email", "feedback.browse", "feedback.hint"]) {
|
|
687
|
+
const occurrences = clientSource.split(`"memory.settings.${key}"`).length - 1;
|
|
688
|
+
assert.ok(occurrences >= 2, `i18n key memory.settings.${key} must exist in both zh and en (got ${occurrences})`);
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// UI 文案必须双语齐全(中英各一条)。用户明确要求所有 UI 文字都做 i18n,
|
|
693
|
+
// 这条守卫用「每个键恰好出现两次」兜住:出现奇数次就是漏译,>2 次说明重复。
|
|
694
|
+
test("i18n keys are bilingual: every key appears exactly twice (zh + en)", () => {
|
|
695
|
+
const counts = new Map();
|
|
696
|
+
for (const match of clientSource.matchAll(/^\s*"([a-zA-Z0-9_.]+)"\s*:\s*"/gm)) {
|
|
697
|
+
counts.set(match[1], (counts.get(match[1]) ?? 0) + 1);
|
|
698
|
+
}
|
|
699
|
+
const odd = [...counts.entries()].filter(([, n]) => n !== 2).map(([key, n]) => `${key}×${n}`);
|
|
700
|
+
assert.deepEqual(odd, [], `这些 i18n 键没有做到中英各一条:${odd.join(", ")}`);
|
|
701
|
+
assert.ok(counts.size > 100, `解析到的键太少(${counts.size}),正则可能没匹配到 i18n 表`);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// issue #135:向量状态卡必须区分「没配」与「配了却一条都没嵌上」。此前卡片只看
|
|
705
|
+
// ready,而未配置的 legacy embedder(ready 恒 true)一路显示成正常 provider。
|
|
706
|
+
test("vector status card surfaces the configured/degraded split", () => {
|
|
707
|
+
// 1. 读后端新字段,缺失(旧服务端)时退化成旧行为而不是误报未配置
|
|
708
|
+
assert.ok(
|
|
709
|
+
clientSource.includes('typeof j?.configured === "boolean" ? j.configured : null'),
|
|
710
|
+
"the card must read `configured` and tolerate older servers that omit it"
|
|
711
|
+
);
|
|
712
|
+
assert.ok(
|
|
713
|
+
clientSource.includes("j?.degraded === true"),
|
|
714
|
+
"the card must read `degraded`"
|
|
715
|
+
);
|
|
716
|
+
// 2. 三态互斥:未配置优先于初始化中,降级只在既非未配置也非初始化时成立
|
|
717
|
+
assert.ok(
|
|
718
|
+
clientSource.includes("const unconfigured = !off && state.configured === false;"),
|
|
719
|
+
"unconfigured must be its own state, not folded into ready"
|
|
720
|
+
);
|
|
721
|
+
assert.ok(
|
|
722
|
+
clientSource.includes("const pending = !off && !unconfigured && state.ready !== true;"),
|
|
723
|
+
"an unconfigured embedder must not be reported as initializing"
|
|
724
|
+
);
|
|
725
|
+
assert.ok(
|
|
726
|
+
clientSource.includes("const degraded = !off && !unconfigured && !pending && state.degraded === true;"),
|
|
727
|
+
"degraded must only apply to a configured, settled embedder"
|
|
728
|
+
);
|
|
729
|
+
// 3. 三个状态各自的标签/说明走 i18n,双语齐备
|
|
730
|
+
for (const key of ["vectorUnconfigured", "vectorUnconfiguredHint", "vectorDegradedHint"]) {
|
|
731
|
+
const occurrences = clientSource.split(`"memory.status.${key}"`).length - 1;
|
|
732
|
+
assert.ok(occurrences >= 2, `i18n key memory.status.${key} must exist in both zh and en (got ${occurrences})`);
|
|
733
|
+
}
|
|
734
|
+
assert.ok(
|
|
735
|
+
clientSource.includes('t("memory.status.vectorUnconfigured")'),
|
|
736
|
+
"the unconfigured state must render its own label"
|
|
737
|
+
);
|
|
738
|
+
assert.ok(
|
|
739
|
+
clientSource.includes('t("memory.status.vectorDegradedHint")'),
|
|
740
|
+
"the degraded state must render its own caption (0 of {m} indexed)"
|
|
741
|
+
);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
// v0.8.0 A4(issue #17):作用域隔离的面板面——设置卡新组(scopeEnabled /
|
|
745
|
+
// strictScope 开关)+ 记忆条目的 scope provenance 展示。i18n 键必须中英双语
|
|
746
|
+
// 齐全;徽章/详情行沿用 heat 徽章的「字段缺省即不渲染」模式,前端不感知开关。
|
|
747
|
+
/**
|
|
748
|
+
* A4(issue #17)面板面回归:作用域隔离的设置卡分组、十个 i18n 键的中英双语
|
|
749
|
+
* 齐备(沿用 occurrences ≥ 2 模式),以及 ScopeBadge 组件与三处接线(cards
|
|
750
|
+
* 脚部 / timeline 行 / 详情抽屉 dmeta)的真实存在性——只做源码断言,不挂
|
|
751
|
+
* React 渲染环境,与文件内其他 clientSource 断言同一风格。
|
|
752
|
+
*/
|
|
753
|
+
test("scope isolation ships panel switches and provenance surfaces (A4)", () => {
|
|
754
|
+
assert.ok(
|
|
755
|
+
/key: "group\.scope", items: \["scopeEnabled", "strictScope"\]/.test(clientSource),
|
|
756
|
+
"FEATURE_GROUPS must carry a dedicated scope group"
|
|
757
|
+
);
|
|
758
|
+
for (const key of [
|
|
759
|
+
"memory.features.group.scope",
|
|
760
|
+
"memory.features.scopeEnabled",
|
|
761
|
+
"memory.features.scopeEnabled.hint",
|
|
762
|
+
"memory.features.strictScope",
|
|
763
|
+
"memory.features.strictScope.hint",
|
|
764
|
+
"memory.explorer.scopeBadge",
|
|
765
|
+
"memory.explorer.detail.agentScope",
|
|
766
|
+
"memory.explorer.detail.workspaceScope",
|
|
767
|
+
"memory.explorer.detail.sensitivity",
|
|
768
|
+
"memory.explorer.detail.occurred"
|
|
769
|
+
]) {
|
|
770
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
771
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
772
|
+
}
|
|
773
|
+
assert.ok(/const ScopeBadge = \(\{ m, t \}\)/.test(clientSource), "ScopeBadge component must exist");
|
|
774
|
+
assert.ok(clientSource.includes("h(ScopeBadge, { m, t })"), "ScopeBadge must be wired into browse views");
|
|
775
|
+
assert.ok(clientSource.includes('t("memory.explorer.detail.agentScope")'), "drawer must render agent scope row");
|
|
776
|
+
assert.ok(clientSource.includes('t("memory.explorer.detail.occurred")'), "drawer must render occurred row");
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// v0.8.0 冲突集中处理:状态页的待确认冲突队列(reason + 双方内容对比 +
|
|
780
|
+
// 保留 A/B/仅标记动作),resolveConflictPending 的第一个前端出口。双语齐备。
|
|
781
|
+
test("conflict queue ships a central review surface on the status tab", () => {
|
|
782
|
+
for (const key of [
|
|
783
|
+
"memory.status.conflictQueue.reason",
|
|
784
|
+
"memory.status.conflictQueue.sideA",
|
|
785
|
+
"memory.status.conflictQueue.sideB",
|
|
786
|
+
"memory.status.conflictQueue.keepA",
|
|
787
|
+
"memory.status.conflictQueue.keepB",
|
|
788
|
+
"memory.status.conflictQueue.markReviewed",
|
|
789
|
+
"memory.status.conflictQueue.applyHint",
|
|
790
|
+
"memory.status.conflictQueue.missing",
|
|
791
|
+
"memory.status.conflictQueue.refresh"
|
|
792
|
+
]) {
|
|
793
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
794
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
795
|
+
}
|
|
796
|
+
assert.ok(/function ConflictsQueue\(\{ t \}\)/.test(clientSource), "ConflictsQueue component must exist");
|
|
797
|
+
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts"'), "queue must fetch the conflicts endpoint");
|
|
798
|
+
assert.ok(clientSource.includes('"/api/dsh-mneme/conflicts/resolve"'), "queue must call the resolve endpoint");
|
|
799
|
+
assert.ok(clientSource.includes("h(ConflictsQueue, { t })"), "status tab must render the queue");
|
|
737
800
|
});
|