dsh-lite-memory 0.3.0 → 0.15.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.
- package/README.md +73 -17
- package/TRIGGERS.md +18 -14
- package/anchor.js +518 -0
- package/bench/bench.mjs +145 -0
- package/bench/evolution.mjs +123 -0
- package/client.js +1519 -33
- package/docs/BENCH.md +74 -0
- package/docs/RELEASE-0.11.0.md +27 -0
- package/docs/RELEASE-0.11.1.md +11 -0
- package/docs/RELEASE-0.12.0.md +15 -0
- package/docs/RELEASE-0.12.1.md +17 -0
- package/docs/RELEASE-0.12.2.md +17 -0
- package/docs/RELEASE-0.13.0.md +7 -0
- package/docs/RELEASE-0.14.0.md +7 -0
- package/docs/RELEASE-0.15.0.md +8 -0
- package/index.js +2080 -76
- package/learning.js +585 -0
- package/package.json +10 -2
- package/state.js +1476 -0
- package/store.js +2967 -244
- package/test/anchor.test.mjs +436 -0
- package/test/client.test.mjs +114 -0
- package/test/contract.test.mjs +880 -0
- package/test/control.test.mjs +278 -0
- package/test/crossing.test.mjs +372 -0
- package/test/evolution-read.test.mjs +448 -0
- package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
- package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
- package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
- package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
- package/test/fixtures/legacy-v1/index.json +63 -0
- package/test/learning.test.mjs +772 -0
- package/test/lexical.test.mjs +33 -0
- package/test/recall.test.mjs +449 -0
- package/test/scenario.test.mjs +184 -0
- package/test/state.test.mjs +485 -0
- package/test/store.test.mjs +816 -115
- package/test/view.test.mjs +257 -0
- package/view.js +616 -0
|
@@ -0,0 +1,278 @@
|
|
|
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
|
+
|
|
6
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-control-'));
|
|
7
|
+
const { apply } = await import('../index.js');
|
|
8
|
+
const store = await import('../store.js');
|
|
9
|
+
const state = await import('../state.js');
|
|
10
|
+
|
|
11
|
+
const effect = (fn) => {
|
|
12
|
+
const dispose = fn();
|
|
13
|
+
return typeof dispose === 'function' ? dispose : () => {};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function boot() {
|
|
17
|
+
const defs = new Map();
|
|
18
|
+
const handlers = new Map();
|
|
19
|
+
const promptContexts = [];
|
|
20
|
+
const ctx = {
|
|
21
|
+
tools: { register: (def) => { defs.set(def.name, def); return () => {}; } },
|
|
22
|
+
effect,
|
|
23
|
+
inject: () => () => {},
|
|
24
|
+
get: (name) => (name === 'systemPrompt'
|
|
25
|
+
? { context: (entry) => { promptContexts.push(entry); return () => {}; } }
|
|
26
|
+
: name === 'agents' ? { currentInitiator: () => null } : undefined),
|
|
27
|
+
on: (event, handler) => {
|
|
28
|
+
const list = handlers.get(event) || [];
|
|
29
|
+
list.push(handler);
|
|
30
|
+
handlers.set(event, list);
|
|
31
|
+
return () => {};
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
apply(ctx, { mode: 'adaptive' });
|
|
35
|
+
const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
36
|
+
const exec = (name, args, agent) => defs.get(name).execute(args, { agent });
|
|
37
|
+
const preStep = (payload, decision) => handlers.get('agent/pre-step').at(-1)(payload, async () => decision);
|
|
38
|
+
return { exec, preStep, text: (agent, signal) => provider.text({ agent, signal }), debug: globalThis.__liteMemoryRecall };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let seq = 0;
|
|
42
|
+
const human = (body) => { seq += 1; return { role: 'user', id: `msg-${seq}`, source: { kind: 'user' }, content: [{ type: 'text', text: body }] }; };
|
|
43
|
+
const enter = (...messages) => ({ kind: 'enter', messages });
|
|
44
|
+
const mkdir = (label) => fs.mkdtempSync(path.join(os.tmpdir(), `lite-memory-control-${label}-`));
|
|
45
|
+
const makeAgent = (id, dir) => ({ id, cwd: dir, session: { id: `session-${id}`, header: { cwd: dir } } });
|
|
46
|
+
const save = (scope, fields) => store.upsert(scope, {
|
|
47
|
+
applicability: 'all_in_scope',
|
|
48
|
+
body: `正文:${fields.title}`,
|
|
49
|
+
resolution: 'distinct',
|
|
50
|
+
...fields,
|
|
51
|
+
summary: fields.summary ?? `${fields.title}的摘要`,
|
|
52
|
+
});
|
|
53
|
+
const seedRule = (scope, title) => {
|
|
54
|
+
const saved = save(scope, { title, tags: ['口播'] });
|
|
55
|
+
const id = saved.record?.id ?? saved.id;
|
|
56
|
+
state.transact(scope, {}, (draft) => {
|
|
57
|
+
const row = draft.entries.find((entry) => entry.id === id);
|
|
58
|
+
row.origin = 'project_evolution';
|
|
59
|
+
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
60
|
+
row.maintenance.learning = { state: 'active' };
|
|
61
|
+
return { changed: true };
|
|
62
|
+
});
|
|
63
|
+
return id;
|
|
64
|
+
};
|
|
65
|
+
const scopeOf = (dir) => store.scopeOf(dir);
|
|
66
|
+
const toggle = (scope, enabled) => store.setMemoryEnabled(scope, enabled, { operationId: `op-${Math.random().toString(16).slice(2)}` });
|
|
67
|
+
|
|
68
|
+
/* C01:总开关关闭后,自动注入、全局召回、检索、读取、写入、学习全部停。 */
|
|
69
|
+
{
|
|
70
|
+
const dir = mkdir('off');
|
|
71
|
+
const scope = scopeOf(dir);
|
|
72
|
+
const globalSaved = save(store.globalScope(), { title: '全局写作偏好', tags: ['写作'] });
|
|
73
|
+
const globalId = globalSaved.record?.id ?? globalSaved.id;
|
|
74
|
+
save(scope, { title: '项目构建约定', tags: ['构建'] });
|
|
75
|
+
seedRule(scope, '口播开头');
|
|
76
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-on' }).ok, true);
|
|
77
|
+
assert.equal(toggle(scope, false).ok, true);
|
|
78
|
+
assert.equal(store.controlStatus(scope).enabled, false);
|
|
79
|
+
|
|
80
|
+
const runtime = boot();
|
|
81
|
+
const agent = makeAgent('a-off', dir);
|
|
82
|
+
const sig = new AbortController().signal;
|
|
83
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('按全局写作偏好来修构建脚本')));
|
|
84
|
+
assert.equal(runtime.text(agent, sig), '');
|
|
85
|
+
assert.equal(runtime.debug.preparedOf(agent), null);
|
|
86
|
+
|
|
87
|
+
const found = await runtime.exec('memory_search', { query: '写作 构建 口播开头' }, agent);
|
|
88
|
+
assert.equal(found.status, 'memory_disabled');
|
|
89
|
+
assert.equal(found.count, 0);
|
|
90
|
+
assert.ok(globalId);
|
|
91
|
+
assert.equal((await runtime.exec('memory_read', { id: globalId }, agent)).status, 'memory_disabled');
|
|
92
|
+
assert.equal((await runtime.exec('memory_forget', { id: globalId, mode: 'archive' }, agent)).mode, 'memory_disabled');
|
|
93
|
+
const suggested = await runtime.exec('memory_suggest', { title: '新偏好', summary: '一条偏好' }, agent);
|
|
94
|
+
assert.equal(suggested.asked, false);
|
|
95
|
+
const saved = await runtime.exec('memory_save', { title: '关闭后写入', summary: '应当拒绝', body: '不该落盘' }, agent);
|
|
96
|
+
assert.equal(saved.changed, false);
|
|
97
|
+
assert.equal(saved.reason, 'memory_disabled');
|
|
98
|
+
assert.equal(saved.id, '');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* C02:关闭期间所有写入入口都拒绝,自进化不能绕过总开关打开。 */
|
|
102
|
+
{
|
|
103
|
+
const dir = mkdir('write');
|
|
104
|
+
const scope = scopeOf(dir);
|
|
105
|
+
assert.equal(toggle(scope, false).ok, true);
|
|
106
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-blocked' }).ok, false);
|
|
107
|
+
const saved = store.saveEntry(scope, { title: '一条项目规范', summary: '普通摘要', body: '正文', kind: 'convention', scope: 'project', durability: 'durable' });
|
|
108
|
+
assert.equal(saved.reasonCode, 'memory_disabled');
|
|
109
|
+
const task = store.saveTaskState(scope, { version: 1, sessions: {}, episodes: {}, defaults: null, revision: 1 }, { operationId: 'task-blocked' });
|
|
110
|
+
assert.equal(task.ok, false);
|
|
111
|
+
const committed = store.commitLearning(scope, { title: '学习规则', summary: '摘要', body: '正文' }, {}, { operationId: 'learn-blocked' });
|
|
112
|
+
assert.equal(committed.ok, false);
|
|
113
|
+
const run = store.setEvolution(scope, false, { operationId: 'evo-off' });
|
|
114
|
+
assert.equal(run.ok, false);
|
|
115
|
+
assert.equal(run.code, 'memory_disabled');
|
|
116
|
+
assert.equal(store.controlStatus(scope).enabled, false);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* C03:关一个项目不影响另一个;重新打开记忆后自进化仍保持关闭。 */
|
|
120
|
+
{
|
|
121
|
+
const dirA = mkdir('iso-a');
|
|
122
|
+
const dirB = mkdir('iso-b');
|
|
123
|
+
const scopeA = scopeOf(dirA);
|
|
124
|
+
const scopeB = scopeOf(dirB);
|
|
125
|
+
save(scopeB, { title: 'B 项目构建约定', tags: ['构建', '脚本'] });
|
|
126
|
+
seedRule(scopeB, '口播开头');
|
|
127
|
+
assert.equal(store.setEvolution(scopeB, true, { operationId: 'evo-b' }).ok, true);
|
|
128
|
+
assert.equal(toggle(scopeA, false).ok, true);
|
|
129
|
+
assert.equal(store.evolutionStatus(scopeB).enabled, true);
|
|
130
|
+
assert.equal(store.controlStatus(scopeB).enabled, true);
|
|
131
|
+
|
|
132
|
+
const runtime = boot();
|
|
133
|
+
const agentB = makeAgent('b-live', dirB);
|
|
134
|
+
const sigB = new AbortController().signal;
|
|
135
|
+
await runtime.preStep({ agent: agentB, signal: sigB, turn: 't1' }, enter(human('按项目构建约定改脚本')));
|
|
136
|
+
assert.match(runtime.text(agentB, sigB), /B 项目构建约定/);
|
|
137
|
+
|
|
138
|
+
toggle(scopeB, false);
|
|
139
|
+
assert.equal(store.evolutionStatus(scopeB).enabled, false);
|
|
140
|
+
assert.equal(store.evolutionStatus(scopeB).configuredEnabled, false);
|
|
141
|
+
toggle(scopeB, true);
|
|
142
|
+
assert.equal(store.evolutionStatus(scopeB).enabled, false);
|
|
143
|
+
assert.equal(store.setEvolution(scopeB, true, { operationId: 'evo-b-again' }).ok, true);
|
|
144
|
+
assert.equal(store.evolutionStatus(scopeB).enabled, true);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* C04:切换总开关让旧任务上下文立即失效,不拿关闭前的绑定继续注入。 */
|
|
148
|
+
{
|
|
149
|
+
const dir = mkdir('stale');
|
|
150
|
+
const scope = scopeOf(dir);
|
|
151
|
+
seedRule(scope, '口播开头');
|
|
152
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-stale' }).ok, true);
|
|
153
|
+
const runtime = boot();
|
|
154
|
+
const agent = makeAgent('a-stale', dir);
|
|
155
|
+
const sig = new AbortController().signal;
|
|
156
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播开头怎么改')));
|
|
157
|
+
const held = runtime.debug.windowOf(agent).experiment;
|
|
158
|
+
assert.ok(held && held.contextId);
|
|
159
|
+
toggle(scope, false);
|
|
160
|
+
toggle(scope, true);
|
|
161
|
+
const stale = await runtime.exec('memory_search', { query: '口播开头', contextId: held.contextId }, agent);
|
|
162
|
+
assert.equal(stale.status !== 'ok', true);
|
|
163
|
+
assert.equal((await runtime.exec('memory_search', { query: '口播开头' }, agent)).status, 'ok');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* C05:关闭保留候选、试用、结果与整理回执;重开记忆后自进化仍是关闭。 */
|
|
167
|
+
{
|
|
168
|
+
const dir = mkdir('preserve');
|
|
169
|
+
const scope = scopeOf(dir);
|
|
170
|
+
const id = seedRule(scope, '口播开头');
|
|
171
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-preserve' }).ok, true);
|
|
172
|
+
const before = store.learningSnapshot(scope);
|
|
173
|
+
assert.equal(before.counts.active, 1);
|
|
174
|
+
assert.equal(toggle(scope, false).ok, true);
|
|
175
|
+
const closed = store.learningSnapshot(scope);
|
|
176
|
+
assert.equal(closed.enabled, false);
|
|
177
|
+
assert.equal(closed.rules.length, before.rules.length);
|
|
178
|
+
assert.equal(closed.counts.active, 1);
|
|
179
|
+
assert.equal(toggle(scope, true).ok, true);
|
|
180
|
+
const reopened = store.learningSnapshot(scope);
|
|
181
|
+
assert.equal(reopened.enabled, false);
|
|
182
|
+
assert.equal(reopened.counts.active, 1);
|
|
183
|
+
assert.equal(store.evolutionStatus(scope).configuredEnabled, false);
|
|
184
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-resume' }).ok, true);
|
|
185
|
+
assert.equal(store.learningSnapshot(scope).counts.active, 1);
|
|
186
|
+
assert.ok(id);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* C06:关闭期间管理面写操作一律拒绝,只读快照不写盘。 */
|
|
190
|
+
{
|
|
191
|
+
const dir = mkdir('managed-off');
|
|
192
|
+
const scope = scopeOf(dir);
|
|
193
|
+
const id = seedRule(scope, '口播开头');
|
|
194
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-managed' }).ok, true);
|
|
195
|
+
toggle(scope, false);
|
|
196
|
+
const file = state.stateFiles(scope).current;
|
|
197
|
+
const before = fs.readFileSync(file);
|
|
198
|
+
const snapshot = store.learningSnapshot(scope);
|
|
199
|
+
const rule = snapshot.rules[0];
|
|
200
|
+
assert.equal(rule.id, id);
|
|
201
|
+
assert.equal(Buffer.compare(before, fs.readFileSync(file)), 0);
|
|
202
|
+
for (const action of ['pin', 'pause', 'resume', 'delete', 'undo', 'adopt', 'edit']) {
|
|
203
|
+
let result;
|
|
204
|
+
if (action === 'pin') result = store.pinLearningRule(scope, id, { pinned: true, expectedRevision: rule.revision });
|
|
205
|
+
if (action === 'pause') result = store.pauseLearningRules(scope, [id], { operationId: 'pause-off' });
|
|
206
|
+
if (action === 'resume') result = store.resumeLearningRule(scope, id);
|
|
207
|
+
if (action === 'delete') result = store.removeLearningRule(scope, id, { expectedRevision: rule.revision });
|
|
208
|
+
if (action === 'undo') result = store.undoLearningChange(scope, id, { expectedRevision: rule.revision });
|
|
209
|
+
if (action === 'adopt') result = store.adoptLearningRule(scope, id, { expectedRevision: rule.revision });
|
|
210
|
+
if (action === 'edit') result = store.editLearningRule(scope, id, { expectedRevision: rule.revision, body: '改过的正文' });
|
|
211
|
+
assert.equal(result.ok, false, action);
|
|
212
|
+
assert.equal(result.code, 'memory_disabled', action);
|
|
213
|
+
}
|
|
214
|
+
assert.equal(Buffer.compare(before, fs.readFileSync(file)), 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* C07:采纳把候选转为生效;编辑只保存并留上一版,不改状态以外的东西。 */
|
|
218
|
+
{
|
|
219
|
+
const dir = mkdir('adopt');
|
|
220
|
+
const scope = scopeOf(dir);
|
|
221
|
+
const id = seedRule(scope, '口播开头');
|
|
222
|
+
const first = store.learningSnapshot(scope).rules[0];
|
|
223
|
+
const edited = store.editLearningRule(scope, id, { expectedRevision: first.revision, body: '开头两句内给结论。', title: '口播结论前置' });
|
|
224
|
+
assert.equal(edited.ok, true);
|
|
225
|
+
const afterEdit = store.learningSnapshot(scope).rules[0];
|
|
226
|
+
assert.equal(afterEdit.state, 'trial');
|
|
227
|
+
assert.equal(afterEdit.body, '开头两句内给结论。');
|
|
228
|
+
assert.equal(afterEdit.title, '口播结论前置');
|
|
229
|
+
assert.equal(afterEdit.learning.versions.length, 1);
|
|
230
|
+
const adopted = store.adoptLearningRule(scope, id, { expectedRevision: afterEdit.revision });
|
|
231
|
+
assert.equal(adopted.ok, true);
|
|
232
|
+
const afterAdopt = store.learningSnapshot(scope).rules[0];
|
|
233
|
+
assert.equal(afterAdopt.state, 'active');
|
|
234
|
+
assert.equal(afterAdopt.body, '开头两句内给结论。');
|
|
235
|
+
assert.equal(afterAdopt.title, '口播结论前置');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* C08:关闭保留已消耗额度与默认绑定,重开后仍按原额度限额。 */
|
|
239
|
+
{
|
|
240
|
+
const dir = mkdir('preserve-quota');
|
|
241
|
+
const scope = scopeOf(dir);
|
|
242
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-quota' }).ok, true);
|
|
243
|
+
const defaults = { taskKind: 'video', seriesId: '口播' };
|
|
244
|
+
assert.equal(store.saveTaskState(scope, { version: 1, sessions: {}, episodes: {}, defaults, revision: 1 }, { operationId: 'defaults' }).ok, true);
|
|
245
|
+
assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a1' }).ok, true);
|
|
246
|
+
assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a2' }).ok, true);
|
|
247
|
+
assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a3' }).ok, false);
|
|
248
|
+
toggle(scope, false);
|
|
249
|
+
toggle(scope, true);
|
|
250
|
+
assert.deepEqual(store.taskState(scope).defaults, { ...defaults, confirmedBy: 'user', confirmedAt: '' });
|
|
251
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-quota-again' }).ok, true);
|
|
252
|
+
const limited = store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a4' });
|
|
253
|
+
assert.equal(limited.ok, false);
|
|
254
|
+
assert.equal(limited.reason, 'episode_limit');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* C09:同一项目两个会话都已准备内容,关闭后两边都立即失效。 */
|
|
258
|
+
{
|
|
259
|
+
const dir = mkdir('two-sessions');
|
|
260
|
+
const scope = scopeOf(dir);
|
|
261
|
+
seedRule(scope, '口播开头');
|
|
262
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-two' }).ok, true);
|
|
263
|
+
const runtime = boot();
|
|
264
|
+
const a1 = makeAgent('two-1', dir);
|
|
265
|
+
const a2 = makeAgent('two-2', dir);
|
|
266
|
+
const sig = new AbortController().signal;
|
|
267
|
+
await runtime.preStep({ agent: a1, signal: sig, turn: 't1' }, enter(human('这期口播开头怎么改')));
|
|
268
|
+
await runtime.preStep({ agent: a2, signal: sig, turn: 't1' }, enter(human('这期口播开头怎么改')));
|
|
269
|
+
assert.match(runtime.text(a1, sig), /口播开头/);
|
|
270
|
+
assert.match(runtime.text(a2, sig), /口播开头/);
|
|
271
|
+
toggle(scope, false);
|
|
272
|
+
assert.equal(runtime.text(a1, sig), '');
|
|
273
|
+
assert.equal(runtime.text(a2, sig), '');
|
|
274
|
+
assert.equal(runtime.debug.preparedOf(a1).text, '');
|
|
275
|
+
assert.equal(runtime.debug.preparedOf(a2).text, '');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
console.log('control.test.mjs: all passed');
|
|
@@ -0,0 +1,372 @@
|
|
|
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
|
+
|
|
6
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-cross-'));
|
|
7
|
+
const store = await import('../store.js');
|
|
8
|
+
const state = await import('../state.js');
|
|
9
|
+
const view = await import('../view.js');
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
saveEntry, routeScope, visibleRules, move, resumeMove, movePending,
|
|
13
|
+
upsert, forget, readLinks, loadIndex, scopeOf, globalScope, readEntry,
|
|
14
|
+
} = store;
|
|
15
|
+
|
|
16
|
+
let passed = 0;
|
|
17
|
+
const test = async (name, fn) => {
|
|
18
|
+
try {
|
|
19
|
+
await fn();
|
|
20
|
+
passed += 1;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error(`FAIL ${name}`);
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const dirs = [];
|
|
28
|
+
const fresh = (label) => {
|
|
29
|
+
const dir = path.join(os.tmpdir(), `lite-memory-cross-${label}-${Math.random().toString(16).slice(2, 8)}`);
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
dirs.push(dir);
|
|
32
|
+
return scopeOf(dir);
|
|
33
|
+
};
|
|
34
|
+
const stateOf = (scope) => {
|
|
35
|
+
const file = path.join(scope.dir, 'state.json');
|
|
36
|
+
if (!fs.existsSync(file)) return { entries: [] };
|
|
37
|
+
return JSON.parse(fs.readFileSync(file, 'utf8')).payload;
|
|
38
|
+
};
|
|
39
|
+
const idsOf = (scope) => stateOf(scope).entries.map((row) => row.id);
|
|
40
|
+
const idsOfPrev = (scope) => {
|
|
41
|
+
const file = path.join(scope.dir, 'state.prev.json');
|
|
42
|
+
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')).payload.entries.map((row) => row.id) : [];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ── S03 范围路由 ────────────────────────────────────────────────────────────
|
|
46
|
+
await test('S03 个人偏好没有显式范围就进全局层,项目层不落副本', () => {
|
|
47
|
+
const scope = fresh('route-pref');
|
|
48
|
+
const saved = saveEntry(scope, { title: '回答用中文', summary: '回复语言偏好', body: '中文、简短', memoryClass: 'personal_preference' });
|
|
49
|
+
assert.equal(saved.chosenScope, 'global');
|
|
50
|
+
assert.equal(saved.reasonCode, 'class_personal_preference');
|
|
51
|
+
assert.equal(idsOf(scope).length, 0);
|
|
52
|
+
assert.equal(stateOf(globalScope()).entries.filter((row) => row.current.title === '回答用中文').length, 1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await test('S03 项目事实留在项目层', () => {
|
|
56
|
+
const scope = fresh('route-project');
|
|
57
|
+
const saved = saveEntry(scope, { title: '构建命令', summary: '构建用 pnpm', body: 'pnpm build', memoryClass: 'project_fact' });
|
|
58
|
+
assert.equal(saved.chosenScope, 'project');
|
|
59
|
+
assert.equal(idsOf(scope).length, 1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await test('S03 设备事实没给设备条件就返回 needsScope,不落盘', () => {
|
|
63
|
+
const scope = fresh('route-env');
|
|
64
|
+
const saved = saveEntry(scope, { title: '本机 Node', summary: '本机 Node 版本', body: 'v24', memoryClass: 'environment_fact' });
|
|
65
|
+
assert.equal(saved.needsScope, true);
|
|
66
|
+
assert.equal(saved.reasonCode, 'needs_environment');
|
|
67
|
+
assert.equal(idsOf(scope).length, 0);
|
|
68
|
+
assert.deepEqual(saved.requiredConditions.map((row) => row.key), ['machine', 'runtime']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await test('S03 设备事实带条件进全局层且是条件适用', () => {
|
|
72
|
+
const scope = fresh('route-env-ok');
|
|
73
|
+
const saved = saveEntry(scope, {
|
|
74
|
+
title: '本机 Node', summary: '本机 Node 版本', body: 'v24', memoryClass: 'environment_fact',
|
|
75
|
+
conditions: [{ key: 'machine', value: 'mac-mini' }],
|
|
76
|
+
});
|
|
77
|
+
assert.equal(saved.chosenScope, 'global');
|
|
78
|
+
assert.equal(saved.record.applicability, 'conditional');
|
|
79
|
+
assert.equal(idsOf(scope).length, 0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await test('S03 一次性任务参数不落盘;显式范围压过类别提案', () => {
|
|
83
|
+
const scope = fresh('route-explicit');
|
|
84
|
+
const task = saveEntry(scope, { title: '临时端口', summary: '这次跑在 8080', body: '8080', durability: 'task' });
|
|
85
|
+
assert.equal(task.needsScope, true);
|
|
86
|
+
assert.equal(task.reasonCode, 'task_state');
|
|
87
|
+
const explicit = saveEntry(scope, { title: '项目内偏好', summary: '只在这个项目用', body: 'x', memoryClass: 'personal_preference', scope: 'project' });
|
|
88
|
+
assert.equal(explicit.chosenScope, 'project');
|
|
89
|
+
assert.equal(explicit.reasonCode, 'explicit');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── S06 两层去重 ────────────────────────────────────────────────────────────
|
|
93
|
+
await test('S06 两层完全相同只返回已有引用,不写第二条', () => {
|
|
94
|
+
const scope = fresh('dedupe-exact');
|
|
95
|
+
const globalSaved = saveEntry(globalScope(), {
|
|
96
|
+
title: '统一时区', summary: '日志都写 UTC', body: 'UTC', scope: 'global', resolution: 'distinct',
|
|
97
|
+
});
|
|
98
|
+
assert.equal(globalSaved.chosenScope, 'global');
|
|
99
|
+
const again = saveEntry(scope, { title: '统一时区', summary: '日志都写 UTC', body: 'UTC' });
|
|
100
|
+
assert.ok(again.existing);
|
|
101
|
+
assert.equal(again.existing.id, globalSaved.record.id);
|
|
102
|
+
assert.equal(again.existing.scope, 'global');
|
|
103
|
+
assert.equal(idsOf(scope).length, 0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await test('S06 两层只是相近只给候选或确认,不直接新增', () => {
|
|
107
|
+
const scope = fresh('dedupe-near');
|
|
108
|
+
saveEntry(globalScope(), { title: '发布前检查清单', summary: '发版前逐项确认', body: '清单', scope: 'global', resolution: 'distinct' });
|
|
109
|
+
const near = saveEntry(scope, { title: '发布前检查清单', summary: '发版前逐项确认(项目补充)', body: '项目清单' });
|
|
110
|
+
assert.equal(near.needsResolution, true);
|
|
111
|
+
assert.equal(near.crossLayer, true);
|
|
112
|
+
assert.equal(idsOf(scope).length, 0);
|
|
113
|
+
assert.equal(near.similarList.length >= 1, true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── S07 槽位:项目覆盖 / 回落 / 条件未知 ────────────────────────────────────
|
|
117
|
+
const seedSlot = (scope, layer, extra) => saveEntry(layer === 'global' ? globalScope() : scope, {
|
|
118
|
+
title: extra.title, summary: extra.summary, body: extra.body || '正文',
|
|
119
|
+
topicKey: 'tooling.pm', resolution: 'distinct', ...extra,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('S07 项目层命中时只给项目规范,全局同名槽位被压住', () => {
|
|
123
|
+
const scope = fresh('slot-match');
|
|
124
|
+
seedSlot(scope, 'global', { title: '包管理器', summary: '用 pnpm', applicability: 'all_in_scope' });
|
|
125
|
+
seedSlot(scope, 'project', {
|
|
126
|
+
title: '包管理器', summary: '这个项目用 npm', applicability: 'conditional',
|
|
127
|
+
conditions: [{ key: 'runtime', value: 'node' }],
|
|
128
|
+
});
|
|
129
|
+
const { winners } = visibleRules(scope, { runtime: 'node' });
|
|
130
|
+
const titles = winners.filter((row) => row.key === 'tooling.pm').map((row) => row.record.summary);
|
|
131
|
+
assert.deepEqual(titles, ['这个项目用 npm']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await test('S07 项目层条件不匹配时回落到全局', () => {
|
|
135
|
+
const scope = fresh('slot-fallback');
|
|
136
|
+
seedSlot(scope, 'global', { title: '包管理器', summary: '用 pnpm', applicability: 'all_in_scope' });
|
|
137
|
+
seedSlot(scope, 'project', {
|
|
138
|
+
title: '包管理器', summary: '这个项目用 npm', applicability: 'conditional',
|
|
139
|
+
conditions: [{ key: 'runtime', value: 'node' }],
|
|
140
|
+
});
|
|
141
|
+
const { winners } = visibleRules(scope, { runtime: 'bun' });
|
|
142
|
+
const rows = winners.filter((row) => row.key === 'tooling.pm');
|
|
143
|
+
assert.equal(rows.length, 1);
|
|
144
|
+
assert.equal(rows[0].record.summary, '用 pnpm');
|
|
145
|
+
assert.equal(rows[0].scope, 'global');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('S07 项目层条件未知时整组暂缓,不拿全局顶上', () => {
|
|
149
|
+
const scope = fresh('slot-unknown');
|
|
150
|
+
seedSlot(scope, 'global', { title: '包管理器', summary: '用 pnpm', applicability: 'all_in_scope' });
|
|
151
|
+
seedSlot(scope, 'project', {
|
|
152
|
+
title: '包管理器', summary: '这个项目用 npm', applicability: 'conditional',
|
|
153
|
+
conditions: [{ key: 'runtime', value: 'node' }],
|
|
154
|
+
});
|
|
155
|
+
const { resolved } = visibleRules(scope, {});
|
|
156
|
+
const group = resolved.get('tooling.pm');
|
|
157
|
+
assert.equal(group.status, 'needs_context');
|
|
158
|
+
assert.equal(group.record, null);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await test('S08 同层同槽位不同内容判冲突,不靠命中顺序仲裁', () => {
|
|
162
|
+
const scope = fresh('slot-conflict');
|
|
163
|
+
upsert(scope, { title: '包管理器一', summary: '用 pnpm', body: 'a', topicKey: 'tooling.pm', applicability: 'all_in_scope' });
|
|
164
|
+
const second = saveEntry(scope, {
|
|
165
|
+
title: '包管理器二', summary: '用 npm', body: 'b', topicKey: 'tooling.pm', applicability: 'all_in_scope', resolution: 'distinct',
|
|
166
|
+
});
|
|
167
|
+
assert.equal(second.chosenScope, 'project');
|
|
168
|
+
const { resolved } = visibleRules(scope, {});
|
|
169
|
+
assert.equal(resolved.get('tooling.pm').status, 'conflict');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await test('S08 同层同槽位同条件再存要确认,不静默新建', () => {
|
|
173
|
+
const scope = fresh('slot-same');
|
|
174
|
+
const first = upsert(scope, { title: '唯一槽位甲', summary: '槽位测试甲', body: 'a', topicKey: 'tooling.slotonly', applicability: 'all_in_scope' });
|
|
175
|
+
const again = saveEntry(scope, { title: '唯一槽位甲 补充', summary: '槽位测试甲', body: 'b', topicKey: 'tooling.slotonly', applicability: 'all_in_scope' });
|
|
176
|
+
assert.equal(again.needsResolution, true);
|
|
177
|
+
assert.equal(again.sameTopic, true);
|
|
178
|
+
assert.equal(again.similarList[0].id, first.record.id);
|
|
179
|
+
assert.equal(idsOf(scope).length, 1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── S09 / D09 搬移 ──────────────────────────────────────────────────────────
|
|
183
|
+
await test('S09 项目搬到全局:新 id、旧 id 失效、prev 不留旧条目', () => {
|
|
184
|
+
const scope = fresh('move-up');
|
|
185
|
+
const saved = saveEntry(scope, {
|
|
186
|
+
title: '提交信息格式', summary: '都用 type: 前缀', body: 'type: subject', topicKey: 'git.commit',
|
|
187
|
+
});
|
|
188
|
+
const before = saved.record.id;
|
|
189
|
+
const done = move(scope, before, { target: 'global' });
|
|
190
|
+
assert.equal(done.status, 'moved');
|
|
191
|
+
assert.notEqual(done.id, before);
|
|
192
|
+
assert.equal(idsOf(scope).includes(before), false);
|
|
193
|
+
assert.equal(idsOfPrev(scope).includes(before), false);
|
|
194
|
+
const moved = stateOf(globalScope()).entries.find((row) => row.id === done.id);
|
|
195
|
+
assert.ok(moved);
|
|
196
|
+
assert.equal(moved.current.body, 'type: subject');
|
|
197
|
+
assert.equal(moved.current.topicKey, 'git.commit');
|
|
198
|
+
assert.equal(movePending(), null);
|
|
199
|
+
assert.equal(readEntry(globalScope(), done.id).body, 'type: subject');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await test('S09 续跑只认发起项目的身份,换项目被拒绝', () => {
|
|
203
|
+
const a = fresh('resume-a');
|
|
204
|
+
const b = fresh('resume-b');
|
|
205
|
+
const saved = saveEntry(a, {
|
|
206
|
+
title: '续跑归属', summary: '搬移中断后只能回原项目续跑', body: 'x', scope: 'project', resolution: 'distinct',
|
|
207
|
+
});
|
|
208
|
+
const pending = move(a, saved.record.id, { target: 'global', stopAfter: 'source' });
|
|
209
|
+
assert.equal(pending.status, 'move_pending');
|
|
210
|
+
assert.throws(() => resumeMove(b), (error) => error.code === 'conflict' && /另一个项目/.test(error.message));
|
|
211
|
+
assert.ok(movePending());
|
|
212
|
+
const done = resumeMove(a);
|
|
213
|
+
assert.equal(done.status, 'moved');
|
|
214
|
+
assert.equal(movePending(), null);
|
|
215
|
+
assert.equal(idsOf(a).includes(saved.record.id), false);
|
|
216
|
+
assert.equal(stateOf(globalScope()).entries.some((row) => row.id === done.id), true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await test('S09 续跑前源 current 丢失时从 prev 恢复,但不会复活旧条目', () => {
|
|
220
|
+
const scope = fresh('resume-prev');
|
|
221
|
+
const saved = saveEntry(scope, {
|
|
222
|
+
title: '恢复不复活', summary: 'prev 里有旧条目也不能带回来', body: 'x', scope: 'project', resolution: 'distinct',
|
|
223
|
+
});
|
|
224
|
+
const stale = fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8');
|
|
225
|
+
const pending = move(scope, saved.record.id, { target: 'global', stopAfter: 'source' });
|
|
226
|
+
assert.equal(pending.stage, 'source_cleaned');
|
|
227
|
+
fs.rmSync(path.join(scope.dir, 'state.json'));
|
|
228
|
+
fs.writeFileSync(path.join(scope.dir, 'state.prev.json'), stale);
|
|
229
|
+
const done = resumeMove(scope);
|
|
230
|
+
assert.equal(done.status, 'moved');
|
|
231
|
+
assert.equal(idsOf(scope).includes(saved.record.id), false);
|
|
232
|
+
assert.equal(idsOfPrev(scope).includes(saved.record.id), false);
|
|
233
|
+
assert.equal(movePending(), null);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await test('S09 收尾会重建 prev;两侧状态对不上就保留协调文件', () => {
|
|
237
|
+
const scope = fresh('resume-verify');
|
|
238
|
+
const saved = saveEntry(scope, {
|
|
239
|
+
title: '收尾校验', summary: 'prev 可重建,状态对不上不结案', body: 'x', scope: 'project', resolution: 'distinct',
|
|
240
|
+
});
|
|
241
|
+
const pending = move(scope, saved.record.id, { target: 'global', stopAfter: 'source' });
|
|
242
|
+
assert.equal(pending.status, 'move_pending');
|
|
243
|
+
fs.rmSync(path.join(globalScope().dir, 'state.prev.json'), { force: true });
|
|
244
|
+
const repaired = resumeMove(scope);
|
|
245
|
+
assert.equal(repaired.status, 'moved');
|
|
246
|
+
assert.equal(idsOfPrev(globalScope()).includes(repaired.id), true);
|
|
247
|
+
assert.equal(movePending(), null);
|
|
248
|
+
|
|
249
|
+
const other = fresh('resume-verify-2');
|
|
250
|
+
const record = saveEntry(other, {
|
|
251
|
+
title: '两侧对不上', summary: '源层状态丢了就不假装搬完', body: 'x', scope: 'project', resolution: 'distinct',
|
|
252
|
+
});
|
|
253
|
+
const pending2 = move(other, record.record.id, { target: 'global', stopAfter: 'source' });
|
|
254
|
+
assert.equal(pending2.status, 'move_pending');
|
|
255
|
+
fs.rmSync(path.join(other.dir, 'state.json'));
|
|
256
|
+
fs.rmSync(path.join(other.dir, 'state.prev.json'), { force: true });
|
|
257
|
+
assert.throws(() => resumeMove(other), (error) => error.code === 'recover_failed' || error.code === 'unavailable');
|
|
258
|
+
assert.ok(movePending());
|
|
259
|
+
state.clearMoveIntent();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await test('S09 全局搬回项目走同一套协议', () => {
|
|
263
|
+
const scope = fresh('move-down');
|
|
264
|
+
const saved = saveEntry(globalScope(), { title: '回滚命令', summary: '回滚用 rollback', body: 'rollback', scope: 'global', resolution: 'distinct' });
|
|
265
|
+
const done = move(scope, saved.record.id, { target: 'project' });
|
|
266
|
+
assert.equal(done.status, 'moved');
|
|
267
|
+
assert.equal(done.from, 'global');
|
|
268
|
+
assert.equal(done.to, 'project');
|
|
269
|
+
assert.equal(stateOf(globalScope()).entries.some((row) => row.id === saved.record.id), false);
|
|
270
|
+
assert.ok(idsOf(scope).includes(done.id));
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await test('D09 目标层已有相同内容就拒绝搬移,不落协调文件', () => {
|
|
274
|
+
const scope = fresh('move-dup');
|
|
275
|
+
const source = saveEntry(scope, { title: '日志目录', summary: '日志在 logs', body: 'logs/', scope: 'project', resolution: 'distinct' });
|
|
276
|
+
saveEntry(globalScope(), { title: '日志目录', summary: '日志在 logs', body: 'logs/', scope: 'global', resolution: 'distinct' });
|
|
277
|
+
assert.throws(() => move(scope, source.record.id, { target: 'global' }), /目标层已有相同内容/);
|
|
278
|
+
assert.equal(movePending(), null);
|
|
279
|
+
assert.equal(idsOf(scope).includes(source.record.id), true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await test('S09 出边在目标层不可见就拒绝搬移', () => {
|
|
283
|
+
const scope = fresh('move-links');
|
|
284
|
+
const target = saveEntry(scope, { title: '发布脚本', summary: '发版执行这个', body: './release.sh', scope: 'project', resolution: 'distinct' });
|
|
285
|
+
const source = saveEntry(scope, {
|
|
286
|
+
title: '发布检查', summary: '发版前先检查', body: '检查', scope: 'project', resolution: 'distinct',
|
|
287
|
+
links: [{ type: 'related', id: target.record.id }],
|
|
288
|
+
});
|
|
289
|
+
assert.throws(() => move(scope, source.record.id, { target: 'global' }), /在目标层不可见/);
|
|
290
|
+
assert.equal(movePending(), null);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await test('D09 目标写入后中断:两层读取被屏障挡住,resume 收尾后只剩一份', () => {
|
|
294
|
+
const scope = fresh('move-crash');
|
|
295
|
+
const saved = saveEntry(scope, { title: '端口约定', summary: '本地端口 49631', body: '49631', scope: 'project', resolution: 'distinct' });
|
|
296
|
+
const done = move(scope, saved.record.id, { target: 'global', stopAfter: 'target' });
|
|
297
|
+
assert.equal(done.status, 'move_pending');
|
|
298
|
+
assert.ok(movePending());
|
|
299
|
+
assert.throws(() => loadIndex(scope), /正在跨层搬移/);
|
|
300
|
+
assert.throws(() => loadIndex(globalScope()), /正在跨层搬移/);
|
|
301
|
+
const resumed = resumeMove(scope);
|
|
302
|
+
assert.equal(resumed.status, 'moved');
|
|
303
|
+
assert.equal(movePending(), null);
|
|
304
|
+
assert.equal(stateOf(globalScope()).entries.filter((row) => row.id === resumed.id).length, 1);
|
|
305
|
+
assert.equal(idsOf(scope).includes(saved.record.id), false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await test('D09 协调文件损坏时报错,不当成没有搬移', () => {
|
|
309
|
+
const scope = fresh('move-corrupt');
|
|
310
|
+
const saved = saveEntry(scope, { title: '缓存目录', summary: '缓存在 .cache', body: '.cache', scope: 'project', resolution: 'distinct' });
|
|
311
|
+
fs.writeFileSync(path.join(state.memoryRoot(), 'move.intent.json'), '{broken');
|
|
312
|
+
assert.throws(() => move(scope, saved.record.id, { target: 'global' }), /协调文件已损坏/);
|
|
313
|
+
state.clearMoveIntent();
|
|
314
|
+
assert.equal(movePending(), null);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ── L01–L06 关联 ────────────────────────────────────────────────────────────
|
|
318
|
+
await test('L01/L02 关联不能指向自己,也不能重复', () => {
|
|
319
|
+
const scope = fresh('link-self');
|
|
320
|
+
const saved = saveEntry(scope, { title: '部署步骤', summary: '部署按这个来', body: '步骤', scope: 'project', resolution: 'distinct' });
|
|
321
|
+
assert.throws(() => upsert(scope, { id: saved.record.id, title: '部署步骤', links: [{ type: 'related', id: saved.record.id }] }), /不能关联自己/);
|
|
322
|
+
const other = saveEntry(scope, { title: '回滚步骤', summary: '回滚按这个来', body: '步骤', scope: 'project', resolution: 'distinct' });
|
|
323
|
+
assert.throws(() => upsert(scope, { id: saved.record.id, title: '部署步骤', links: [{ type: 'related', id: other.record.id }, { type: 'related', id: other.record.id }] }), /重复/);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await test('L03 关联最多 3 条', () => {
|
|
327
|
+
const scope = fresh('link-max');
|
|
328
|
+
const ids = [];
|
|
329
|
+
for (const n of [1, 2, 3, 4]) {
|
|
330
|
+
ids.push(saveEntry(scope, { title: `关联目标 ${n}`, summary: `目标 ${n}`, body: 'x', scope: 'project', resolution: 'distinct' }).record.id);
|
|
331
|
+
}
|
|
332
|
+
assert.throws(() => upsert(scope, { title: '关联四条', summary: '四个关联', body: 'x', links: ids.map((id) => ({ type: 'related', id })) }), /最多 3/);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await test('L04 项目条目可以关联全局条目,全局条目不能关联项目条目', () => {
|
|
336
|
+
const scope = fresh('link-layers');
|
|
337
|
+
const globalSaved = saveEntry(globalScope(), { title: '全局约定', summary: '全局层的约定', body: 'x', scope: 'global', resolution: 'distinct' });
|
|
338
|
+
const projectLinked = saveEntry(scope, {
|
|
339
|
+
title: '项目条目', summary: '关联全局', body: 'x', scope: 'project', resolution: 'distinct', links: [{ type: 'related', id: globalSaved.record.id }],
|
|
340
|
+
});
|
|
341
|
+
assert.equal(projectLinked.needsResolution, false);
|
|
342
|
+
assert.equal(view.linksOf(projectLinked.record).length, 1);
|
|
343
|
+
assert.throws(() => upsert(globalScope(), { title: '全局条目', summary: '想关联项目', body: 'x', links: [{ type: 'related', id: projectLinked.record.id }] }), /关联目标不存在/);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
await test('L05 关联目标被归档后读出来是失效引用,不悄悄消失', () => {
|
|
347
|
+
const scope = fresh('link-archive');
|
|
348
|
+
const target = saveEntry(scope, { title: '被归档目标', summary: '之后会被归档', body: 'x', scope: 'project', resolution: 'distinct' });
|
|
349
|
+
const source = saveEntry(scope, {
|
|
350
|
+
title: '关联方', summary: '关联了一个目标', body: 'x', scope: 'project', resolution: 'distinct', links: [{ type: 'related', id: target.record.id }],
|
|
351
|
+
});
|
|
352
|
+
forget(scope, target.record.id, { mode: 'archive' });
|
|
353
|
+
const links = readLinks(scope, source.record.id).links;
|
|
354
|
+
assert.equal(links.length, 1);
|
|
355
|
+
assert.equal(links[0].status, 'invalid');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
await test('L06 展示视图不把关联结构塞给模型', () => {
|
|
359
|
+
const scope = fresh('link-view');
|
|
360
|
+
const target = saveEntry(scope, { title: '视图目标', summary: '视图测试', body: 'x', scope: 'project', resolution: 'distinct' });
|
|
361
|
+
const source = saveEntry(scope, {
|
|
362
|
+
title: '视图关联方', summary: '视图测试', body: 'x', scope: 'project', resolution: 'distinct', links: [{ type: 'related', id: target.record.id }],
|
|
363
|
+
});
|
|
364
|
+
const search = view.toModelView(source.record, 'search');
|
|
365
|
+
assert.equal('links' in search, false);
|
|
366
|
+
const read = view.toModelView(source.record, 'read');
|
|
367
|
+
assert.equal('links' in read, false);
|
|
368
|
+
assert.equal(read.related, 1);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
372
|
+
console.log(`crossing.test.mjs: ${passed} passed`);
|