dsh-memu-memory 1.2.1 → 1.2.2
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/AGENTS.md +1 -1
- package/README.md +12 -5
- package/README.zh.md +4 -2
- package/docs/contributing.md +16 -3
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -4
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +2 -2
- package/lib/client.js +1361 -146
- package/lib/consolidation-worker.js +30 -8
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1252 -91
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +51 -9
- package/lib/inbound.js +19 -0
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +282 -17
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +32 -3
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +577 -9
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +36 -18
- package/lib/tools.js +168 -10
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +1 -1
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-fork.test.mjs +14 -12
- package/tests/consolidation-snapshot.test.mjs +39 -0
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +52 -5
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +35 -2
- package/tests/graph.test.mjs +96 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +33 -4
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +188 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
// 词典管理面(RPC / 模型工具 / 备份导出 / 卡片流 UI)的接口与界面层测试。
|
|
2
|
+
//
|
|
3
|
+
// 当前版本起公开生命周期只有 active / archived:pending 是 legacy 数据、rejected 是内部
|
|
4
|
+
// 墓碑,二者都不出现在列表、页签计数与检索里;模型写入直接生效,人工只做编辑 /
|
|
5
|
+
// 归档 / 恢复 / 删除。本文件的断言只针对这一层自身的行为(授权、形状、映射、
|
|
6
|
+
// allowlist、导入冲突语义),不锁定核心内部实现。
|
|
7
|
+
import test from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
|
|
14
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-lexicon-ui-'))
|
|
15
|
+
process.env.DSH_MEMU_HOME = home
|
|
16
|
+
|
|
17
|
+
const state = await import('../lib/state.js')
|
|
18
|
+
const { dispatch } = await import('../lib/rpc.js')
|
|
19
|
+
const { registerTools } = await import('../lib/tools.js')
|
|
20
|
+
const backup = await import('../lib/backup.js')
|
|
21
|
+
const { dataDir, writeJson } = await import('../lib/paths.js')
|
|
22
|
+
|
|
23
|
+
test.after(() => {
|
|
24
|
+
try { state.flushState() } catch {}
|
|
25
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const lib = state.createLibrary({ name: 'lexicon-main', cwd: path.join(home, 'lexicon-main') })
|
|
29
|
+
const readLexiconFile = (libraryId) => JSON.parse(fs.readFileSync(path.join(dataDir(), 'lexicon', `${libraryId}.json`), 'utf8'))
|
|
30
|
+
const writeLexiconFile = (libraryId, store) => writeJson(path.join(dataDir(), 'lexicon', `${libraryId}.json`), store)
|
|
31
|
+
const termIdOf = (libraryId, term) => Object.entries(readLexiconFile(libraryId).terms).find(([, e]) => e.term === term)?.[0] || null
|
|
32
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
33
|
+
|
|
34
|
+
function fakeToolCtx() {
|
|
35
|
+
const tools = []
|
|
36
|
+
const ctx = {
|
|
37
|
+
tools: { register(tool) { tools.push(tool); return () => {} } },
|
|
38
|
+
logger: { warn() {} },
|
|
39
|
+
}
|
|
40
|
+
const dispose = registerTools(ctx)
|
|
41
|
+
return { tools, dispose }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const toolsExec = (sessionId) => ({ agent: { session: { header: { id: sessionId, cwd: path.join(home, 'lexicon-main'), agentPreset: 'rp' } } } })
|
|
45
|
+
|
|
46
|
+
// 直接写盘模拟 legacy / 墓碑数据:条目形状只保留列表与索引读取需要的字段。
|
|
47
|
+
function legacyEntry(term, status, extra = {}) {
|
|
48
|
+
const stamp = '2026-01-01T00:00:00.000Z'
|
|
49
|
+
return {
|
|
50
|
+
term,
|
|
51
|
+
normalizedTerm: term,
|
|
52
|
+
kind: 'term',
|
|
53
|
+
meaning: `legacy ${status}`,
|
|
54
|
+
usage: '',
|
|
55
|
+
aliases: [],
|
|
56
|
+
normalizedAliases: [],
|
|
57
|
+
examples: [],
|
|
58
|
+
context: {},
|
|
59
|
+
visibility: 'shared',
|
|
60
|
+
sensitivity: 'normal',
|
|
61
|
+
confidence: 0.8,
|
|
62
|
+
evidence: [],
|
|
63
|
+
status,
|
|
64
|
+
source: 'model-proposal',
|
|
65
|
+
createdAt: stamp,
|
|
66
|
+
updatedAt: stamp,
|
|
67
|
+
hitCounts: { candidate: 0, injection: 0, query: 0 },
|
|
68
|
+
strength: 0.6,
|
|
69
|
+
...extra,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test('lexiconList defaults to active; archived tab, stable statusCounts, includeArchived and pagination follow the RPC', async () => {
|
|
74
|
+
await assert.rejects(() => dispatch({}, 'lexiconList', { libraryId: 'lib-nope' }), /library not found/)
|
|
75
|
+
await dispatch({}, 'lexiconAdd', { libraryId: lib.id, term: '糖刀', kind: 'meme', meaning: '表面发糖、实际虐心的剧情' })
|
|
76
|
+
await sleep(12)
|
|
77
|
+
await dispatch({}, 'lexiconAdd', { libraryId: lib.id, term: '红牌', kind: 'trigger', sensitivity: 'sensitive', meaning: '角色行为的红线信号' })
|
|
78
|
+
await sleep(12)
|
|
79
|
+
await dispatch({}, 'lexiconAdd', { libraryId: lib.id, term: '咖啡上头', kind: 'slang', meaning: '连喝三杯后的亢奋状态' })
|
|
80
|
+
const view = await dispatch({}, 'lexiconList', { libraryId: lib.id })
|
|
81
|
+
assert.equal(view.libraryId, lib.id)
|
|
82
|
+
assert.equal(view.page, 1)
|
|
83
|
+
assert.equal(view.pages, 1)
|
|
84
|
+
assert.equal(view.total, 3)
|
|
85
|
+
assert.deepEqual(view.counts, { slang: 1, meme: 1, trigger: 1, term: 0 })
|
|
86
|
+
assert.deepEqual(view.statusCounts, { active: 3, archived: 0 }, 'the default tab is active only')
|
|
87
|
+
// 分页排序 updatedAt desc:最后写入的排最前。
|
|
88
|
+
assert.deepEqual(view.items.map((row) => row.term), ['咖啡上头', '红牌', '糖刀'])
|
|
89
|
+
assert.ok(view.items.every((row) => row.status === 'active'))
|
|
90
|
+
// 只投影会话可见字段:核心内部的规范化表/指纹不上 RPC。
|
|
91
|
+
for (const row of view.items) {
|
|
92
|
+
assert.equal(Object.hasOwn(row, 'normalizedTerm'), false)
|
|
93
|
+
assert.equal(Object.hasOwn(row, 'normalizedAliases'), false)
|
|
94
|
+
assert.equal(Object.hasOwn(row, 'fingerprint'), false)
|
|
95
|
+
assert.ok(row.id && row.term && row.kind)
|
|
96
|
+
}
|
|
97
|
+
const sensitive = await dispatch({}, 'lexiconList', { libraryId: lib.id, sensitivity: 'sensitive' })
|
|
98
|
+
assert.deepEqual(sensitive.items.map((row) => row.term), ['红牌'])
|
|
99
|
+
const slang = await dispatch({}, 'lexiconList', { libraryId: lib.id, kind: 'slang' })
|
|
100
|
+
assert.deepEqual(slang.items.map((row) => row.term), ['咖啡上头'])
|
|
101
|
+
assert.deepEqual(slang.counts, { slang: 1, meme: 1, trigger: 1, term: 0 }, 'kind 计数跟随 kind 以外的过滤')
|
|
102
|
+
assert.deepEqual(slang.statusCounts, { active: 3, archived: 0 }, 'page-tab counts do not move with kind/search filters')
|
|
103
|
+
const byQuery = await dispatch({}, 'lexiconList', { libraryId: lib.id, q: '红线' })
|
|
104
|
+
assert.deepEqual(byQuery.items.map((row) => row.term), ['红牌'])
|
|
105
|
+
assert.deepEqual(byQuery.statusCounts, { active: 3, archived: 0 })
|
|
106
|
+
|
|
107
|
+
// 分页:每页 2 条,第 2 页拿走剩余 1 条;越界页与超大 pageSize 被 clamp。
|
|
108
|
+
const paged = await dispatch({}, 'lexiconList', { libraryId: lib.id, pageSize: 2, page: 1 })
|
|
109
|
+
assert.equal(paged.pages, 2)
|
|
110
|
+
assert.equal(paged.total, 3)
|
|
111
|
+
assert.deepEqual(paged.items.map((row) => row.term), ['咖啡上头', '红牌'])
|
|
112
|
+
const page2 = await dispatch({}, 'lexiconList', { libraryId: lib.id, pageSize: 2, page: 2 })
|
|
113
|
+
assert.deepEqual(page2.items.map((row) => row.term), ['糖刀'])
|
|
114
|
+
const clamped = await dispatch({}, 'lexiconList', { libraryId: lib.id, pageSize: 9999, page: 99 })
|
|
115
|
+
assert.equal(clamped.page, 1)
|
|
116
|
+
assert.ok(clamped.pageSize <= 200)
|
|
117
|
+
|
|
118
|
+
// 归档:离开 active 页签,但仍在归档页签 / includeArchived 里可恢复。
|
|
119
|
+
const knifeId = view.items.find((row) => row.term === '糖刀').id
|
|
120
|
+
const archived = await dispatch({}, 'lexiconArchive', { libraryId: lib.id, termId: knifeId })
|
|
121
|
+
assert.equal(archived.action, 'archive')
|
|
122
|
+
assert.equal(archived.term.status, 'archived')
|
|
123
|
+
const afterArchive = await dispatch({}, 'lexiconList', { libraryId: lib.id })
|
|
124
|
+
assert.equal(afterArchive.total, 2)
|
|
125
|
+
assert.deepEqual(afterArchive.statusCounts, { active: 2, archived: 1 })
|
|
126
|
+
assert.ok(!afterArchive.items.some((row) => row.id === knifeId))
|
|
127
|
+
const archivedTab = await dispatch({}, 'lexiconList', { libraryId: lib.id, status: 'archived' })
|
|
128
|
+
assert.deepEqual(archivedTab.items.map((row) => row.term), ['糖刀'])
|
|
129
|
+
assert.deepEqual(archivedTab.statusCounts, { active: 2, archived: 1 })
|
|
130
|
+
const both = await dispatch({}, 'lexiconList', { libraryId: lib.id, includeArchived: true })
|
|
131
|
+
assert.equal(both.total, 3)
|
|
132
|
+
const explicitWins = await dispatch({}, 'lexiconList', { libraryId: lib.id, includeArchived: true, status: 'active' })
|
|
133
|
+
assert.deepEqual(explicitWins.items.map((row) => row.term), afterArchive.items.map((row) => row.term), 'an explicit status outranks includeArchived')
|
|
134
|
+
// 归档词条默认搜不到;只有管理面显式 includeArchived 才返回。
|
|
135
|
+
assert.equal((await dispatch({}, 'lexiconSearch', { libraryId: lib.id, q: '糖刀' })).items.length, 0)
|
|
136
|
+
const archivedSearch = await dispatch({}, 'lexiconSearch', { libraryId: lib.id, q: '糖刀', includeArchived: true })
|
|
137
|
+
assert.equal(archivedSearch.items.length, 1)
|
|
138
|
+
assert.equal(archivedSearch.items[0].status, 'archived')
|
|
139
|
+
assert.equal((await dispatch({}, 'lexiconSearch', { libraryId: lib.id, q: '糖刀', includePending: true })).items.length, 0, 'includePending is gone')
|
|
140
|
+
// 恢复:重新回到 active 页签并重新可搜。
|
|
141
|
+
const restored = await dispatch({}, 'lexiconRestore', { libraryId: lib.id, termId: knifeId })
|
|
142
|
+
assert.equal(restored.action, 'restore')
|
|
143
|
+
assert.equal(restored.term.status, 'active')
|
|
144
|
+
assert.equal((await dispatch({}, 'lexiconList', { libraryId: lib.id })).total, 3)
|
|
145
|
+
assert.equal((await dispatch({}, 'lexiconList', { libraryId: lib.id, status: 'archived' })).total, 0)
|
|
146
|
+
assert.equal((await dispatch({}, 'lexiconSearch', { libraryId: lib.id, q: '糖刀' })).items.length, 1)
|
|
147
|
+
|
|
148
|
+
// 生命周期只认两态;pending/rejected/deleted 不是可筛选的状态。
|
|
149
|
+
for (const status of ['pending', 'rejected', 'deleted']) {
|
|
150
|
+
await assert.rejects(() => dispatch({}, 'lexiconList', { libraryId: lib.id, status }), /unknown lexicon status/)
|
|
151
|
+
}
|
|
152
|
+
await assert.rejects(() => dispatch({}, 'lexiconRestore', { libraryId: lib.id, termId: knifeId }), /only archived terms can be restored/)
|
|
153
|
+
await assert.rejects(() => dispatch({}, 'lexiconArchive', { libraryId: lib.id, termId: 'lex-nope' }), /does not exist/)
|
|
154
|
+
await assert.rejects(() => dispatch({}, 'lexiconArchive', { libraryId: lib.id }), /termId is required/)
|
|
155
|
+
await assert.rejects(() => dispatch({}, 'lexiconRestore', { libraryId: lib.id }), /termId is required/)
|
|
156
|
+
// 旧审核方法彻底消失:lexiconReview 不再是 dispatch 的入口。
|
|
157
|
+
await assert.rejects(() => dispatch({}, 'lexiconReview', { libraryId: lib.id, termId: knifeId, action: 'approve' }), /unknown endpoint/)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('pending and rejected rows are internal only: never listed, counted or searchable', async () => {
|
|
161
|
+
const store = readLexiconFile(lib.id)
|
|
162
|
+
store.terms['lex-legacy-pending-qa'] = legacyEntry('老待审', 'pending')
|
|
163
|
+
store.terms['lex-tombstone-qa'] = legacyEntry('墓碑词', 'rejected', { fingerprint: 'deadbeef' })
|
|
164
|
+
writeLexiconFile(lib.id, store)
|
|
165
|
+
|
|
166
|
+
const active = await dispatch({}, 'lexiconList', { libraryId: lib.id })
|
|
167
|
+
assert.equal(active.total, 3, 'legacy pending and rejected tombstones are not listed')
|
|
168
|
+
assert.deepEqual(active.statusCounts, { active: 3, archived: 0 }, 'tombstones never count toward the tabs')
|
|
169
|
+
assert.ok(!active.items.some((row) => ['pending', 'rejected'].includes(row.status)))
|
|
170
|
+
const archived = await dispatch({}, 'lexiconList', { libraryId: lib.id, status: 'archived' })
|
|
171
|
+
assert.equal(archived.total, 0)
|
|
172
|
+
const both = await dispatch({}, 'lexiconList', { libraryId: lib.id, includeArchived: true })
|
|
173
|
+
assert.equal(both.total, 3)
|
|
174
|
+
assert.equal((await dispatch({}, 'lexiconSearch', { libraryId: lib.id, q: '老待审' })).items.length, 0, 'legacy pending never searches')
|
|
175
|
+
assert.equal((await dispatch({}, 'lexiconSearch', { libraryId: lib.id, q: '墓碑词', includeArchived: true })).items.length, 0, 'rejected tombstones never search')
|
|
176
|
+
|
|
177
|
+
// 清理手工数据,后面的导入/备份用例不背着 legacy 行。
|
|
178
|
+
const clean = readLexiconFile(lib.id)
|
|
179
|
+
delete clean.terms['lex-legacy-pending-qa']
|
|
180
|
+
delete clean.terms['lex-tombstone-qa']
|
|
181
|
+
writeLexiconFile(lib.id, clean)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('lexiconList and lexiconSearch resolve the library from the session binding', async () => {
|
|
185
|
+
const sessionLib = state.createLibrary({ name: 'lexicon-session', cwd: path.join(home, 'lexicon-session') })
|
|
186
|
+
state.ensureSession('lex-session-mapped', { cwd: path.join(home, 'lexicon-session') })
|
|
187
|
+
await dispatch({}, 'lexiconAdd', { sessionId: 'lex-session-mapped', term: '月引', kind: 'term', meaning: '月下引路的仪式' })
|
|
188
|
+
const view = await dispatch({}, 'lexiconList', { sessionId: 'lex-session-mapped' })
|
|
189
|
+
assert.equal(view.libraryId, sessionLib.id)
|
|
190
|
+
// 未绑定会话没有可映射的库。
|
|
191
|
+
await assert.rejects(() => dispatch({}, 'lexiconList', { sessionId: 'lex-never-bound' }), /not bound/)
|
|
192
|
+
// 搜索:会话路径带 memoryContext 做可见过滤;显式库路径是管理视角。
|
|
193
|
+
await dispatch({}, 'lexiconAdd', { sessionId: 'lex-session-mapped', term: '雾航', kind: 'term', meaning: '雾夜航行的禁忌', context: { worldId: 'world-a' } })
|
|
194
|
+
await dispatch({}, 'setMemoryContext', { sessionId: 'lex-session-mapped', context: { worldId: 'world-a' } })
|
|
195
|
+
const visible = await dispatch({}, 'lexiconSearch', { sessionId: 'lex-session-mapped', q: '雾航' })
|
|
196
|
+
assert.equal(visible.items.length, 1)
|
|
197
|
+
await dispatch({}, 'setMemoryContext', { sessionId: 'lex-session-mapped', context: { worldId: 'world-b' } })
|
|
198
|
+
const blocked = await dispatch({}, 'lexiconSearch', { sessionId: 'lex-session-mapped', q: '雾航' })
|
|
199
|
+
assert.equal(blocked.items.length, 0, 'context 越界的词条不通过会话搜索搭车返回')
|
|
200
|
+
await assert.rejects(() => dispatch({}, 'lexiconSearch', { libraryId: sessionLib.id }), /q is required/)
|
|
201
|
+
// 显式选库(管理视角)没有会话场景:无作用域词条照常命中;带 worldId 作用域的
|
|
202
|
+
// 词条与空白 context 不可见——与记忆的 contextVisible 同一把尺,词典不另造权限。
|
|
203
|
+
const admin = await dispatch({}, 'lexiconSearch', { libraryId: sessionLib.id, q: '月引' })
|
|
204
|
+
assert.equal(admin.items.length, 1)
|
|
205
|
+
assert.equal(admin.items[0].id, termIdOf(sessionLib.id, '月引'))
|
|
206
|
+
const adminScoped = await dispatch({}, 'lexiconSearch', { libraryId: sessionLib.id, q: '雾航' })
|
|
207
|
+
assert.equal(adminScoped.items.length, 0, 'worldId 作用域词条对空白 context 不可见')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('lexiconAdd validates required fields and writes as the user with active status', async () => {
|
|
211
|
+
await assert.rejects(() => dispatch({}, 'lexiconAdd', { libraryId: lib.id, kind: 'meme', meaning: 'x' }), /term is required/)
|
|
212
|
+
await assert.rejects(() => dispatch({}, 'lexiconAdd', { libraryId: lib.id, term: 'x', meaning: 'x' }), /kind is required/)
|
|
213
|
+
await assert.rejects(() => dispatch({}, 'lexiconAdd', { libraryId: lib.id, term: 'x', kind: 'meme' }), /meaning is required/)
|
|
214
|
+
const result = await dispatch({}, 'lexiconAdd', {
|
|
215
|
+
libraryId: lib.id, term: '霓虹深海', kind: 'term', meaning: '城市夜景的代称',
|
|
216
|
+
usage: '描写夜景', aliases: ['霓虹海'], examples: ['今晚是霓虹深海'],
|
|
217
|
+
evidence: [{ kind: 'message', id: 'm1', quote: '今晚是霓虹深海' }],
|
|
218
|
+
})
|
|
219
|
+
assert.equal(result.created, true)
|
|
220
|
+
assert.equal(result.libraryId, lib.id)
|
|
221
|
+
assert.ok(result.term.termId, '核心回传词条对象(含 termId)')
|
|
222
|
+
const store = readLexiconFile(lib.id)
|
|
223
|
+
const entry = Object.values(store.terms).find((e) => e.term === '霓虹深海')
|
|
224
|
+
assert.equal(entry.source, 'user')
|
|
225
|
+
assert.equal(entry.status, 'active')
|
|
226
|
+
assert.deepEqual(entry.aliases, ['霓虹海'])
|
|
227
|
+
assert.equal(entry.evidence[0].quote, '今晚是霓虹深海')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('user lexiconUpdate applies meaning and sensitivity in place; archive/restore/delete follow the public lifecycle', async () => {
|
|
231
|
+
const termId = termIdOf(lib.id, '霓虹深海')
|
|
232
|
+
assert.ok(termId)
|
|
233
|
+
await assert.rejects(() => dispatch({}, 'lexiconUpdate', { libraryId: lib.id, termId: 'lex-nope', patch: { meaning: 'x' } }), /unknown lexicon term/)
|
|
234
|
+
await assert.rejects(() => dispatch({}, 'lexiconUpdate', { libraryId: lib.id, termId, patch: { status: 'archived' } }), /unknown lexicon field/, 'status only moves through lexiconArchive/Restore')
|
|
235
|
+
const before = Object.keys(readLexiconFile(lib.id).terms).length
|
|
236
|
+
// 非敏感字段(usage)原地更新。
|
|
237
|
+
const inPlace = await dispatch({}, 'lexiconUpdate', { libraryId: lib.id, termId, patch: { usage: '描写城市夜景时用' } })
|
|
238
|
+
assert.equal(inPlace.created, false)
|
|
239
|
+
assert.equal(readLexiconFile(lib.id).terms[termId].usage, '描写城市夜景时用')
|
|
240
|
+
// meaning 是敏感字段,但用户来源直接生效:不生成 revision、不等人审。
|
|
241
|
+
const meaning = await dispatch({}, 'lexiconUpdate', { libraryId: lib.id, termId, patch: { meaning: '城市夜景的代称(已核对)' } })
|
|
242
|
+
assert.equal(meaning.created, false)
|
|
243
|
+
assert.equal(meaning.pendingRevision, undefined)
|
|
244
|
+
// sensitivity 同样由用户直接改。
|
|
245
|
+
const sensitivity = await dispatch({}, 'lexiconUpdate', { libraryId: lib.id, termId, patch: { sensitivity: 'sensitive' } })
|
|
246
|
+
assert.equal(sensitivity.created, false)
|
|
247
|
+
let store = readLexiconFile(lib.id)
|
|
248
|
+
assert.equal(Object.keys(store.terms).length, before, 'no revision row is ever created')
|
|
249
|
+
assert.ok(!Object.values(store.terms).some((entry) => entry.revisionOf))
|
|
250
|
+
assert.equal(store.terms[termId].meaning, '城市夜景的代称(已核对)')
|
|
251
|
+
assert.equal(store.terms[termId].sensitivity, 'sensitive')
|
|
252
|
+
assert.equal(store.terms[termId].status, 'active')
|
|
253
|
+
assert.equal(store.terms[termId].source, 'user')
|
|
254
|
+
// 改回普通敏感度,避免影响后面的导入保护用例。
|
|
255
|
+
await dispatch({}, 'lexiconUpdate', { libraryId: lib.id, termId, patch: { sensitivity: 'normal' } })
|
|
256
|
+
|
|
257
|
+
// 归档 / 恢复:可逆,不写 rejected 指纹。
|
|
258
|
+
const archived = await dispatch({}, 'lexiconArchive', { libraryId: lib.id, termId })
|
|
259
|
+
assert.equal(archived.action, 'archive')
|
|
260
|
+
assert.equal(archived.term.status, 'archived')
|
|
261
|
+
assert.equal((await dispatch({}, 'lexiconList', { libraryId: lib.id })).items.some((row) => row.id === termId), false)
|
|
262
|
+
assert.equal((await dispatch({}, 'lexiconList', { libraryId: lib.id, status: 'archived' })).items.some((row) => row.id === termId), true)
|
|
263
|
+
store = readLexiconFile(lib.id)
|
|
264
|
+
assert.equal(store.rejected?.[store.terms[termId].fingerprint], undefined, 'archiving is not a rejection tombstone')
|
|
265
|
+
await assert.rejects(() => dispatch({}, 'lexiconArchive', { libraryId: lib.id, termId }), /only active terms can be archived/)
|
|
266
|
+
const restored = await dispatch({}, 'lexiconRestore', { libraryId: lib.id, termId })
|
|
267
|
+
assert.equal(restored.action, 'restore')
|
|
268
|
+
assert.equal(restored.term.status, 'active')
|
|
269
|
+
assert.equal((await dispatch({}, 'lexiconList', { libraryId: lib.id })).items.some((row) => row.id === termId), true)
|
|
270
|
+
|
|
271
|
+
// 删除是永久的:归档词条也能删(管理面两段确认由 UI 负责),删一次成功,再删报未知。
|
|
272
|
+
await dispatch({}, 'lexiconArchive', { libraryId: lib.id, termId })
|
|
273
|
+
const gone = await dispatch({}, 'lexiconDelete', { libraryId: lib.id, termId })
|
|
274
|
+
assert.equal(gone.deleted, true)
|
|
275
|
+
await assert.rejects(() => dispatch({}, 'lexiconDelete', { libraryId: lib.id, termId }), /does not exist/)
|
|
276
|
+
await assert.rejects(() => dispatch({}, 'lexiconRestore', { libraryId: lib.id, termId }), /does not exist/)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('model tools: proposals are active immediately; sensitive triggers and field rewrites are refused with readable text', async () => {
|
|
280
|
+
const { tools, dispose } = fakeToolCtx()
|
|
281
|
+
const names = tools.map((tool) => tool.name)
|
|
282
|
+
assert.ok(names.includes('memu_lexicon_search'))
|
|
283
|
+
assert.ok(names.includes('memu_lexicon_propose'))
|
|
284
|
+
// 未绑定会话:两条工具都给明确文案,而不是创建幽灵库。
|
|
285
|
+
const unboundSearch = await tools.find((tool) => tool.name === 'memu_lexicon_search').execute({ query: '糖刀' }, toolsExec('lex-unbound-session'))
|
|
286
|
+
assert.match(unboundSearch, /没有绑定记忆库/)
|
|
287
|
+
const unboundPropose = await tools.find((tool) => tool.name === 'memu_lexicon_propose').execute({ term: 'x', kind: 'term', meaning: 'y' }, toolsExec('lex-unbound-session'))
|
|
288
|
+
assert.match(unboundPropose, /没有绑定记忆库/)
|
|
289
|
+
state.ensureSession('lex-tools-session', { cwd: path.join(home, 'lexicon-main') })
|
|
290
|
+
const exec = toolsExec('lex-tools-session')
|
|
291
|
+
const propose = (args) => tools.find((tool) => tool.name === 'memu_lexicon_propose').execute(args, exec)
|
|
292
|
+
const search = (query) => tools.find((tool) => tool.name === 'memu_lexicon_search').execute({ query }, exec)
|
|
293
|
+
|
|
294
|
+
// snake_case → 内部 camelCase:usage/aliases/examples 全链路落到词条字段上;普通词条直接 active。
|
|
295
|
+
const proposal = await propose({
|
|
296
|
+
term: '贴贴',
|
|
297
|
+
kind: 'slang',
|
|
298
|
+
meaning: '亲密地靠在一起的动作',
|
|
299
|
+
usage: '描述互动',
|
|
300
|
+
aliases: ['贴贴抱'],
|
|
301
|
+
examples: ['我们贴贴'],
|
|
302
|
+
evidence: [{ kind: 'message', id: 'm2', quote: '我们贴贴' }],
|
|
303
|
+
confidence: 0.8,
|
|
304
|
+
})
|
|
305
|
+
assert.match(proposal, /status=active/)
|
|
306
|
+
let store = readLexiconFile(lib.id)
|
|
307
|
+
const entry = Object.values(store.terms).find((e) => e.term === '贴贴')
|
|
308
|
+
assert.equal(entry.status, 'active', 'model proposals take effect immediately; no pending state')
|
|
309
|
+
assert.equal(entry.source, 'model-proposal')
|
|
310
|
+
assert.equal(entry.usage, '描述互动')
|
|
311
|
+
assert.deepEqual(entry.aliases, ['贴贴抱'])
|
|
312
|
+
assert.deepEqual(entry.examples, ['我们贴贴'])
|
|
313
|
+
assert.equal(entry.evidence[0].quote, '我们贴贴')
|
|
314
|
+
|
|
315
|
+
// sensitive trigger 的 typed rejection 翻译成"该让用户做什么"。
|
|
316
|
+
const refusal = await propose({ term: '红牌', kind: 'trigger', meaning: '试图由模型提案敏感触发词' })
|
|
317
|
+
assert.match(refusal, /sensitive trigger 不能由模型写入或修改/)
|
|
318
|
+
assert.equal(Object.values(readLexiconFile(lib.id).terms).find((e) => e.term === '红牌').meaning, '角色行为的红线信号', '敏感触发词的原词条没有被提案覆盖')
|
|
319
|
+
|
|
320
|
+
// 重复提案改 meaning:非用户改敏感字段被拒绝(工具把它翻译成敏感 trigger 文案——当前后端行为)。
|
|
321
|
+
const rewrite = await propose({ term: '糖刀', kind: 'meme', meaning: '试图覆盖 active 词条的提案' })
|
|
322
|
+
assert.match(rewrite, /sensitive trigger/)
|
|
323
|
+
assert.equal(Object.values(readLexiconFile(lib.id).terms).find((e) => e.term === '糖刀').meaning, '表面发糖、实际虐心的剧情')
|
|
324
|
+
|
|
325
|
+
// 完全相同的重复提案:原地更新,不新建、不改生命周期。
|
|
326
|
+
const replay = await propose({ term: '糖刀', kind: 'meme', meaning: '表面发糖、实际虐心的剧情' })
|
|
327
|
+
assert.match(replay, /已更新现有生效词条/)
|
|
328
|
+
store = readLexiconFile(lib.id)
|
|
329
|
+
assert.equal(Object.values(store.terms).filter((e) => e.normalizedTerm === '糖刀').length, 1, 'no duplicate row')
|
|
330
|
+
assert.equal(Object.values(store.terms).find((e) => e.normalizedTerm === '糖刀').status, 'active')
|
|
331
|
+
|
|
332
|
+
// 归档词条归用户管理:模型不能复活或覆盖。
|
|
333
|
+
const archivedSeed = await dispatch({}, 'lexiconAdd', { libraryId: lib.id, term: '归档提案词', kind: 'term', meaning: '模型不该复活它' })
|
|
334
|
+
await dispatch({}, 'lexiconArchive', { libraryId: lib.id, termId: archivedSeed.term.termId })
|
|
335
|
+
const notOwned = await propose({ term: '归档提案词', kind: 'term', meaning: '模型不该复活它' })
|
|
336
|
+
assert.match(notOwned, /已归档或归用户管理/)
|
|
337
|
+
assert.equal(readLexiconFile(lib.id).terms[archivedSeed.term.termId].status, 'archived')
|
|
338
|
+
|
|
339
|
+
// 搜索工具:命中给 term→meaning/usage/evidence 摘要,归档词条不参与。
|
|
340
|
+
const miss = await search('不存在的词')
|
|
341
|
+
assert.match(miss, /没有命中/)
|
|
342
|
+
const hit = await search('糖刀')
|
|
343
|
+
assert.match(hit, /\[糖刀\]/)
|
|
344
|
+
assert.match(hit, /meaning=/)
|
|
345
|
+
const archivedHidden = await search('归档提案词')
|
|
346
|
+
assert.match(archivedHidden, /没有命中/, 'archived terms do not enter the model search')
|
|
347
|
+
assert.equal(formatSearchStatus(await search('贴贴')), 'active', 'a model-written term is searchable right away')
|
|
348
|
+
dispose()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
// 只从搜索结果里取 status 字段,避免测试耦合具体排版。
|
|
352
|
+
function formatSearchStatus(text) {
|
|
353
|
+
const match = /status=([a-z]+)/.exec(String(text || ''))
|
|
354
|
+
return match ? match[1] : ''
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
test('backup manifest carries the lexicon store and skips unsafe names', async () => {
|
|
358
|
+
// 词典目录里放一个不安全命名的文件:writeStores 必须跳过它。
|
|
359
|
+
const junk = path.join(dataDir(), 'lexicon', 'bad id.json')
|
|
360
|
+
fs.mkdirSync(path.dirname(junk), { recursive: true })
|
|
361
|
+
fs.writeFileSync(junk, '{}')
|
|
362
|
+
const created = backup.createBackup({ label: 'lexicon-ui' })
|
|
363
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(created.dir, 'manifest.json'), 'utf8'))
|
|
364
|
+
const rel = `lexicon/${lib.id}.json`
|
|
365
|
+
assert.ok(manifest.files[rel], `manifest lists ${rel}`)
|
|
366
|
+
assert.equal(manifest.files[rel].sha256, crypto.createHash('sha256').update(fs.readFileSync(path.join(dataDir(), 'lexicon', `${lib.id}.json`))).digest('hex'))
|
|
367
|
+
assert.ok(!Object.keys(manifest.files).some((name) => name.includes('bad id')), 'isSafeId 之外的文件名不进备份')
|
|
368
|
+
fs.rmSync(junk, { force: true })
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('restore allowlist accepts lexicon/<id>.json and refuses out-of-bounds entries', async () => {
|
|
372
|
+
const lexFile = path.join(dataDir(), 'lexicon', `${lib.id}.json`)
|
|
373
|
+
const original = fs.readFileSync(lexFile, 'utf8')
|
|
374
|
+
const craft = (name, files) => {
|
|
375
|
+
const dir = path.join(dataDir(), 'backups', name)
|
|
376
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
377
|
+
fs.mkdirSync(path.join(dir, rel.split('/').slice(0, -1).join('/')), { recursive: true })
|
|
378
|
+
fs.writeFileSync(path.join(dir, ...rel.split('/')), body)
|
|
379
|
+
}
|
|
380
|
+
const inventory = {}
|
|
381
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
382
|
+
inventory[rel] = { bytes: Buffer.byteLength(body), sha256: crypto.createHash('sha256').update(body).digest('hex') }
|
|
383
|
+
}
|
|
384
|
+
writeJson(path.join(dir, 'manifest.json'), {
|
|
385
|
+
version: backup.BACKUP_SCHEMA,
|
|
386
|
+
kind: 'dsh-memu-memory/backup',
|
|
387
|
+
name,
|
|
388
|
+
uniqueId: name + '-craft',
|
|
389
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
390
|
+
createdAtMs: Date.parse('2026-01-01T00:00:00.000Z'),
|
|
391
|
+
label: 'craft',
|
|
392
|
+
complete: true,
|
|
393
|
+
files: inventory,
|
|
394
|
+
})
|
|
395
|
+
return dir
|
|
396
|
+
}
|
|
397
|
+
// 正路径:只含词典文件的备份可以整份恢复。
|
|
398
|
+
craft('2026-01-01T00-00-00-000Z', { [`lexicon/${lib.id}.json`]: original })
|
|
399
|
+
await dispatch({}, 'lexiconDelete', { libraryId: lib.id, termId: termIdOf(lib.id, '咖啡上头') })
|
|
400
|
+
assert.notEqual(fs.readFileSync(lexFile, 'utf8'), original)
|
|
401
|
+
const restored = await backup.restoreFromBackup('2026-01-01T00-00-00-000Z')
|
|
402
|
+
assert.equal(restored.files, 1)
|
|
403
|
+
assert.equal(fs.readFileSync(lexFile, 'utf8'), original)
|
|
404
|
+
fs.rmSync(path.join(dataDir(), 'backups', '2026-01-01T00-00-00-000Z'), { recursive: true, force: true })
|
|
405
|
+
// 越界:路径穿越在 manifest 校验阶段被拒。(restoreFromBackup 是同步抛错,
|
|
406
|
+
// assert.rejects 必须用 async 箭头把同步 throw 变成 rejection。)
|
|
407
|
+
craft('2026-01-02T00-00-00-000Z', { 'lexicon/../state.json': '{}' })
|
|
408
|
+
await assert.rejects(async () => backup.restoreFromBackup('2026-01-02T00-00-00-000Z'), /invalid backup manifest path/)
|
|
409
|
+
fs.rmSync(path.join(dataDir(), 'backups', '2026-01-02T00-00-00-000Z'), { recursive: true, force: true })
|
|
410
|
+
// 越界:词典目录下的子路径不在 allowlist 内。
|
|
411
|
+
craft('2026-01-03T00-00-00-000Z', { 'lexicon/sub/x.json': '{}' })
|
|
412
|
+
await assert.rejects(async () => backup.restoreFromBackup('2026-01-03T00-00-00-000Z'), /cannot restore/)
|
|
413
|
+
fs.rmSync(path.join(dataDir(), 'backups', '2026-01-03T00-00-00-000Z'), { recursive: true, force: true })
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('export bundles a versioned lexicon member; import merges in place with tombstones and sensitive terms protected', async () => {
|
|
417
|
+
const out = await dispatch({}, 'exportLibrary', { libraryId: lib.id })
|
|
418
|
+
assert.equal(out.format, 'dsh-memu-memory-library@1')
|
|
419
|
+
assert.equal(out.lexicon.version, 1)
|
|
420
|
+
assert.ok(Array.isArray(out.lexicon.terms) && out.lexicon.terms.length > 0)
|
|
421
|
+
assert.ok(out.lexicon.terms.every((e) => e.id && e.term))
|
|
422
|
+
|
|
423
|
+
const target = state.createLibrary({ name: 'lexicon-import', cwd: path.join(home, 'lexicon-import') })
|
|
424
|
+
const anchorMemory = [{ id: 'mem-anchor', title: 'anchor', content: '锚点正文,让记忆导入有内容可写', status: 'active' }]
|
|
425
|
+
// 冲突更新:同一规范化词(去标点/空白后同词)直接原地更新(用户来源),不再产生 pending revision。
|
|
426
|
+
await dispatch({}, 'lexiconAdd', { libraryId: target.id, term: '糖刀', kind: 'meme', meaning: '导入前的旧释义' })
|
|
427
|
+
let report = await dispatch({}, 'importMemories', {
|
|
428
|
+
libraryId: target.id,
|
|
429
|
+
memories: anchorMemory,
|
|
430
|
+
lexicon: { version: 1, terms: [{ term: '糖刀!!', kind: 'meme', meaning: '导入后的新释义' }] },
|
|
431
|
+
})
|
|
432
|
+
assert.equal(report.lexicon.updated, 1, '同规范词的导入按"更新既有词条"计')
|
|
433
|
+
assert.equal(report.lexicon.added, 0)
|
|
434
|
+
assert.equal(report.lexicon.revisions, 0, 'current no longer generates pending revisions')
|
|
435
|
+
let store = readLexiconFile(target.id)
|
|
436
|
+
const merged = Object.values(store.terms).filter((e) => e.normalizedTerm === '糖刀')
|
|
437
|
+
assert.equal(merged.length, 1, 'no revision draft is created')
|
|
438
|
+
assert.equal(merged[0].meaning, '导入后的新释义')
|
|
439
|
+
assert.equal(merged[0].status, 'active')
|
|
440
|
+
assert.equal(merged[0].source, 'user')
|
|
441
|
+
|
|
442
|
+
// 敏感保护:active 且 sensitive 的词条绝不覆盖,跳过并上报。
|
|
443
|
+
await dispatch({}, 'lexiconAdd', { libraryId: target.id, term: '红线', kind: 'trigger', sensitivity: 'sensitive', meaning: '绝对红线' })
|
|
444
|
+
report = await dispatch({}, 'importMemories', {
|
|
445
|
+
libraryId: target.id,
|
|
446
|
+
memories: anchorMemory,
|
|
447
|
+
lexicon: { version: 1, terms: [{ term: '红线', kind: 'trigger', meaning: '试图用导入覆盖红线', sensitivity: 'sensitive' }] },
|
|
448
|
+
})
|
|
449
|
+
assert.equal(report.lexicon.skipped.length, 1)
|
|
450
|
+
assert.equal(report.lexicon.updated, 0)
|
|
451
|
+
assert.equal(Object.values(readLexiconFile(target.id).terms).find((e) => e.term === '红线').meaning, '绝对红线')
|
|
452
|
+
|
|
453
|
+
// 墓碑:导入包里的 rejected 行不恢复;库里已有的 rejected 词条也不被洗回生命周期。
|
|
454
|
+
store = readLexiconFile(target.id)
|
|
455
|
+
store.terms['lex-tomb-import'] = legacyEntry('墓碑导入', 'rejected', { fingerprint: 'cafebabe' })
|
|
456
|
+
writeLexiconFile(target.id, store)
|
|
457
|
+
report = await dispatch({}, 'importMemories', {
|
|
458
|
+
libraryId: target.id,
|
|
459
|
+
memories: anchorMemory,
|
|
460
|
+
lexicon: { version: 1, terms: [
|
|
461
|
+
{ term: '墓碑导入', kind: 'term', meaning: '导入想复活墓碑' },
|
|
462
|
+
{ term: '拒绝行', kind: 'term', meaning: '包里的墓碑行', status: 'rejected' },
|
|
463
|
+
] },
|
|
464
|
+
})
|
|
465
|
+
assert.equal(report.lexicon.added, 0)
|
|
466
|
+
assert.equal(report.lexicon.skipped.length, 2, 'both the tombstone row and the existing tombstone are skipped')
|
|
467
|
+
assert.equal(report.lexicon.failed.length, 0)
|
|
468
|
+
store = readLexiconFile(target.id)
|
|
469
|
+
assert.equal(store.terms['lex-tomb-import'].status, 'rejected')
|
|
470
|
+
assert.equal(Object.values(store.terms).some((e) => e.term === '拒绝行'), false)
|
|
471
|
+
|
|
472
|
+
// legacy pending 收口:普通提案 → active,敏感提案 → archived,revision 草稿不覆盖正条。
|
|
473
|
+
await dispatch({}, 'lexiconAdd', { libraryId: target.id, term: '旧正条', kind: 'term', meaning: '正条释义' })
|
|
474
|
+
report = await dispatch({}, 'importMemories', {
|
|
475
|
+
libraryId: target.id,
|
|
476
|
+
memories: anchorMemory,
|
|
477
|
+
lexicon: { version: 1, terms: [
|
|
478
|
+
{ term: '旧提案导入', kind: 'term', meaning: 'legacy 普通提案', status: 'pending' },
|
|
479
|
+
{ term: '旧提案敏感', kind: 'trigger', sensitivity: 'sensitive', meaning: 'legacy 敏感提案', status: 'pending' },
|
|
480
|
+
{ term: '旧正条', kind: 'term', meaning: 'legacy 草稿不该覆盖正条', status: 'pending', revisionOf: 'lex-target' },
|
|
481
|
+
] },
|
|
482
|
+
})
|
|
483
|
+
assert.equal(report.lexicon.added, 2)
|
|
484
|
+
assert.equal(report.lexicon.skipped.length, 1, 'the legacy revision draft is skipped, its active target is untouched')
|
|
485
|
+
store = readLexiconFile(target.id)
|
|
486
|
+
assert.equal(Object.values(store.terms).find((e) => e.term === '旧提案导入').status, 'active')
|
|
487
|
+
assert.equal(Object.values(store.terms).find((e) => e.term === '旧提案敏感').status, 'archived')
|
|
488
|
+
assert.equal(Object.values(store.terms).find((e) => e.term === '旧正条').meaning, '正条释义')
|
|
489
|
+
assert.ok(!Object.values(store.terms).some((e) => e.status === 'pending'), 'no pending row lands from an import')
|
|
490
|
+
|
|
491
|
+
// 新词条:超长 meaning/usage/aliases/examples 与直接写入同一把尺——按核心 clip 落地,
|
|
492
|
+
// 不再整条拒绝;term 本身超长/归一化为空才进 failed(term 是唯一键,不能截断)。
|
|
493
|
+
report = await dispatch({}, 'importMemories', {
|
|
494
|
+
libraryId: target.id,
|
|
495
|
+
memories: anchorMemory,
|
|
496
|
+
lexicon: { version: 1, terms: [
|
|
497
|
+
{ term: '月白', kind: 'term', meaning: '月光下的白' },
|
|
498
|
+
{ term: '长义', kind: 'term', meaning: 'y'.repeat(501), usage: 'z'.repeat(301), aliases: ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'], examples: ['例'.repeat(201), '例二', '例三', '例四', '例五', '例六', '例七'] },
|
|
499
|
+
{ term: '超'.repeat(81), kind: 'term', meaning: 'term 本身超长,必须整条拒绝而不是截断' },
|
|
500
|
+
{ term: '!!!', kind: 'term', meaning: '规范化后为空的 term' },
|
|
501
|
+
] },
|
|
502
|
+
})
|
|
503
|
+
assert.equal(report.lexicon.added, 2, 'clipped entries land like a direct write would')
|
|
504
|
+
assert.equal(report.lexicon.failed.length, 2)
|
|
505
|
+
assert.match(report.lexicon.failed[0].reason, /80/, 'an over-long term is rejected, not clipped (uniqueness)')
|
|
506
|
+
assert.match(report.lexicon.failed[1].reason, /normalizes to empty/)
|
|
507
|
+
const store2 = readLexiconFile(target.id)
|
|
508
|
+
const clipped = Object.values(store2.terms).find((e) => e.term === '长义')
|
|
509
|
+
assert.equal(clipped.meaning.length, 500)
|
|
510
|
+
assert.equal(clipped.usage.length, 300)
|
|
511
|
+
assert.equal(clipped.aliases.length, 8, 'aliases beyond 8 are clipped like the core write path')
|
|
512
|
+
assert.equal(clipped.examples.length, 5)
|
|
513
|
+
assert.equal(clipped.examples[0].length, 200)
|
|
514
|
+
await assert.rejects(() => dispatch({}, 'importMemories', { libraryId: target.id, memories: anchorMemory, lexicon: { version: 1 } }), /no "terms" array/)
|
|
515
|
+
// 老版本包没有 lexicon member:导入照旧,不报错。
|
|
516
|
+
const legacy = await dispatch({}, 'importMemories', { libraryId: target.id, memories: anchorMemory })
|
|
517
|
+
assert.equal(legacy.lexicon, undefined)
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
test('the lexicon panel is a two-tab card flow with no review/pending surface; auto graph edges get a badge', () => {
|
|
521
|
+
const src = fs.readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
522
|
+
const start = src.indexOf('const LEXICON_TAB_LABELS')
|
|
523
|
+
const end = src.indexOf('function SettingsPage')
|
|
524
|
+
assert.ok(start > 0 && end > start, 'LexiconPanel and its tab labels must stay a single block before SettingsPage')
|
|
525
|
+
const panel = src.slice(start, end)
|
|
526
|
+
// 页签由 RPC 的 statusCounts 驱动,卡片网格替代表格行。
|
|
527
|
+
assert.match(panel, /statusCounts/)
|
|
528
|
+
assert.match(panel, /lexiconTabActive/)
|
|
529
|
+
assert.match(panel, /lexiconTabArchived/)
|
|
530
|
+
assert.match(panel, /gridTemplateColumns/)
|
|
531
|
+
// 归档 / 恢复 / 删除走正确的 RPC,且不再有审核面。
|
|
532
|
+
assert.match(panel, /lexiconArchive'/)
|
|
533
|
+
assert.match(panel, /lexiconRestore'/)
|
|
534
|
+
assert.match(panel, /lexiconDelete'/)
|
|
535
|
+
assert.match(panel, /lexiconUpdate'/)
|
|
536
|
+
assert.ok(!/lexiconReview|includePending|lexiconPending|lexiconApprove|lexiconReject|待审/.test(panel), 'no review/pending surface may remain')
|
|
537
|
+
assert.ok(!/includeArchived/.test(panel), 'the panel never widens the listing; archived rows come from the tab status')
|
|
538
|
+
// 空态:使用中 / 已归档 / 搜索无匹配三档分开。
|
|
539
|
+
assert.match(panel, /lexiconArchivedEmpty/)
|
|
540
|
+
assert.match(panel, /lexiconNoMatch/)
|
|
541
|
+
// 来源记忆跳转与命中三计数 tooltip 保留。
|
|
542
|
+
assert.match(panel, /onFocusMemory/)
|
|
543
|
+
assert.match(panel, /lexiconHitsDetail/)
|
|
544
|
+
// 自动关联边带小 Tag,手动边不受影响(只认 source === 'auto')。
|
|
545
|
+
assert.match(src, /edge\.source === 'auto'/)
|
|
546
|
+
assert.match(src, /t\('graphAutoLink'\)/)
|
|
547
|
+
})
|