@modusensus/dsh-mneme 0.8.9 → 0.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/assets/icon.svg +3 -0
- package/lib/api-standalone.js +11 -1
- package/lib/api.js +6 -1
- package/lib/client.js +208 -59
- package/lib/config.js +26 -0
- package/lib/index.js +10 -4
- package/lib/inject.js +68 -9
- package/lib/service.js +28 -3
- package/lib/settings.js +9 -1
- package/lib/tools.js +17 -8
- package/lib/write-admission.js +165 -22
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +6 -2
- package/scripts/benchmark-recall.js +57 -7
- package/src/api-standalone.js +11 -1
- package/src/api.js +6 -1
- package/src/config.js +26 -0
- package/src/index.js +10 -4
- package/src/inject.js +68 -9
- package/src/service.js +28 -3
- package/src/settings.js +9 -1
- package/src/tools.js +17 -8
- package/src/write-admission.js +165 -22
- package/test/api.test.js +17 -2
- package/test/client.test.js +245 -45
- package/test/epistemic.test.js +27 -0
- package/test/helpers/write-admission-samples.js +308 -0
- package/test/inject.test.js +137 -0
- package/test/recall-anti-update.test.js +82 -0
- package/test/write-admission.test.js +208 -0
package/test/client.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
@@ -63,9 +63,13 @@ test("sidebar entry opens the sheet; conversation-view tab stays retired", () =>
|
|
|
63
63
|
"no dialog surface should remain"
|
|
64
64
|
);
|
|
65
65
|
assert.ok(
|
|
66
|
-
/function openLibrary\(\) \{\s*setOverlayOpen\(true\)
|
|
66
|
+
/function openLibrary\(\) \{\s*setOverlayOpen\(true\);/.test(clientSource),
|
|
67
67
|
"the entry must open the overlay directly (no tab activation)"
|
|
68
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
|
+
);
|
|
69
73
|
assert.equal(
|
|
70
74
|
clientSource.includes('ctx.slots.inject("conversation.view"'),
|
|
71
75
|
false,
|
|
@@ -126,16 +130,20 @@ test("trigger renders a wide row or a rail icon from the wide flag", () => {
|
|
|
126
130
|
"the label span must render only when wide"
|
|
127
131
|
);
|
|
128
132
|
assert.ok(
|
|
129
|
-
/size: wide \?
|
|
130
|
-
"the portalled icon must follow the
|
|
133
|
+
/size: wide \? 16 : 18/.test(clientSource),
|
|
134
|
+
"the portalled icon must follow the host panel-row size (16) and the rail size (18)"
|
|
131
135
|
);
|
|
132
136
|
});
|
|
133
137
|
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
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", () => {
|
|
139
147
|
assert.ok(
|
|
140
148
|
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
141
149
|
"the footer slot registration must stay as anchor + fallback"
|
|
@@ -149,20 +157,24 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
|
|
|
149
157
|
"the portal must anchor at the host's regionArea container"
|
|
150
158
|
);
|
|
151
159
|
assert.ok(
|
|
152
|
-
clientSource.includes("
|
|
153
|
-
"the
|
|
160
|
+
clientSource.includes(`'[class*="panelList"]'`),
|
|
161
|
+
"the wide shape must target the host panel list (the 插件 / 自动化任务 group)"
|
|
154
162
|
);
|
|
155
163
|
assert.ok(
|
|
156
|
-
clientSource.includes("
|
|
157
|
-
"the
|
|
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"
|
|
158
166
|
);
|
|
159
167
|
assert.ok(
|
|
160
|
-
clientSource.includes(
|
|
161
|
-
"the entry must
|
|
168
|
+
clientSource.includes("list.appendChild(created)"),
|
|
169
|
+
"the wide entry must be appended into that group, below the host's own entries"
|
|
162
170
|
);
|
|
163
171
|
assert.ok(
|
|
164
|
-
|
|
165
|
-
"the
|
|
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"
|
|
166
178
|
);
|
|
167
179
|
assert.ok(
|
|
168
180
|
clientSource.includes('"memory.view.label"'),
|
|
@@ -178,7 +190,7 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
|
|
|
178
190
|
// sidebar-entry-core (logoRow row, legacy direct-child button), (b) re-asserts
|
|
179
191
|
// its slot from the existing observer, and (c) stays idempotent — the anchor
|
|
180
192
|
// check short-circuits before any DOM write, so observers never ping-pong.
|
|
181
|
-
test("entry re-asserts
|
|
193
|
+
test("entry re-asserts its slot against ecosystem entries", () => {
|
|
182
194
|
assert.ok(
|
|
183
195
|
clientSource.includes(`btn.closest('[class*="logoRow"]')`),
|
|
184
196
|
"the anchor must resolve the New-Session logo row like the ecosystem core"
|
|
@@ -192,18 +204,55 @@ test("entry re-asserts the New-Session-adjacent slot against ecosystem entries",
|
|
|
192
204
|
"placement must be idempotent so the observer never writes on steady state"
|
|
193
205
|
);
|
|
194
206
|
assert.ok(
|
|
195
|
-
/place\(cur\);\s*\n\s*
|
|
196
|
-
"the observer must re-place the entry
|
|
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"
|
|
197
245
|
);
|
|
198
246
|
});
|
|
199
247
|
|
|
200
248
|
// Three alignment/softness guarantees born from field feedback: (a) the
|
|
201
|
-
// portalled entry
|
|
202
|
-
// rewrite
|
|
203
|
-
//
|
|
204
|
-
// (
|
|
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
|
|
205
254
|
// container needs the z-index because transform creates the context.
|
|
206
|
-
test("entry
|
|
255
|
+
test("entry re-reads the live host classes and lifts the toolbar dropdown", () => {
|
|
207
256
|
assert.ok(
|
|
208
257
|
clientSource.includes("new MutationObserver"),
|
|
209
258
|
"the entry must re-sync the copied class via MutationObserver"
|
|
@@ -334,33 +383,99 @@ test("graph toggle uses a node-graph glyph, not the share icon", () => {
|
|
|
334
383
|
);
|
|
335
384
|
});
|
|
336
385
|
|
|
337
|
-
// #287
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
/primitives\.IconArchiveOutline20\s*\?\?\s*primitives\.IconArchiveOutlineRegular\s*\?\?\s*primitives\.IconArchiveOutlineMedium/.test(clientSource),
|
|
345
|
-
"the glyph must probe the 0.1.6 pixel name before the 0.1.7 weight names"
|
|
346
|
-
);
|
|
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)", () => {
|
|
347
393
|
assert.equal(
|
|
348
|
-
/
|
|
394
|
+
/primitives\.IconArchiveOutline(20|Regular|Medium)/.test(clientSource),
|
|
349
395
|
false,
|
|
350
|
-
"the
|
|
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"
|
|
351
405
|
);
|
|
352
406
|
assert.ok(
|
|
353
|
-
/const
|
|
354
|
-
"the
|
|
407
|
+
/const renderMnemeMark = \(props\) => h\(MnemeMark, props\)/.test(clientSource),
|
|
408
|
+
"the mark must render through one shared helper"
|
|
355
409
|
);
|
|
356
410
|
for (const site of [
|
|
357
|
-
"
|
|
358
|
-
"
|
|
359
|
-
"
|
|
360
|
-
"renderArchiveIcon({ size })" // better-sidebar tab 图标
|
|
411
|
+
"renderMnemeMark({ size: 15 })", // 浮层标题栏
|
|
412
|
+
"renderMnemeMark({ size: wide ? 16 : 18 })", // 侧栏 trigger + portal 入口(面板行档 16 / 标题栏档 18)
|
|
413
|
+
"renderMnemeMark({ size })" // better-sidebar tab 图标
|
|
361
414
|
]) {
|
|
362
415
|
assert.ok(clientSource.includes(site), `every render site must go through the helper: ${site}`);
|
|
363
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)");
|
|
364
479
|
});
|
|
365
480
|
|
|
366
481
|
// 方案 A:查询收敛。状态页只做仪表盘(小页预览 + 服务端 total + 查看全部),
|
|
@@ -937,11 +1052,96 @@ test("conflict queue: actionable status card and sidebar entry badge", () => {
|
|
|
937
1052
|
assert.ok(clientSource.includes("mneme-statuscard--actionable"), "highlight CSS must exist");
|
|
938
1053
|
assert.ok((clientSource.match(/h\(ConflictBadge, \{ pending \}\)/g) || []).length === 2,
|
|
939
1054
|
"badge must be wired into both entry buttons");
|
|
940
|
-
assert.ok((clientSource.match(/= useConflictBadgeCount\(
|
|
941
|
-
"both entry components must subscribe to the badge count");
|
|
1055
|
+
assert.ok((clientSource.match(/= useConflictBadgeCount\(\);/g) || []).length === 2,
|
|
1056
|
+
"both entry components must subscribe to the shared badge count");
|
|
942
1057
|
assert.ok(clientSource.includes('"/api/dsh-mneme/dream-status"'), "badge must reuse the dream-status endpoint");
|
|
943
1058
|
});
|
|
944
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
|
+
|
|
945
1145
|
// --- issue #179:注入预览(状态页卡片 + /inject-preview 端点透传的旁路快照) ---
|
|
946
1146
|
test("a11y+preview: inject preview card is wired on the status tab", () => {
|
|
947
1147
|
assert.ok(clientSource.includes('"/api/dsh-mneme/inject-preview"'), "card must fetch the preview endpoint");
|
package/test/epistemic.test.js
CHANGED
|
@@ -296,3 +296,30 @@ test("conflict resolution keeps the LLM winner when disabled", () => {
|
|
|
296
296
|
assert.equal(store.getById(w.memory.id).archived, false);
|
|
297
297
|
assert.equal(store.getById(l.memory.id).archived, true);
|
|
298
298
|
});
|
|
299
|
+
|
|
300
|
+
// E2 四臂实验(Kaggle v11)当场抓到的回归:saveWithDedupe 此前丢弃显式
|
|
301
|
+
// epistemic_status,store 回退内容推断,英文/无标记内容一律 subjective——
|
|
302
|
+
// EPISTEMIC_WEIGHTS 对整池均匀 ×0.7,trustEpistemicWeighting 的重排整体空转
|
|
303
|
+
// (R+ 臂与 A 臂注入序逐条相同)。显式值必须穿透创建路径;undefined 保持推断。
|
|
304
|
+
test("saveWithDedupe forwards explicit epistemic_status (rerank needs non-uniform weights)", async () => {
|
|
305
|
+
const store = createStore(":memory:");
|
|
306
|
+
const builder = createService({ store, mirror: null, config: {} });
|
|
307
|
+
const bait = builder.saveWithDedupe({ type: "preference", title: "m#0", content: "User feels that daily stand-up meetings are micromanagement theater", importance: 3, epistemic_status: "subjective" });
|
|
308
|
+
const fact = builder.saveWithDedupe({ type: "preference", title: "m#1", content: "User is a Lead Software Engineer with 15 years of experience", importance: 3, epistemic_status: "observation" });
|
|
309
|
+
assert.equal(store.getById(bait.memory.id).epistemic_status, "subjective", "explicit subjective stored");
|
|
310
|
+
assert.equal(store.getById(fact.memory.id).epistemic_status, "observation", "explicit field must survive saveWithDedupe (English content, inference cannot classify)");
|
|
311
|
+
|
|
312
|
+
const plain = createService({ store, mirror: null, config: { signalTransparency: true } });
|
|
313
|
+
const weighted = createService({ store, mirror: null, config: { signalTransparency: true, trustEpistemicWeighting: true } });
|
|
314
|
+
for (const s of [plain, weighted]) s.setVectorIndex(createVectorIndex({ store }));
|
|
315
|
+
const opts = { mode: "auto", topK: 5, useRerank: false };
|
|
316
|
+
const pRows = await plain.searchMemories("software engineer", opts);
|
|
317
|
+
const wRows = await weighted.searchMemories("software engineer", opts);
|
|
318
|
+
const pScore = Object.fromEntries(pRows.map((r) => [r.id, r.signals?.final]));
|
|
319
|
+
const wScore = Object.fromEntries(wRows.map((r) => [r.id, r.signals?.final]));
|
|
320
|
+
assert.ok(pScore[fact.memory.id] > 0, "observation entry is a candidate");
|
|
321
|
+
assert.ok(
|
|
322
|
+
Math.abs(wScore[fact.memory.id] - pScore[fact.memory.id]) < 1e-9,
|
|
323
|
+
"observation keeps weight 1.0 — if scaled, the explicit field was dropped (all rows uniform subjective)"
|
|
324
|
+
);
|
|
325
|
+
});
|