@modusensus/dsh-mneme 0.7.16 → 0.7.18
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.en.md +35 -10
- package/README.md +26 -45
- package/lib/api.js +6 -2
- package/lib/client.js +207 -47
- package/lib/store.js +16 -2
- package/package.json +10 -3
- package/src/api.js +6 -2
- package/src/store.js +16 -2
- package/test/api-routes-gaps.test.js +218 -0
- package/test/api.test.js +55 -0
- package/test/client.test.js +136 -0
- package/test/lib-smoke.test.js +96 -0
package/src/store.js
CHANGED
|
@@ -637,7 +637,7 @@ export function createStore(path) {
|
|
|
637
637
|
return ts;
|
|
638
638
|
}
|
|
639
639
|
|
|
640
|
-
function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, updatedFrom = null, updatedTo = null } = {}) {
|
|
640
|
+
function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null } = {}) {
|
|
641
641
|
const clauses = [];
|
|
642
642
|
const params = [];
|
|
643
643
|
if (type !== undefined) {
|
|
@@ -673,6 +673,12 @@ export function createStore(path) {
|
|
|
673
673
|
} else if (!includeArchived) {
|
|
674
674
|
clauses.push("archived = 0");
|
|
675
675
|
}
|
|
676
|
+
// 与 list() 同过滤:deposited 视图的 total 才能和行保持一致。
|
|
677
|
+
if (depositedOnly) {
|
|
678
|
+
clauses.push(
|
|
679
|
+
"(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
|
|
680
|
+
);
|
|
681
|
+
}
|
|
676
682
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
677
683
|
return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
|
|
678
684
|
}
|
|
@@ -924,7 +930,7 @@ export function createStore(path) {
|
|
|
924
930
|
return rows.map(toRow);
|
|
925
931
|
}
|
|
926
932
|
|
|
927
|
-
function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
|
|
933
|
+
function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
|
|
928
934
|
const clauses = [];
|
|
929
935
|
const params = [];
|
|
930
936
|
if (type) {
|
|
@@ -962,6 +968,14 @@ export function createStore(path) {
|
|
|
962
968
|
} else if (!includeArchived) {
|
|
963
969
|
clauses.push("archived = 0");
|
|
964
970
|
}
|
|
971
|
+
// depositedOnly:只看 autoDream 巩固过的记忆——receipt_chain 的 merge /
|
|
972
|
+
// update live verdict(record_id 即保留/更新目标)∪ source="dream" 的
|
|
973
|
+
// 直写沉淀(记忆库总览)。conflict 不算沉淀:两侧只被仲裁,内容未落。
|
|
974
|
+
if (depositedOnly) {
|
|
975
|
+
clauses.push(
|
|
976
|
+
"(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
|
|
977
|
+
);
|
|
978
|
+
}
|
|
965
979
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
966
980
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
967
981
|
// "chrono" is pure newest-first — the stable order paged browsing (month
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { createStore } from "../src/store.js";
|
|
5
|
+
import { createService } from "../src/service.js";
|
|
6
|
+
import { createApi } from "../src/api.js";
|
|
7
|
+
import { createSettings } from "../src/settings.js";
|
|
8
|
+
|
|
9
|
+
// 覆盖 PR#73 删测后遗留的三个存活 API 空白(v0.7.16 回归补测):
|
|
10
|
+
// /api/dsh-mneme/delete 面板删记忆(POST + auth + 存在性 404)
|
|
11
|
+
// /api/dsh-mneme/entities 实体目录(只读、limit 夹取)
|
|
12
|
+
// /api/dsh-mneme/external-api 独立服务配置(GET 发 token / PUT 校验)
|
|
13
|
+
// 这三个路由在 src/api.js 里一直在跑,只是丢了测试。
|
|
14
|
+
|
|
15
|
+
class FakeRes extends EventEmitter {
|
|
16
|
+
constructor() { super(); this.statusCode = 200; this.body = ""; }
|
|
17
|
+
writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
|
|
18
|
+
end(text) { this.body = text ?? ""; this.emit("end"); return this; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function req(path, method = "GET", body = null) {
|
|
22
|
+
const r = new EventEmitter();
|
|
23
|
+
r.url = path;
|
|
24
|
+
r.method = method;
|
|
25
|
+
r.headers = {};
|
|
26
|
+
if (body !== null) {
|
|
27
|
+
process.nextTick(() => {
|
|
28
|
+
r.emit("data", Buffer.from(JSON.stringify(body)));
|
|
29
|
+
r.emit("end");
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return r;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function setup(apiToken = "") {
|
|
36
|
+
const store = createStore(":memory:");
|
|
37
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
38
|
+
const settings = createSettings(store.db);
|
|
39
|
+
const commands = { add: () => {}, remove: () => {}, list: () => [] };
|
|
40
|
+
const routes = [];
|
|
41
|
+
const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
|
|
42
|
+
createApi(ctx, service, settings, commands, null, undefined, apiToken);
|
|
43
|
+
return { store, service, routes, settings };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function handler(routes, path) {
|
|
47
|
+
return routes.find((r) => r.path === path);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// /api/dsh-mneme/delete
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
test("delete is auth-gated and rejects non-POST", async () => {
|
|
54
|
+
const { routes } = setup("secret-token");
|
|
55
|
+
const route = handler(routes, "/api/dsh-mneme/delete");
|
|
56
|
+
|
|
57
|
+
// 未带 token → 401
|
|
58
|
+
let res = new FakeRes();
|
|
59
|
+
await route.handler(req("/api/dsh-mneme/delete", "POST", { id: "x" }), res);
|
|
60
|
+
assert.equal(res.statusCode, 401);
|
|
61
|
+
assert.equal(JSON.parse(res.body).error, "unauthorized");
|
|
62
|
+
|
|
63
|
+
// 带 token 但 GET → 404
|
|
64
|
+
res = new FakeRes();
|
|
65
|
+
const ok = req("/api/dsh-mneme/delete", "GET");
|
|
66
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
67
|
+
await route.handler(ok, res);
|
|
68
|
+
assert.equal(res.statusCode, 404);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("delete validates id and reports missing/unknown 404", async () => {
|
|
72
|
+
const { routes, service } = setup("secret-token");
|
|
73
|
+
const route = handler(routes, "/api/dsh-mneme/delete");
|
|
74
|
+
|
|
75
|
+
// 空 id → 400
|
|
76
|
+
let res = new FakeRes();
|
|
77
|
+
let ok = req("/api/dsh-mneme/delete", "POST", { id: " " });
|
|
78
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
79
|
+
await route.handler(ok, res);
|
|
80
|
+
assert.equal(res.statusCode, 400);
|
|
81
|
+
assert.equal(JSON.parse(res.body).error, "missing-id");
|
|
82
|
+
|
|
83
|
+
// 不存在的 id → 404(store.remove 静默,靠前置 getById 区分)
|
|
84
|
+
res = new FakeRes();
|
|
85
|
+
ok = req("/api/dsh-mneme/delete", "POST", { id: "no-such-memory" });
|
|
86
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
87
|
+
await route.handler(ok, res);
|
|
88
|
+
assert.equal(res.statusCode, 404);
|
|
89
|
+
assert.equal(JSON.parse(res.body).error, "not-found");
|
|
90
|
+
|
|
91
|
+
// 确认没被误删
|
|
92
|
+
assert.equal(service.count(), 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("delete removes an existing memory", async () => {
|
|
96
|
+
const { routes, service } = setup("secret-token");
|
|
97
|
+
const route = handler(routes, "/api/dsh-mneme/delete");
|
|
98
|
+
|
|
99
|
+
const { memory } = service.saveWithDedupe({ type: "project", title: "要删的", content: "删掉我" });
|
|
100
|
+
assert.ok(service.getById(memory.id));
|
|
101
|
+
|
|
102
|
+
const res = new FakeRes();
|
|
103
|
+
const ok = req("/api/dsh-mneme/delete", "POST", { id: memory.id });
|
|
104
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
105
|
+
await route.handler(ok, res);
|
|
106
|
+
assert.equal(res.statusCode, 200);
|
|
107
|
+
assert.deepEqual(JSON.parse(res.body), { ok: true });
|
|
108
|
+
assert.equal(service.getById(memory.id), undefined);
|
|
109
|
+
assert.equal(service.count(), 0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// /api/dsh-mneme/entities
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
test("entities directory is read-only and returns empty list", async () => {
|
|
116
|
+
const { routes } = setup();
|
|
117
|
+
const route = handler(routes, "/api/dsh-mneme/entities");
|
|
118
|
+
const res = new FakeRes();
|
|
119
|
+
await route.handler(req("/api/dsh-mneme/entities"), res);
|
|
120
|
+
assert.equal(res.statusCode, 200);
|
|
121
|
+
assert.deepEqual(JSON.parse(res.body), { entities: [], total: 0 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("entities lists seeded entities without auth", async () => {
|
|
125
|
+
const { routes, service } = setup("secret-token"); // 有 token 但读路由不校验
|
|
126
|
+
service.createEntity({ name: "SQLite", type: "technology" });
|
|
127
|
+
service.createEntity({ name: "记忆", type: "concept" });
|
|
128
|
+
|
|
129
|
+
const route = handler(routes, "/api/dsh-mneme/entities");
|
|
130
|
+
const res = new FakeRes();
|
|
131
|
+
await route.handler(req("/api/dsh-mneme/entities"), res);
|
|
132
|
+
assert.equal(res.statusCode, 200);
|
|
133
|
+
const data = JSON.parse(res.body);
|
|
134
|
+
assert.equal(data.total, 2);
|
|
135
|
+
const names = data.entities.map((e) => e.name).sort();
|
|
136
|
+
assert.deepEqual(names, ["SQLite", "记忆"]);
|
|
137
|
+
for (const e of data.entities) {
|
|
138
|
+
assert.ok(e.type, "each entity carries a type");
|
|
139
|
+
assert.ok(Number.isInteger(e.mention_count));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("entities clamps limit to [1, 1000]", async () => {
|
|
144
|
+
const { routes, service } = setup();
|
|
145
|
+
for (let i = 0; i < 5; i++) service.createEntity({ name: `e${i}`, type: "concept" });
|
|
146
|
+
const route = handler(routes, "/api/dsh-mneme/entities");
|
|
147
|
+
|
|
148
|
+
// 夹取语义:NaN/0 先被 `|| 500` 兜底成 500(0 不再走 max(1,) 下限),
|
|
149
|
+
// 负数才被夹到 1,超限(>1000)夹到 1000。断言按真实实现收紧。
|
|
150
|
+
for (const [qs, want] of [["limit=abc", 5], ["limit=0", 5], ["limit=-10", 1], ["limit=5000", 5]]) {
|
|
151
|
+
const res = new FakeRes();
|
|
152
|
+
await route.handler(req(`/api/dsh-mneme/entities?${qs}`), res);
|
|
153
|
+
const data = JSON.parse(res.body);
|
|
154
|
+
assert.equal(data.entities.length, want, `${qs} → ${want} (got ${data.entities.length})`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// /api/dsh-mneme/external-api
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
test("external-api GET materializes and persists a token", async () => {
|
|
162
|
+
const { routes, settings } = setup();
|
|
163
|
+
const route = handler(routes, "/api/dsh-mneme/external-api");
|
|
164
|
+
|
|
165
|
+
const res = new FakeRes();
|
|
166
|
+
await route.handler(req("/api/dsh-mneme/external-api"), res);
|
|
167
|
+
assert.equal(res.statusCode, 200);
|
|
168
|
+
const cfg = JSON.parse(res.body).config;
|
|
169
|
+
assert.ok(typeof cfg.token === "string" && cfg.token.length >= 16, "token is generated");
|
|
170
|
+
|
|
171
|
+
// 持久化后二次 GET 返回同一 token
|
|
172
|
+
const res2 = new FakeRes();
|
|
173
|
+
await route.handler(req("/api/dsh-mneme/external-api"), res2);
|
|
174
|
+
assert.equal(JSON.parse(res2.body).config.token, cfg.token);
|
|
175
|
+
assert.ok(settings.getExternalApi().token, "token persisted to settings kv");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("external-api PUT requires auth and validates port/host", async () => {
|
|
179
|
+
const { routes } = setup("secret-token");
|
|
180
|
+
const route = handler(routes, "/api/dsh-mneme/external-api");
|
|
181
|
+
|
|
182
|
+
// 未带 token → 401
|
|
183
|
+
let res = new FakeRes();
|
|
184
|
+
await route.handler(req("/api/dsh-mneme/external-api", "PUT", { enabled: true }), res);
|
|
185
|
+
assert.equal(res.statusCode, 401);
|
|
186
|
+
|
|
187
|
+
// 端口越界 → 400
|
|
188
|
+
res = new FakeRes();
|
|
189
|
+
let ok = req("/api/dsh-mneme/external-api", "PUT", { port: 70000 });
|
|
190
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
191
|
+
await route.handler(ok, res);
|
|
192
|
+
assert.equal(res.statusCode, 400);
|
|
193
|
+
assert.equal(JSON.parse(res.body).error, "invalid-port");
|
|
194
|
+
|
|
195
|
+
// host 带协议 → 400
|
|
196
|
+
res = new FakeRes();
|
|
197
|
+
ok = req("/api/dsh-mneme/external-api", "PUT", { host: "https://example.com" });
|
|
198
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
199
|
+
await route.handler(ok, res);
|
|
200
|
+
assert.equal(res.statusCode, 400);
|
|
201
|
+
assert.equal(JSON.parse(res.body).error, "invalid-host");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("external-api PUT applies valid config and echoes it", async () => {
|
|
205
|
+
const { routes } = setup("secret-token");
|
|
206
|
+
const route = handler(routes, "/api/dsh-mneme/external-api");
|
|
207
|
+
|
|
208
|
+
const res = new FakeRes();
|
|
209
|
+
const ok = req("/api/dsh-mneme/external-api", "PUT", { enabled: true, port: 8790, host: "127.0.0.1" });
|
|
210
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
211
|
+
await route.handler(ok, res);
|
|
212
|
+
assert.equal(res.statusCode, 200);
|
|
213
|
+
const cfg = JSON.parse(res.body).config;
|
|
214
|
+
assert.equal(cfg.enabled, true);
|
|
215
|
+
assert.equal(cfg.port, 8790);
|
|
216
|
+
assert.equal(cfg.host, "127.0.0.1");
|
|
217
|
+
assert.ok(typeof cfg.token === "string");
|
|
218
|
+
});
|
package/test/api.test.js
CHANGED
|
@@ -994,3 +994,58 @@ test("GET /api/dsh-mneme/list?archived=only lists just archived rows; default li
|
|
|
994
994
|
await route.handler(req("/api/dsh-mneme/list"), back);
|
|
995
995
|
assert.equal(JSON.parse(back.body).total, 2);
|
|
996
996
|
});
|
|
997
|
+
|
|
998
|
+
test("GET /api/dsh-mneme/list?deposited=only lists dream-touched memories (receipts ∪ source=dream)", async () => {
|
|
999
|
+
const { routes, service } = setup();
|
|
1000
|
+
const plain = service.saveWithDedupe({ type: "preference", title: "无关", content: "plain" });
|
|
1001
|
+
const merged = service.saveWithDedupe({ type: "project", title: "被巩固", content: "merged" });
|
|
1002
|
+
const updated = service.saveWithDedupe({ type: "decision", title: "被更新", content: "updated" });
|
|
1003
|
+
service.saveWithDedupe({ type: "summary", title: "总览", content: "overview", source: "dream" });
|
|
1004
|
+
const ids = [plain, merged, updated].map((r) => r.memory.id);
|
|
1005
|
+
|
|
1006
|
+
// 巩固账本:merge 落在 keepSource、update 落在目标;conflict 只仲裁不落
|
|
1007
|
+
// 内容,不算沉淀。verdict='live' 才有效。
|
|
1008
|
+
service.saveReceipt({
|
|
1009
|
+
receipt_id: "r-merge", run_id: "run-1", record_id: merged.memory.id, kind: "merge",
|
|
1010
|
+
input_digest: "d1", keep_source: merged.memory.id, sources: [merged.memory.id, ids[0]],
|
|
1011
|
+
verdict: "live", count_before: 2, count_after: 1, policy_epoch: 0,
|
|
1012
|
+
created_at: new Date().toISOString()
|
|
1013
|
+
});
|
|
1014
|
+
service.saveReceipt({
|
|
1015
|
+
receipt_id: "r-update", run_id: "run-1", record_id: updated.memory.id, kind: "update",
|
|
1016
|
+
input_digest: "d2", verdict: "live", count_before: 1, count_after: 1,
|
|
1017
|
+
policy_epoch: 0, created_at: new Date().toISOString()
|
|
1018
|
+
});
|
|
1019
|
+
service.saveReceipt({
|
|
1020
|
+
receipt_id: "r-conflict", run_id: "run-1", record_id: "winner-x", kind: "conflict",
|
|
1021
|
+
input_digest: "d3", winner_id: "winner-x", loser_id: "loser-y", verdict: "live",
|
|
1022
|
+
count_before: 2, count_after: 2, policy_epoch: 0, created_at: new Date().toISOString()
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
1026
|
+
const res = new FakeRes();
|
|
1027
|
+
await route.handler(req("/api/dsh-mneme/list?deposited=only"), res);
|
|
1028
|
+
const data = JSON.parse(res.body);
|
|
1029
|
+
assert.deepEqual(
|
|
1030
|
+
data.items.map((m) => m.title).sort(),
|
|
1031
|
+
["总览", "被巩固", "被更新"],
|
|
1032
|
+
"deposited view = receipt merge/update records ∪ source=dream writes"
|
|
1033
|
+
);
|
|
1034
|
+
assert.equal(data.total, 3, "total honors the deposited filter");
|
|
1035
|
+
|
|
1036
|
+
// 默认列表不受 deposited 过滤影响
|
|
1037
|
+
const def = new FakeRes();
|
|
1038
|
+
await route.handler(req("/api/dsh-mneme/list"), def);
|
|
1039
|
+
assert.equal(JSON.parse(def.body).total, 4);
|
|
1040
|
+
|
|
1041
|
+
// 与 archived=only 可叠加:归档的沉淀记忆才出现在交集视图里
|
|
1042
|
+
const upd = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
1043
|
+
const arch = new FakeRes();
|
|
1044
|
+
await upd.handler(req("/api/dsh-mneme/update", "POST", { id: merged.memory.id, archived: true }), arch);
|
|
1045
|
+
assert.equal(arch.statusCode, 200);
|
|
1046
|
+
const both = new FakeRes();
|
|
1047
|
+
await route.handler(req("/api/dsh-mneme/list?deposited=only&archived=only"), both);
|
|
1048
|
+
const bothData = JSON.parse(both.body);
|
|
1049
|
+
assert.deepEqual(bothData.items.map((m) => m.title), ["被巩固"]);
|
|
1050
|
+
assert.equal(bothData.total, 1);
|
|
1051
|
+
});
|
package/test/client.test.js
CHANGED
|
@@ -160,6 +160,104 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
|
|
|
160
160
|
);
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
// Three alignment/softness guarantees born from field feedback: (a) the
|
|
164
|
+
// portalled entry keeps tracking the live New-Session class (host and skin
|
|
165
|
+
// rewrite it asynchronously, so a mount-time snapshot goes stale), (b) it
|
|
166
|
+
// fills the same width as that button and inherits its native centering,
|
|
167
|
+
// (c) the toolbar dropdown escapes the transform stacking trap — the
|
|
168
|
+
// container needs the z-index because transform creates the context.
|
|
169
|
+
test("entry tracks native class, fills native width, and lifts the toolbar dropdown", () => {
|
|
170
|
+
assert.ok(
|
|
171
|
+
clientSource.includes("new MutationObserver"),
|
|
172
|
+
"the entry must re-sync the copied class via MutationObserver"
|
|
173
|
+
);
|
|
174
|
+
assert.ok(
|
|
175
|
+
clientSource.includes('attributeFilter: ["class"]'),
|
|
176
|
+
"the observer must watch class attribute changes"
|
|
177
|
+
);
|
|
178
|
+
assert.ok(
|
|
179
|
+
/\.mneme-topentry-native\{width:100%;/.test(clientSource),
|
|
180
|
+
"the entry button must fill the same width as the New-Session row"
|
|
181
|
+
);
|
|
182
|
+
assert.equal(
|
|
183
|
+
clientSource.includes(".mneme-topentry-native .mneme-topentry-label{flex:1;min-width:0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"),
|
|
184
|
+
false,
|
|
185
|
+
"the old left-aligned label override must go; native centering applies"
|
|
186
|
+
);
|
|
187
|
+
assert.ok(
|
|
188
|
+
/\.mneme-xtools\{[^}]*z-index:3\}/.test(clientSource),
|
|
189
|
+
"the toolbar container must carry z-index:3 (sticky month header is 2, drawer 6)"
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Importance renders as Lucide star glyphs (the morphicons-paired data set;
|
|
194
|
+
// the runtime cannot require the ESM-only morphicons engine, so the path
|
|
195
|
+
// ships inline like the other stroke icons), not raw ★ text. Only the
|
|
196
|
+
// drawer's edit-mode <option> labels keep the text form — SVG cannot render
|
|
197
|
+
// inside <option>.
|
|
198
|
+
test("importance renders as star glyphs, not raw text stars", () => {
|
|
199
|
+
assert.ok(
|
|
200
|
+
clientSource.includes("STAR_PATH_D"),
|
|
201
|
+
"the Lucide star path data must be inlined"
|
|
202
|
+
);
|
|
203
|
+
assert.ok(
|
|
204
|
+
clientSource.includes("const ImportanceStars"),
|
|
205
|
+
"the star-row component must exist"
|
|
206
|
+
);
|
|
207
|
+
assert.equal(
|
|
208
|
+
(clientSource.match(/"★"\.repeat/g) || []).length,
|
|
209
|
+
1,
|
|
210
|
+
"only the drawer edit <option> labels may keep the ★ text form"
|
|
211
|
+
);
|
|
212
|
+
assert.ok(
|
|
213
|
+
/h\(ImportanceStars, \{ className: "mneme-dmetaval"/.test(clientSource),
|
|
214
|
+
"the drawer detail must render the star row"
|
|
215
|
+
);
|
|
216
|
+
assert.ok(
|
|
217
|
+
/h\(ImportanceStars, \{ value: m\.importance/.test(clientSource),
|
|
218
|
+
"the card foot must render the star row"
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// better-sidebar ecosystem integration is an optional capability (official
|
|
223
|
+
// external-plugin-guide §2.2): 'betterSidebar' IS declared in inject (DSH's
|
|
224
|
+
// runtime gates ctx property access on the inject declaration — probing
|
|
225
|
+
// without declaring fails the whole loader entry, verified in the field) and
|
|
226
|
+
// marked as an optional peer dependency, so when it is absent the service
|
|
227
|
+
// resolves to undefined and registration skips gracefully. Registration
|
|
228
|
+
// probes at runtime inside a named ctx.effect (scope cleanup on HMR/disable)
|
|
229
|
+
// with a bounded retry for service-provision ordering.
|
|
230
|
+
test("better-sidebar tab declares optional inject, probes, and skips gracefully", () => {
|
|
231
|
+
assert.ok(
|
|
232
|
+
/const inject = \["slots", "locale", "betterSidebar"\];/.test(clientSource),
|
|
233
|
+
"module inject must declare betterSidebar — DSH gates ctx property access on it"
|
|
234
|
+
);
|
|
235
|
+
assert.ok(
|
|
236
|
+
/const bs = ctx\.betterSidebar;[\s\S]{0,60}typeof bs\.registerTab === "function"/.test(clientSource),
|
|
237
|
+
"the tab registration must still probe ctx.betterSidebar at runtime (undefined when absent)"
|
|
238
|
+
);
|
|
239
|
+
assert.ok(
|
|
240
|
+
/id: "dsh-mneme:memory"/.test(clientSource),
|
|
241
|
+
"the registered tab id must be package-prefixed"
|
|
242
|
+
);
|
|
243
|
+
assert.ok(
|
|
244
|
+
/title: \(\) => t\("memory\.view\.label"\)/.test(clientSource),
|
|
245
|
+
"the tab title must reuse the localized 记忆库 label"
|
|
246
|
+
);
|
|
247
|
+
assert.ok(
|
|
248
|
+
/component: \(\) => h\(MemoryExplorer, \{ t \}\)/.test(clientSource),
|
|
249
|
+
"the tab must reuse the MemoryExplorer views"
|
|
250
|
+
);
|
|
251
|
+
assert.ok(
|
|
252
|
+
clientSource.includes('"dsh-mneme: better-sidebar tab"'),
|
|
253
|
+
"the registration effect must carry a named label for scope cleanup"
|
|
254
|
+
);
|
|
255
|
+
assert.ok(
|
|
256
|
+
/if \(\+\+tries <= 10\) timer = setTimeout\(attempt, 1000\);/.test(clientSource),
|
|
257
|
+
"the service probe must retry with a bound (10 × 1s), not loop forever"
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
163
261
|
// The graph toggle must not read as "share": the primitives share icon is
|
|
164
262
|
// banned and a custom node-graph glyph takes its place.
|
|
165
263
|
test("graph toggle uses a node-graph glyph, not the share icon", () => {
|
|
@@ -174,6 +272,44 @@ test("graph toggle uses a node-graph glyph, not the share icon", () => {
|
|
|
174
272
|
);
|
|
175
273
|
});
|
|
176
274
|
|
|
275
|
+
// 方案 A:查询收敛。状态页只做仪表盘(小页预览 + 服务端 total + 查看全部),
|
|
276
|
+
// 沉淀/归档的完整浏览走记忆库的 deposited/archived 筛选视图(chip 预置 +
|
|
277
|
+
// 状态页入口跳转),详情抽屉给归档记忆一个反向的「恢复」。
|
|
278
|
+
test("status dashboard links into deposited/archived library views", () => {
|
|
279
|
+
assert.ok(
|
|
280
|
+
clientSource.includes('"/api/dsh-mneme/list?deposited=only&limit=8&order=chrono"'),
|
|
281
|
+
"the workbench deposited preview must read the server-side deposited view"
|
|
282
|
+
);
|
|
283
|
+
assert.ok(
|
|
284
|
+
clientSource.includes('"/api/dsh-mneme/list?archived=only&limit=3&order=chrono"'),
|
|
285
|
+
"the workbench archived preview must cap at 3 rows backed by a server total"
|
|
286
|
+
);
|
|
287
|
+
assert.ok(
|
|
288
|
+
clientSource.includes("browseWithFilter"),
|
|
289
|
+
"the status page must jump into the library with preset filters"
|
|
290
|
+
);
|
|
291
|
+
assert.ok(
|
|
292
|
+
/view === "status" && h\(StatusPanel, \{ t, onBrowse: browseWithFilter \}\)/.test(clientSource),
|
|
293
|
+
"the status panel must receive the browse-jump callback"
|
|
294
|
+
);
|
|
295
|
+
assert.ok(
|
|
296
|
+
clientSource.includes('(depositedOnly ? "&deposited=only" : "")'),
|
|
297
|
+
"the library filterQS must carry the deposited chip"
|
|
298
|
+
);
|
|
299
|
+
assert.ok(
|
|
300
|
+
clientSource.includes('(archivedOnly ? "&archived=only" : "")'),
|
|
301
|
+
"the library filterQS must carry the archived chip"
|
|
302
|
+
);
|
|
303
|
+
assert.ok(
|
|
304
|
+
clientSource.includes('postUpdate({ archived: false }, { restored: true })'),
|
|
305
|
+
"the drawer must offer restore for archived memories"
|
|
306
|
+
);
|
|
307
|
+
assert.ok(
|
|
308
|
+
clientSource.includes('"memory.status.viewAll"'),
|
|
309
|
+
"the view-all entries must come from the dictionary"
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
177
313
|
// Every memory feature lives in the main-area library now: the explorer
|
|
178
314
|
// hosts three sub-views (browse / entities / settings) switched by tabs whose
|
|
179
315
|
// labels come from dedicated dictionary keys.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
// lib/ 运行冒烟(v0.7.16 回归补测,重建被 PR#73 删除的 lib-smoke.test.js)。
|
|
8
|
+
// npm 包实际加载的是 lib/(package main → lib/index.js),历史教训 v0.7.8
|
|
9
|
+
// 只改 src 忘了同步 lib 发出去旧产物(issue #65)。这里补两层守护:
|
|
10
|
+
// 1) 静态:src/ 与 lib/ 逐文件字节一致(check-sync.js 的测试版,CI 常驻);
|
|
11
|
+
// 2) 运行时:直接从 lib/ 导入核心模块跑关键链路,证明打包产物可独立加载。
|
|
12
|
+
|
|
13
|
+
const root = fileURLToPath(new URL("..", import.meta.url)); // dsh-mneme/
|
|
14
|
+
const srcDir = join(root, "src");
|
|
15
|
+
const libDir = join(root, "lib");
|
|
16
|
+
|
|
17
|
+
function walk(dir) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
20
|
+
const full = join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory()) out.push(...walk(full));
|
|
22
|
+
else if (entry.isFile()) out.push(full);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("src/ and lib/ are byte-identical (no publish drift)", () => {
|
|
28
|
+
const srcFiles = walk(srcDir);
|
|
29
|
+
assert.ok(srcFiles.length > 0, "src tree is not empty");
|
|
30
|
+
const drift = [];
|
|
31
|
+
for (const file of srcFiles) {
|
|
32
|
+
const rel = relative(srcDir, file);
|
|
33
|
+
const dest = join(libDir, rel);
|
|
34
|
+
if (!readFileSync(dest, "utf8")) {
|
|
35
|
+
drift.push(`missing lib/${rel}`);
|
|
36
|
+
} else if (!readFileSync(file).equals(readFileSync(dest))) {
|
|
37
|
+
drift.push(`differ lib/${rel}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
assert.deepEqual(drift, [], "src↔lib must stay in sync (npm run sync)");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// lib 运行时冒烟:直接加载 npm 实际分发的产物
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
test("lib store+service runs a save/get/count cycle", async () => {
|
|
47
|
+
const { createStore } = await import("../lib/store.js");
|
|
48
|
+
const { createService } = await import("../lib/service.js");
|
|
49
|
+
|
|
50
|
+
const store = createStore(":memory:");
|
|
51
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
52
|
+
const { memory } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
53
|
+
assert.ok(memory.id);
|
|
54
|
+
assert.ok(service.getById(memory.id));
|
|
55
|
+
assert.equal(service.count(), 1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("lib summarize parses extraction JSON robustly", async () => {
|
|
59
|
+
const { parseSummaryJson } = await import("../lib/summarize.js");
|
|
60
|
+
|
|
61
|
+
// 前后夹带杂质的原始输出 → 只取数组
|
|
62
|
+
const parsed = parseSummaryJson("```json\n[{\"type\":\"preference\",\"title\":\"x\",\"content\":\"y\",\"importance\":9}]\n```");
|
|
63
|
+
assert.equal(parsed.length, 1);
|
|
64
|
+
assert.equal(parsed[0].importance, 5, "importance clamped to [1,5]");
|
|
65
|
+
|
|
66
|
+
// 非法 JSON / 非数组 → 空数组,不抛
|
|
67
|
+
assert.deepEqual(parseSummaryJson("no array here"), []);
|
|
68
|
+
assert.deepEqual(parseSummaryJson('{"type":"preference"}'), []);
|
|
69
|
+
assert.deepEqual(parseSummaryJson(""), []);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("lib summarize constructs with autoSummarize disabled", async () => {
|
|
73
|
+
const { createSummarizer } = await import("../lib/summarize.js");
|
|
74
|
+
const summarizer = createSummarizer({}, {}, { autoSummarize: false });
|
|
75
|
+
assert.equal(typeof summarizer.dispose, "function");
|
|
76
|
+
summarizer.dispose();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("lib inject constructs and renders an empty block", async () => {
|
|
80
|
+
const { createInjector } = await import("../lib/inject.js");
|
|
81
|
+
const registered = [];
|
|
82
|
+
const ctx = {
|
|
83
|
+
systemPrompt: {
|
|
84
|
+
context: (def) => { registered.push(def); return () => {}; }
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const service = { injectCandidates: () => [], injectSettings: () => [] };
|
|
88
|
+
const dispose = createInjector(ctx, service, {}, {});
|
|
89
|
+
assert.equal(typeof dispose, "function");
|
|
90
|
+
assert.equal(registered.length, 2, "memory + user-settings contexts registered");
|
|
91
|
+
|
|
92
|
+
const memoryBlock = registered.find((d) => d.name === "memory");
|
|
93
|
+
assert.equal(typeof memoryBlock.text, "function");
|
|
94
|
+
assert.equal(memoryBlock.text({}), ""); // 无候选 → 空块
|
|
95
|
+
dispose();
|
|
96
|
+
});
|