dsh-memsys 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import {
8
+ buildSummary,
9
+ createSummaryHandler,
10
+ readUiConfig,
11
+ resolveAutoInject,
12
+ writeUiConfig,
13
+ } from '../src/runtime/summary.mjs';
14
+ import { insertRow } from '../src/storage/repositories.mjs';
15
+ import { apply } from '../index.mjs';
16
+ import {
17
+ createTestContext,
18
+ makeCandidate,
19
+ makeMemory,
20
+ makeObservation,
21
+ mutableClock,
22
+ } from './helpers/fixtures.mjs';
23
+
24
+ const ROOT = path.resolve(import.meta.dirname, '..');
25
+
26
+ function fakeRes() {
27
+ return {
28
+ statusCode: 0,
29
+ headers: {},
30
+ body: '',
31
+ setHeader(key, value) {
32
+ this.headers[key] = value;
33
+ },
34
+ end(chunk) {
35
+ this.body += chunk ?? '';
36
+ },
37
+ json() {
38
+ return JSON.parse(this.body);
39
+ },
40
+ };
41
+ }
42
+
43
+ function fakeReq({ method = 'GET', headers = {}, body = null, remoteAddress = '127.0.0.1' } = {}) {
44
+ const listeners = new Map();
45
+ const req = {
46
+ method,
47
+ headers,
48
+ url: '/dsh-memsys/api/summary',
49
+ socket: { remoteAddress },
50
+ on(event, handler) {
51
+ listeners.set(event, handler);
52
+ return req;
53
+ },
54
+ flush() {
55
+ if (body !== null) listeners.get('data')?.(body);
56
+ listeners.get('end')?.();
57
+ },
58
+ };
59
+ return req;
60
+ }
61
+
62
+ function silentLogger() {
63
+ const warnings = [];
64
+ return { warnings, warn: (message) => warnings.push(message) };
65
+ }
66
+
67
+ /** Records `ctx.inject` scopes; `webServer: null` means "this profile has none". */
68
+ function fakeHost({ webServer = null, routes = [] } = {}) {
69
+ const scopes = [];
70
+ const ctx = {
71
+ inject(services, callback) {
72
+ scopes.push([...services]);
73
+ if (services.includes('webServer')) {
74
+ if (!webServer) return () => {}; // cordis never runs a scope whose service is absent
75
+ callback({ webServer, effect: (fn) => fn() });
76
+ return () => {};
77
+ }
78
+ callback({});
79
+ return () => {};
80
+ },
81
+ };
82
+ void routes;
83
+ return { ctx, scopes };
84
+ }
85
+
86
+ test('P6/UI: summary route shape has three counts and five recent memories', (t) => {
87
+ const ctx = createTestContext({ clock: mutableClock().clock });
88
+ t.after(() => ctx.cleanup());
89
+ const summary = buildSummary(ctx.main, { dataDir: ctx.dir, autoInject: false });
90
+ assert.deepEqual(
91
+ Object.keys(summary).sort(),
92
+ ['auto_inject', 'auto_inject_source', 'candidates', 'data_dir', 'memories', 'pending_due', 'recent'],
93
+ );
94
+ assert.equal(summary.memories, 0);
95
+ assert.equal(summary.candidates, 0);
96
+ assert.equal(summary.pending_due, 0);
97
+ assert.equal(summary.auto_inject_source, 'default');
98
+ assert.ok(Array.isArray(summary.recent));
99
+ assert.equal(typeof summary.data_dir, 'string');
100
+
101
+ const handler = createSummaryHandler({ main: ctx.main, dataDir: ctx.dir, getAutoInject: () => ({ value: false, source: 'profile' }) });
102
+ const res = fakeRes();
103
+ handler({ method: 'GET', headers: {}, socket: { remoteAddress: '127.0.0.1' }, url: '/dsh-memsys/api/summary' }, res);
104
+ assert.equal(res.statusCode, 200);
105
+ assert.equal(res.headers['content-type'], 'application/json');
106
+ assert.equal(res.json().memories, 0);
107
+ assert.equal(res.json().auto_inject_source, 'profile');
108
+
109
+ const denied = fakeRes();
110
+ handler({ method: 'GET', headers: {}, socket: { remoteAddress: '8.8.8.8' }, url: '/dsh-memsys/api/summary' }, denied);
111
+ assert.equal(denied.statusCode, 403);
112
+ assert.match(denied.body, /forbidden/);
113
+
114
+ const cross = fakeRes();
115
+ handler(
116
+ { method: 'GET', headers: { origin: 'https://evil.example', host: '127.0.0.1:1' }, socket: { remoteAddress: '127.0.0.1' }, url: '/dsh-memsys/api/summary' },
117
+ cross,
118
+ );
119
+ assert.equal(cross.statusCode, 403);
120
+ });
121
+
122
+ test('P6/UI: counts only open candidates, pending-due memories, and truncates recent text', (t) => {
123
+ const ctx = createTestContext({ clock: mutableClock().clock });
124
+ t.after(() => ctx.cleanup());
125
+
126
+ const observation = makeObservation(ctx, { text: '今天在工地把气瓶的阀门换了,顺手把记录补上。' });
127
+ insertRow(ctx.main, 'observations', observation);
128
+
129
+ const pendingCandidate = makeCandidate(ctx, observation, { businessKey: 'ui-cand-pending' });
130
+ const promotedCandidate = makeCandidate(ctx, observation, { businessKey: 'ui-cand-promoted', status: 'promoted' });
131
+ insertRow(ctx.main, 'candidates', pendingCandidate);
132
+ insertRow(ctx.main, 'candidates', promotedCandidate);
133
+
134
+ const longText = '鲸'.repeat(150);
135
+ const dueCandidate = makeCandidate(ctx, observation, { businessKey: 'ui-cand-due', text: longText });
136
+ insertRow(ctx.main, 'candidates', dueCandidate);
137
+ const dueMemory = makeMemory(ctx, dueCandidate, { dueState: 'pending-due' });
138
+ insertRow(ctx.main, 'memories', dueMemory);
139
+ insertRow(ctx.main, 'memories', makeMemory(ctx, promotedCandidate, { businessKey: 'ui-mem-plain' }));
140
+
141
+ const summary = buildSummary(ctx.main, { dataDir: ctx.dir, autoInject: false });
142
+ assert.equal(summary.memories, 2);
143
+ assert.equal(summary.candidates, 2, 'promoted candidates are history, not "候选"');
144
+ assert.equal(summary.pending_due, 1);
145
+ assert.equal(summary.recent.length, 2);
146
+ assert.equal(Array.from(summary.recent.find((row) => row.id === dueMemory.id).text).length, 80);
147
+ });
148
+
149
+ test('P6/UI: ui-config.json wins over profile config, and a bad file falls back with one warning', () => {
150
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-prio-'));
151
+ try {
152
+ assert.deepEqual(resolveAutoInject({ dataDir: dir, profileValue: true }), { value: true, source: 'profile' });
153
+ assert.deepEqual(resolveAutoInject({ dataDir: dir }), { value: false, source: 'default' });
154
+
155
+ writeUiConfig(dir, false);
156
+ assert.deepEqual(readUiConfig(dir), { auto_inject: false });
157
+ assert.deepEqual(resolveAutoInject({ dataDir: dir, profileValue: true }), { value: false, source: 'ui-config' });
158
+ assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'ui-config.json'), 'utf8')).version, 1);
159
+
160
+ const file = path.join(dir, 'ui-config.json');
161
+ fs.writeFileSync(file, '{ not json', 'utf8');
162
+ const badJson = silentLogger();
163
+ assert.deepEqual(resolveAutoInject({ dataDir: dir, profileValue: true, logger: badJson }), { value: true, source: 'profile' });
164
+ assert.equal(badJson.warnings.length, 1, 'a corrupt file warns once and never throws');
165
+
166
+ fs.writeFileSync(file, JSON.stringify({ version: 99, auto_inject: true }), 'utf8');
167
+ const badVersion = silentLogger();
168
+ assert.deepEqual(resolveAutoInject({ dataDir: dir, profileValue: false, logger: badVersion }), { value: false, source: 'profile' });
169
+ assert.equal(badVersion.warnings.length, 1);
170
+ } finally {
171
+ fs.rmSync(dir, { recursive: true, force: true });
172
+ }
173
+ });
174
+
175
+ test('P6/UI: POST config persists the switch and applies it live', (t) => {
176
+ const ctx = createTestContext({ clock: mutableClock().clock });
177
+ t.after(() => ctx.cleanup());
178
+
179
+ let live = { value: false, source: 'profile' };
180
+ let applied = 0;
181
+ const handler = createSummaryHandler({
182
+ main: () => ctx.main,
183
+ dataDir: ctx.dir,
184
+ getAutoInject: () => live,
185
+ onAutoInject: (next) => {
186
+ writeUiConfig(ctx.dir, next);
187
+ live = { value: next, source: 'ui-config' };
188
+ applied += 1;
189
+ },
190
+ });
191
+
192
+ const res = fakeRes();
193
+ const req = fakeReq({
194
+ method: 'POST',
195
+ headers: { 'content-type': 'application/json', host: '127.0.0.1:50478' },
196
+ body: JSON.stringify({ auto_inject: true }),
197
+ });
198
+ handler(req, res);
199
+ req.flush();
200
+
201
+ assert.equal(res.statusCode, 200);
202
+ assert.deepEqual(res.json(), { ok: true, auto_inject: true, auto_inject_source: 'ui-config' });
203
+ assert.equal(live.value, true, 'the running process sees the new value without a restart');
204
+ assert.equal(applied, 1);
205
+ assert.deepEqual(readUiConfig(ctx.dir), { auto_inject: true }, 'the switch survives a restart');
206
+ assert.equal(fs.existsSync(path.join(ctx.dir, 'ui-config.json')), true);
207
+
208
+ const summary = fakeRes();
209
+ handler(fakeReq({ headers: { host: '127.0.0.1:50478' } }), summary);
210
+ assert.equal(summary.json().auto_inject, true);
211
+ assert.equal(summary.json().auto_inject_source, 'ui-config');
212
+
213
+ const wrongType = fakeRes();
214
+ const badReq = fakeReq({ method: 'POST', headers: { 'content-type': 'text/plain', host: '127.0.0.1:50478' }, body: 'auto_inject=1' });
215
+ handler(badReq, wrongType);
216
+ badReq.flush();
217
+ assert.equal(wrongType.statusCode, 415);
218
+ });
219
+
220
+ test('P6/UI: browser half registers the panel, the sidebar row and the settings section', () => {
221
+ const code = fs.readFileSync(path.join(ROOT, 'client.js'), 'utf8');
222
+
223
+ let loaded = null;
224
+ const window = { __ModuleLoader__: { load: (mod) => { loaded = mod; } } };
225
+ new Function('window', code)(window);
226
+ assert.equal(loaded?.id, 'dsh-memsys', 'the module id must equal the package name');
227
+
228
+ const React = { createElement: () => null, useState: () => [null, () => {}], useEffect: () => {} };
229
+ const plugin = loaded.factory((name, version) => {
230
+ assert.equal(name, 'react');
231
+ assert.equal(version, undefined);
232
+ return React;
233
+ });
234
+
235
+ const registered = [];
236
+ const slots = {
237
+ inject(name, callback) {
238
+ assert.ok(['main', 'sidebar.panellist', 'settings.section'].includes(name));
239
+ callback();
240
+ },
241
+ register(spec, component) {
242
+ registered.push({ spec, component });
243
+ return () => {};
244
+ },
245
+ };
246
+ assert.deepEqual(plugin.inject, ['slots']);
247
+ plugin.apply({ slots });
248
+
249
+ const byName = Object.fromEntries(registered.map((entry) => [entry.spec.name, entry]));
250
+ assert.equal(byName.main.spec.key, 'dsh-memsys');
251
+ assert.equal(byName['sidebar.panellist'].spec.id, 'dsh-memsys');
252
+ assert.equal(byName['sidebar.panellist'].spec.label(), '记忆');
253
+ assert.equal(byName['settings.section'].spec.id, 'dsh-memsys');
254
+ assert.equal(byName['settings.section'].spec.label(), '记忆系统');
255
+ for (const entry of registered) assert.equal(typeof entry.component, 'function');
256
+ });
257
+
258
+ test('P6/UI: headless boot without webServer never activates the route scope', async () => {
259
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-headless-'));
260
+ const { ctx, scopes } = fakeHost({ webServer: null });
261
+
262
+ const dispose = await apply(ctx, { data_dir: dir, auto_inject: false });
263
+ try {
264
+ assert.equal(typeof dispose, 'function');
265
+ assert.ok(
266
+ scopes.some((services) => services.includes('webServer')),
267
+ 'the optional webServer scope is requested',
268
+ );
269
+ assert.equal(fs.existsSync(path.join(dir, 'ui-config.json')), false, 'a headless boot writes nothing');
270
+ } finally {
271
+ dispose();
272
+ fs.rmSync(dir, { recursive: true, force: true });
273
+ }
274
+ });
275
+
276
+ test('P6/UI: with webServer the two exact routes serve loopback and reject cross-origin', async () => {
277
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memsys-ui-web-'));
278
+ const routes = [];
279
+ const webServer = {
280
+ register(route) {
281
+ routes.push(route);
282
+ return () => {};
283
+ },
284
+ };
285
+ const { ctx } = fakeHost({ webServer });
286
+
287
+ const dispose = await apply(ctx, { data_dir: dir, auto_inject: false });
288
+ try {
289
+ assert.deepEqual(
290
+ routes.map((route) => [route.kind, route.path]),
291
+ [
292
+ ['exact', '/dsh-memsys/api/summary'],
293
+ ['exact', '/dsh-memsys/api/config'],
294
+ ],
295
+ );
296
+
297
+ const ok = fakeRes();
298
+ routes[0].handler(fakeReq({ headers: { host: '127.0.0.1:50478' } }), ok);
299
+ assert.equal(ok.statusCode, 200);
300
+ assert.equal(ok.json().auto_inject, false);
301
+ assert.equal(ok.json().auto_inject_source, 'profile', 'the profile patch value is the fallback rank');
302
+
303
+ const cross = fakeRes();
304
+ routes[0].handler(fakeReq({ headers: { origin: 'https://evil.example', host: '127.0.0.1:50478' } }), cross);
305
+ assert.equal(cross.statusCode, 403);
306
+ } finally {
307
+ dispose();
308
+ }
309
+ });