dsh-muv-table 0.2.9 → 0.2.10

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,372 @@
1
+ // Regression: the MUV card/variable parser.
2
+ //
3
+ // Run: node test-muv-parser.mjs
4
+ //
5
+ // Covers the "silent data loss / robustness" round of fixes:
6
+ // ① `空值 + 下一行 - ` 列表被吞成空对象 → 内容既不在数据里也不在 schema 里
7
+ // ③ 变量块的候选来源只扫 4 个字段,世界书条目 / tavern_helper 脚本读不到
8
+ // ④ alternate_greetings(或 entries / scripts)是对象时 for...of 抛 TypeError → 端点 500
9
+ // ⑤ 扁平 V1 卡(没有 data 包装)能被 findJsonMatch 找到,却被 parseMuvCard 读成 0 字段
10
+ // ⑥ `键: ""` 空字符串被序列化成裸的 `键: `,再解析回来变成 `{}`(类型静默翻转)
11
+ //
12
+ // 真实卡只在存在时才测(缺失 → SKIP),合成卡负责精确覆盖每条分支。
13
+
14
+ import { parseInitvar, serializeInitvar } from './lib/initvar-parser.js'
15
+ import { parseMuvCard, extractVariableInsert } from './lib/muv-parser.js'
16
+ import { applyEditsAndGenerate, generateMuvBlock } from './lib/block-generator.js'
17
+ import { readPngCard } from './lib/png-card.js'
18
+ import { findCard } from './test-cards.mjs'
19
+ import fs from 'node:fs'
20
+
21
+ let pass = 0, fail = 0
22
+ function check(name, cond, detail) {
23
+ if (cond) { pass++; console.log(' OK ' + name) }
24
+ else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
25
+ }
26
+
27
+ /** Find a dot-path in a schema tree; returns the field object or undefined. */
28
+ function fieldAt(schemas, path) {
29
+ const keys = path.split('.')
30
+ let list = schemas
31
+ let found
32
+ for (const key of keys) {
33
+ found = (list || []).find(f => f.name === key)
34
+ if (!found) return undefined
35
+ list = found.children
36
+ }
37
+ return found
38
+ }
39
+ function at(obj, path) {
40
+ return path.split('.').reduce((o, k) => (o === undefined || o === null ? undefined : o[k]), obj)
41
+ }
42
+ function leafPaths(schemas, prefix = '') {
43
+ const out = []
44
+ for (const f of schemas || []) {
45
+ const p = prefix ? prefix + '.' + f.name : f.name
46
+ if (f.type === 'object' && (f.children || []).length > 0) out.push(...leafPaths(f.children, p))
47
+ else out.push(p)
48
+ }
49
+ return out
50
+ }
51
+
52
+ console.log('=== MUV 解析器回归 ===\n')
53
+
54
+ // ─────────────────────────────────────────────────────────
55
+ console.log('[1] ① 列表值:`键: ` + 下一行 `- item` 必须是数组')
56
+ const listText = [
57
+ '人际交往:',
58
+ ' 结识道友录:',
59
+ ' 沈慕微:',
60
+ ' 关系标签: 师尊',
61
+ ' 最近互动记录: ',
62
+ ' - 因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。',
63
+ ' 江念:',
64
+ ' 最近互动记录:',
65
+ ' - (呼呼大睡中,听到动静也只是翻了个身)。',
66
+ '世界系统:',
67
+ ' 在场角色: ""',
68
+ ].join('\n')
69
+ const listParsed = parseInitvar(listText)
70
+ check('列表成为数组', Array.isArray(at(listParsed, '人际交往.结识道友录.沈慕微.最近互动记录')),
71
+ JSON.stringify(at(listParsed, '人际交往.结识道友录.沈慕微.最近互动记录')))
72
+ check('列表内容原样保留',
73
+ at(listParsed, '人际交往.结识道友录.沈慕微.最近互动记录')?.[0] === '因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。')
74
+ check('同缩进的列表也认(键与 `- ` 对齐)',
75
+ Array.isArray(at(listParsed, '人际交往.结识道友录.江念.最近互动记录'))
76
+ && at(listParsed, '人际交往.结识道友录.江念.最近互动记录')[0] === '(呼呼大睡中,听到动静也只是翻了个身)。')
77
+ check('列表后面的兄弟键仍然归到上一层', at(listParsed, '世界系统.在场角色') === '',
78
+ JSON.stringify(at(listParsed, '世界系统.在场角色')))
79
+ check('普通嵌套对象没被改成数组', !Array.isArray(at(listParsed, '人际交往.结识道友录.沈慕微')))
80
+
81
+ const multi = parseInitvar('甲:\n - 一\n - 二\n - 三\n乙: 1')
82
+ check('多条目列表', JSON.stringify(at(multi, '甲')) === '["一","二","三"]', JSON.stringify(at(multi, '甲')))
83
+ check('列表里的数字/布尔按标量解析', JSON.stringify(at(parseInitvar('甲:\n - 3\n - true\n - 文字'), '甲')) === '[3,true,"文字"]',
84
+ JSON.stringify(at(parseInitvar('甲:\n - 3\n - true\n - 文字'), '甲')))
85
+ check('孤立的 `- item`(上面没有待定键)不会污染根对象',
86
+ Object.keys(parseInitvar('- 野生条目\n名字: 甲')).join(',') === '名字',
87
+ Object.keys(parseInitvar('- 野生条目\n名字: 甲')).join(','))
88
+ check('列表写回仍是列表(往返)',
89
+ serializeInitvar(listParsed).includes(' - 因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。')
90
+ && Array.isArray(at(parseInitvar(serializeInitvar(listParsed)), '人际交往.结识道友录.沈慕微.最近互动记录')))
91
+ check('空列表往返为 [](不是 {})',
92
+ JSON.stringify(at(parseInitvar(serializeInitvar(parseInitvar('甲: []'))), '甲')) === '[]',
93
+ JSON.stringify(at(parseInitvar(serializeInitvar(parseInitvar('甲: []'))), '甲')))
94
+
95
+ // ─────────────────────────────────────────────────────────
96
+ console.log('\n[2] ⑥ 空字符串往返:`键: ""` 不能再变成 `{}`')
97
+ const emptyRound = parseInitvar(serializeInitvar(parseInitvar('在场角色: ""')))
98
+ check('`键: ""` 往返仍是空字符串', emptyRound['在场角色'] === '', JSON.stringify(emptyRound['在场角色']))
99
+ check('类型没变(不是 object)', typeof emptyRound['在场角色'] !== 'object', typeof emptyRound['在场角色'])
100
+
101
+ // ─────────────────────────────────────────────────────────
102
+ console.log('\n[3] ③ 变量块的候选来源:世界书条目 + tavern_helper 脚本')
103
+ const wbInitvarCard = {
104
+ data: {
105
+ name: 'wb-initvar',
106
+ first_mes: '普通问候',
107
+ character_book: {
108
+ entries: [
109
+ { comment: '无关设定', content: '这里没有变量块' },
110
+ { comment: '[initvar]变量初始化勿开', content: '<initvar>\n名字: 世界书\n年龄: 9\n</initvar>' },
111
+ ],
112
+ },
113
+ },
114
+ }
115
+ const wbInitvar = parseMuvCard(wbInitvarCard)
116
+ check('世界书条目里的 <initvar> 被读到', Object.keys(wbInitvar.initvarData).join(',') === '名字,年龄',
117
+ Object.keys(wbInitvar.initvarData).join(','))
118
+ check('世界书条目里的 <initvar> 建有 schema', wbInitvar.schemas.length === 2, 'schemas=' + wbInitvar.schemas.length)
119
+
120
+ const scriptTree = { 世界信息: { 时间: '10:00' }, 主播档案: { 超天酱: { 数值: { 压力值: 45 } } } }
121
+ const scriptCard = {
122
+ data: {
123
+ name: 'script-vi',
124
+ first_mes: '普通问候',
125
+ extensions: {
126
+ tavern_helper: {
127
+ scripts: [
128
+ { name: '注释脚本', content: '// 没有变量' },
129
+ { name: '变量初始化', content: 'const n = 1\n<VariableInsert>' + JSON.stringify(scriptTree) + '</VariableInsert>' },
130
+ ],
131
+ },
132
+ },
133
+ },
134
+ }
135
+ const scriptParsed = parseMuvCard(scriptCard)
136
+ check('脚本里的 <VariableInsert> 被读到',
137
+ Object.keys(scriptParsed.initvarData).join(',') === '世界信息,主播档案',
138
+ Object.keys(scriptParsed.initvarData).join(','))
139
+ check('脚本里的变量树建得出嵌套 schema',
140
+ !!fieldAt(scriptParsed.schemas, '主播档案.超天酱.数值.压力值'))
141
+
142
+ // 顺序:问候语里的真块永远优先于世界书/脚本(已经是好的卡不许变)
143
+ const precedence = parseMuvCard({
144
+ data: {
145
+ name: 'precedence',
146
+ first_mes: '<VariableInsert>' + JSON.stringify({ 真变量: 1 }) + '</VariableInsert>',
147
+ alternate_greetings: ['<initvar>\n名字: 问候语里的\n</initvar>'],
148
+ character_book: { entries: [{ comment: 'x', content: '<initvar>\n名字: 世界书里的\n</initvar>' }] },
149
+ extensions: { tavern_helper: { scripts: [{ name: 's', content: '<VariableInsert>{"脚本里的":1}</VariableInsert>' }] } },
150
+ },
151
+ })
152
+ check('<initvar> 优先于任何 <VariableInsert>', at(precedence.initvarData, '名字') === '问候语里的',
153
+ JSON.stringify(precedence.initvarData))
154
+ check('问候语里的 <VariableInsert> 优先于世界书里的 <initvar>',
155
+ parseMuvCard({
156
+ data: {
157
+ name: 'p2',
158
+ first_mes: '<VariableInsert>{"问候语里的":1}</VariableInsert>',
159
+ character_book: { entries: [{ comment: 'x', content: '<initvar>\n名字: 世界书示例\n</initvar>' }] },
160
+ },
161
+ }).initvarData['问候语里的'] === 1)
162
+
163
+ // 文档块(引用了标签但不是 JSON)不能抢走真块,也不能抛异常
164
+ const docCard = parseMuvCard({
165
+ data: {
166
+ name: 'doc',
167
+ first_mes: '【主页】\n<VariableInsert>' + JSON.stringify({ 真变量: { a: 1 } }) + '</VariableInsert>',
168
+ character_book: {
169
+ entries: [{ comment: '规则', content: '- 新增则 `<VariableInsert>`\n```json\n{ "示例": 1 }\n```\n</VariableInsert>' }],
170
+ },
171
+ },
172
+ })
173
+ check('世界书里的文档块不被误命中', Object.keys(docCard.initvarData).join(',') === '真变量',
174
+ Object.keys(docCard.initvarData).join(','))
175
+ check('只有非法文档块时返回空表而不抛异常',
176
+ (() => {
177
+ const d = parseMuvCard({
178
+ data: {
179
+ name: 'doc-only',
180
+ first_mes: '没有变量',
181
+ character_book: { entries: [{ comment: 'd', content: '<VariableInsert>不是 JSON {</VariableInsert>' }] },
182
+ },
183
+ })
184
+ return d.schemas.length === 0 && typeof d.initvarData === 'object'
185
+ })())
186
+
187
+ // ─────────────────────────────────────────────────────────
188
+ console.log('\n[4] ④ 畸形卡:该是数组的地方来了对象/字符串')
189
+ const malformed = {
190
+ name: 'malformed',
191
+ first_mes: 'hi',
192
+ alternate_greetings: { a: 'x' },
193
+ character_book: { entries: { not: 'an array' } },
194
+ extensions: { tavern_helper: { scripts: 'not an array' } },
195
+ }
196
+ let malformedError = ''
197
+ try {
198
+ const m = parseMuvCard(malformed)
199
+ check('parseMuvCard 不抛异常', true)
200
+ check('返回可用的空结构', Array.isArray(m.schemas) && m.schemas.length === 0, 'schemas=' + m.schemas.length)
201
+ } catch (e) { malformedError = e.message; check('parseMuvCard 不抛异常', false, e.message) }
202
+ check('extractVariableInsert 不抛异常', (() => {
203
+ try { extractVariableInsert({ alternate_greetings: { a: 1 }, first_mes: 'x' }); return true } catch { return false }
204
+ })())
205
+ check('alternate_greetings 是对象时仍能读到 first_mes 里的变量',
206
+ Object.keys(parseMuvCard({
207
+ data: { name: 'm2', alternate_greetings: {}, first_mes: '<initvar>\n甲: 1\n</initvar>' },
208
+ }).initvarData).join(',') === '甲')
209
+ check('entries 是数组但条目是 null 时不抛异常', (() => {
210
+ try {
211
+ const m = parseMuvCard({ data: { name: 'm3', first_mes: 'x', character_book: { entries: [null, 7, { content: 'ok' }] } } })
212
+ return Array.isArray(m.schemas)
213
+ } catch { return false }
214
+ })())
215
+ check('parseMuvCard(null) 不抛异常', (() => {
216
+ try { return parseMuvCard(null).name === 'Unnamed' } catch { return false }
217
+ })())
218
+ console.log(' (畸形卡: ' + (malformedError || '无异常') + ')')
219
+
220
+ // ─────────────────────────────────────────────────────────
221
+ console.log('\n[5] ⑤ 扁平 V1 卡(没有 data 包装)')
222
+ const flat = parseMuvCard({
223
+ name: 'flat',
224
+ description: '描述',
225
+ first_mes: '<initvar>\n名字: 扁平卡\n年龄: 18\n</initvar>',
226
+ spec: 'chara_card_v1',
227
+ extensions: { regex_scripts: [{ scriptName: 'r1' }] },
228
+ })
229
+ check('卡名读得到', flat.name === 'flat', 'name=' + flat.name)
230
+ check('initvarData 非空', Object.keys(flat.initvarData).join(',') === '名字,年龄',
231
+ Object.keys(flat.initvarData).join(','))
232
+ check('schemas 非空', flat.schemas.length === 2, 'schemas=' + flat.schemas.length)
233
+ check('扁平卡里的 <VariableInsert> 也认',
234
+ Object.keys(parseMuvCard({ name: 'f2', first_mes: '<VariableInsert>{"世界信息":1}</VariableInsert>' }).initvarData).join(',') === '世界信息')
235
+ check('有 data 包装时以 data 为准(不回归)', (() => {
236
+ const w = parseMuvCard({ name: 'outer', first_mes: '<initvar>\n外层: 1\n</initvar>', data: { name: 'inner', first_mes: '<initvar>\n内层: 1\n</initvar>' } })
237
+ return w.name === 'inner' && Object.keys(w.initvarData).join(',') === '内层'
238
+ })(), 'name=' + parseMuvCard({ name: 'outer', data: { name: 'inner' } }).name)
239
+
240
+ // ─────────────────────────────────────────────────────────
241
+ console.log('\n[6] 列表在 schema 里的形状 + 编辑后仍然写成列表')
242
+ const listSchema = parseMuvCard({ name: 'ls', first_mes: '<initvar>' + listText + '</initvar>' })
243
+ const listField = fieldAt(listSchema.schemas, '人际交往.结识道友录.沈慕微.最近互动记录')
244
+ check('列表字段进了 schema', !!listField)
245
+ check('类型是文本、标记 isList', listField?.type === 'string' && listField?.isList === true,
246
+ JSON.stringify({ type: listField?.type, isList: listField?.isList }))
247
+ check('默认值是多行文本', listField?.defaultValue === '因为想吃灵鹤被{{user}}抓包,目前心虚加不知所措。',
248
+ JSON.stringify(listField?.defaultValue))
249
+ const edited = applyEditsAndGenerate(listSchema.initvarData, [
250
+ { path: '人际交往.结识道友录.沈慕微.最近互动记录', value: '第一条\n第二条' },
251
+ ])
252
+ check('编辑列表行 → 生成块仍是 `- ` 列表',
253
+ edited.includes(' - 第一条') && edited.includes(' - 第二条'), edited.split('\n').filter(l => l.includes('第一条')).join('|'))
254
+ check('编辑列表行 → 路径没被拍平成一个字符串',
255
+ edited.includes('最近互动记录:\n'), edited.split('\n').slice(0, 14).join(' / '))
256
+ check('生成块包着 <initvar> 且可再解析',
257
+ generateMuvBlock(listSchema.initvarData).startsWith('<UpdateVariable>\n<initvar>'))
258
+
259
+ // ─────────────────────────────────────────────────────────
260
+ console.log('\n[7] 真实卡:往返一致性(解析→序列化→解析 必须一模一样)')
261
+ const REAL = ['苍玄界', '_足控天堂2', '食人世界', '涩涩提瓦特', '异世界农场']
262
+ let blocks = 0, notIdempotent = 0, realParsed = 0
263
+ const seen = []
264
+ for (const nm of REAL) {
265
+ const file = findCard(nm)
266
+ if (!file) continue
267
+ let card
268
+ try { card = file.endsWith('.png') ? readPngCard(file) : JSON.parse(fs.readFileSync(file, 'utf8')) } catch (_) { continue }
269
+ seen.push(nm)
270
+ // 解析不抛异常
271
+ let parsed
272
+ try { parsed = parseMuvCard(card) } catch (e) {
273
+ check(nm + ' parseMuvCard 不抛异常', false, e.message)
274
+ continue
275
+ }
276
+ realParsed++
277
+ const data = card.data && typeof card.data === 'object' && !Array.isArray(card.data) ? card.data : card
278
+ const texts = []
279
+ for (const k of ['first_mes', 'description', 'scenario']) if (typeof data[k] === 'string') texts.push(data[k])
280
+ for (const g of (Array.isArray(data.alternate_greetings) ? data.alternate_greetings : [])) if (typeof g === 'string') texts.push(g)
281
+ for (const t of texts) {
282
+ const m = t.match(/<initvar>([\s\S]*?)<\/initvar>/i)
283
+ if (!m) continue
284
+ blocks++
285
+ const once = parseInitvar(m[1])
286
+ const twice = parseInitvar(serializeInitvar(once))
287
+ if (JSON.stringify(once) !== JSON.stringify(twice)) {
288
+ notIdempotent++
289
+ if (notIdempotent <= 2) console.log(' 首个不一致: ' + nm + ' ' + firstDiff(once, twice))
290
+ }
291
+ }
292
+ check(nm + ' 解析成功(schemas=' + parsed.schemas.length + ', blocks=' + parsed.initvarBlocks.length + ')', true)
293
+ }
294
+ function firstDiff(x, y, pre = '') {
295
+ const keys = new Set([...Object.keys(x || {}), ...Object.keys(y || {})])
296
+ for (const k of keys) {
297
+ const xv = x?.[k], yv = y?.[k]
298
+ if (JSON.stringify(xv) === JSON.stringify(yv)) continue
299
+ if (xv && yv && typeof xv === 'object' && typeof yv === 'object' && !Array.isArray(xv) && !Array.isArray(yv)) {
300
+ return firstDiff(xv, yv, pre + k + '.')
301
+ }
302
+ return pre + k + ': ' + JSON.stringify(xv) + ' -> ' + JSON.stringify(yv)
303
+ }
304
+ return '(仅嵌套差异)'
305
+ }
306
+ if (!seen.length) {
307
+ console.log(' SKIP 本机没有真实卡样本')
308
+ } else {
309
+ console.log(' 真实卡: ' + seen.join(' / '))
310
+ check('真实卡的 initvar 块都被解析了 (' + blocks + ' 块)', blocks > 0)
311
+ check('每块都往返一致(0 处元数据丢失)', notIdempotent === 0, notIdempotent + ' 块不一致')
312
+ }
313
+
314
+ // ─────────────────────────────────────────────────────────
315
+ console.log('\n[8] 真实卡:苍玄界 的两个列表字段(本次修复的原始病灶)')
316
+ const cangxuan = findCard('苍玄界')
317
+ if (!cangxuan) {
318
+ console.log(' SKIP 找不到 苍玄界')
319
+ } else {
320
+ const card = cangxuan.endsWith('.png') ? readPngCard(cangxuan) : JSON.parse(fs.readFileSync(cangxuan, 'utf8'))
321
+ const parsed = parseMuvCard(card)
322
+ const WANT = ['人际交往.结识道友录.沈慕微.最近互动记录', '人际交往.结识道友录.江念.最近互动记录']
323
+ const leaves = leafPaths(parsed.schemas)
324
+ check('两条互动记录路径都在 schema 里', WANT.every(p => leaves.includes(p)),
325
+ '缺: ' + WANT.filter(p => !leaves.includes(p)).join(',') + ' (共 ' + leaves.length + ' 叶子)')
326
+ check('叶子行数 >= 67', leaves.length >= 67, 'leaves=' + leaves.length)
327
+ check('数据里是数组', Array.isArray(at(parsed.initvarData, WANT[0])), JSON.stringify(at(parsed.initvarData, WANT[0])))
328
+ }
329
+
330
+ // ─────────────────────────────────────────────────────────
331
+ console.log('\n[9] 真实卡:足控天堂2 的文档块不能顶掉真变量树')
332
+ const fk = findCard('_足控天堂2')
333
+ if (!fk) {
334
+ console.log(' SKIP 找不到 _足控天堂2')
335
+ } else {
336
+ const parsed = parseMuvCard(readPngCard(fk))
337
+ const keys = parsed.schemas.map(s => s.name)
338
+ check('schemas 仍是 8 组', parsed.schemas.length === 8, 'schemas=' + parsed.schemas.length)
339
+ check('读的是真树(公司.总现金=40000)', at(parsed.initvarData, '公司.总现金') === 40000,
340
+ '总现金=' + at(parsed.initvarData, '公司.总现金'))
341
+ check('世界书/脚本里的文档示例没被当变量', !keys.includes('示例') && !keys.includes('变量结构'), keys.join(','))
342
+ check('主播档案.$template 仍被剔除', !keys.includes('$template'))
343
+ }
344
+
345
+ // ─────────────────────────────────────────────────────────
346
+ // 这条是**钉住现状**,不是本次要修的东西:世界书里还有一种「没有 <initvar> 标签、
347
+ // 只有注释前缀 [initvar] 的裸 YAML」投递方式(`异世界农场` 的
348
+ // `[initvar]变量初始化勿开` 就是它,content 里只有 `时间:`/`种族好感度:` 这样的裸文本)。
349
+ // 按标签扫描(③ 要求的那套)确实扫不到它,所以那张卡依然是 schemas: 0。
350
+ // 要接住它需要一条**新的**判定规则(例如「comment 以 [initvar] 开头的世界书条目,
351
+ // 在彻底找不到变量块时把 content 当 initvar 块解析」),那是另一个决定 —— 见 CHANGELOG。
352
+ console.log('\n[10] 真实卡:异世界农场 —— 裸 [initvar] 世界书条目(现状钉住)')
353
+ const farm = findCard('异世界农场')
354
+ if (!farm) {
355
+ console.log(' SKIP 找不到 异世界农场')
356
+ } else {
357
+ const card = readPngCard(farm)
358
+ const entry = (card.data.character_book?.entries || [])
359
+ .find(e => /^\[initvar\]/i.test(String(e?.comment || '')))
360
+ check('确实存在注释为 [initvar]… 的世界书条目', !!entry, entry?.comment)
361
+ check('★那份 content 里没有 <initvar> 标签(所以按标签扫不到)',
362
+ !!entry && !/<initvar>/i.test(String(entry.content || '')),
363
+ 'content 前 40 字: ' + String(entry?.content || '').slice(0, 40))
364
+ const parsed = parseMuvCard(card)
365
+ check('现状:schemas 仍是 0(这条不在本轮范围内)', parsed.schemas.length === 0, 'schemas=' + parsed.schemas.length)
366
+ check('但 Zod 变量结构读得到(上一轮的修复仍然有效)', String(parsed.zodSource || '').length > 100,
367
+ 'zodSource=' + String(parsed.zodSource || '').length)
368
+ check('没有产生垃圾 schema', parsed.schemas.length === 0 && Object.keys(parsed.initvarData || {}).length === 0)
369
+ }
370
+
371
+ console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`)
372
+ process.exit(fail ? 1 : 0)
@@ -0,0 +1,210 @@
1
+ // Regression: preset resolution in `/api/muv-table/tavern-card`.
2
+ //
3
+ // Run: node test-preset-resolve.mjs
4
+ //
5
+ // ② 的实测症状:`?presetId=does-not-exist` 会静默返回 tavern-lite 的「川上富江」
6
+ // (HTTP 200 / found:true / cardName=川上富江),用户以为在看自己的卡。
7
+ // 预设改名或重名(历史上「深渊区」→「精简酒馆」)同样中招。
8
+ //
9
+ // 这个用例**不碰用户真实环境**:先把 DSH_HOME 指到一个临时目录,造出假预设,
10
+ // 再动态 import 插件(模块级 PRESETS_ROOT 是 import 时确定的),然后走真实路由。
11
+ // 因此它的结果在任何机器上一致,而不是「本机恰好有哪张卡」。
12
+
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import os from 'node:os'
16
+
17
+ let pass = 0, fail = 0
18
+ function check(name, cond, detail) {
19
+ if (cond) { pass++; console.log(' OK ' + name) }
20
+ else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
21
+ }
22
+
23
+ console.log('=== 预设定位回归(临时 DSH_HOME,不碰真实环境)===\n')
24
+
25
+ // ── 临时环境 ──────────────────────────────────────────────
26
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-muv-preset-'))
27
+ const presets = path.join(tmp, '.agent-presets')
28
+ const cardsDir = path.join(tmp, 'cards')
29
+ fs.mkdirSync(presets, { recursive: true })
30
+ fs.mkdirSync(cardsDir, { recursive: true })
31
+
32
+ const PERSONA_YML = [
33
+ 'name: "@deepseek-ai/dsh-persona"',
34
+ 'text: |-',
35
+ ' 角色名:测试预设',
36
+ ' # 没有 <initvar>,变量树从 characters.json 取',
37
+ '',
38
+ ].join('\n')
39
+
40
+ /** A preset directory with one card in tavern's own store. */
41
+ function writePreset(id, cardName, desc) {
42
+ const dir = path.join(presets, id)
43
+ fs.mkdirSync(dir, { recursive: true })
44
+ fs.writeFileSync(path.join(dir, 'agent.cordis.yml'), PERSONA_YML, 'utf8')
45
+ fs.writeFileSync(path.join(dir, 'characters.json'), JSON.stringify([
46
+ { name: cardName, desc, first: '', enabled: true },
47
+ ]), 'utf8')
48
+ }
49
+
50
+ const LIST_DESC = [
51
+ '开场。',
52
+ '<initvar>',
53
+ '主角状态:',
54
+ ' 名字: 测试',
55
+ '人际交往:',
56
+ ' 最近互动记录: ',
57
+ ' - 第一条互动',
58
+ ' - 第二条互动',
59
+ '</initvar>',
60
+ ].join('\n')
61
+
62
+ writePreset('tavern-lite', 'ZZ-MUV-DEFAULT-CARD', '<initvar>\n默认卡: 1\n</initvar>')
63
+ writePreset('real-preset', 'ZZ-MUV-REAL-CARD', LIST_DESC)
64
+ writePreset('malformed-preset', 'ZZ-MUV-MALFORMED-CARD', '没有变量')
65
+
66
+ // A library card whose `alternate_greetings` is an object instead of a list — the
67
+ // shape that used to make parseMuvCard throw and the endpoint answer 500.
68
+ fs.writeFileSync(path.join(cardsDir, 'ZZ-MUV-MALFORMED-CARD.json'), JSON.stringify({
69
+ data: { name: 'ZZ-MUV-MALFORMED-CARD', first_mes: 'hi', alternate_greetings: { a: 'x' } },
70
+ }), 'utf8')
71
+
72
+ // The same trick with a card that is fine, to prove the library lookup still works.
73
+ fs.writeFileSync(path.join(cardsDir, 'ZZ-MUV-LIB-CARD.json'), JSON.stringify({
74
+ data: { name: 'ZZ-MUV-LIB-CARD', first_mes: '<initvar>\n库卡: 1\n</initvar>' },
75
+ }), 'utf8')
76
+ writePreset('lib-preset', 'ZZ-MUV-LIB-CARD', '没有变量')
77
+
78
+ fs.writeFileSync(path.join(presets, 'session-bindings.json'), JSON.stringify({
79
+ 'session-bound-real': 'real-preset',
80
+ 'session-bound-gone': 'renamed-away', // 预设改过名 → 绑定悬空
81
+ 'session-bound-default': 'default', // 酒馆语义:default ⇒ tavern-lite
82
+ }), 'utf8')
83
+
84
+ process.env.DSH_HOME = tmp
85
+ process.env.DSH_MUV_CARD_DIRS = cardsDir
86
+
87
+ const { apply } = await import('./lib/index.js')
88
+
89
+ const routes = []
90
+ apply({ get: () => undefined, webServer: { register: (r) => routes.push(r) } })
91
+ const route = routes.find(r => r.path === '/api/muv-table/tavern-card')
92
+ check('路由已注册', !!route, routes.map(r => r.path).join(' '))
93
+
94
+ async function call(qs) {
95
+ let status = 0, raw = ''
96
+ await route.handler(
97
+ { method: 'GET', url: '/api/muv-table/tavern-card' + qs },
98
+ { writeHead: (s) => { status = s }, end: (b) => { raw = String(b) } },
99
+ )
100
+ let body = null
101
+ try { body = JSON.parse(raw) } catch (_) {}
102
+ return { status, body }
103
+ }
104
+
105
+ function show(label, r) {
106
+ console.log(' ' + label + ' -> HTTP ' + r.status + ' ' + JSON.stringify({
107
+ ok: r.body?.ok, found: r.body?.found, cardName: r.body?.cardName,
108
+ presetDir: r.body?.presetDir, presetSource: r.body?.presetSource, error: r.body?.error,
109
+ }))
110
+ }
111
+
112
+ // ── 1) 显式 presetId 不存在:必须明说,不许换卡 ─────────────
113
+ console.log('\n[1] ?presetId=does-not-exist')
114
+ const r1 = await call('?presetId=does-not-exist')
115
+ show('返回', r1)
116
+ check('HTTP 200(不是 500)', r1.status === 200, 'status=' + r1.status)
117
+ check('found:false', r1.body?.found === false, 'found=' + r1.body?.found)
118
+ check('错误里点名了那个 id',
119
+ typeof r1.body?.error === 'string' && r1.body.error.includes('does-not-exist'),
120
+ JSON.stringify(r1.body?.error))
121
+ check('reason=preset-not-found', r1.body?.reason === 'preset-not-found', r1.body?.reason)
122
+ check('★没有偷偷换成默认预设的卡',
123
+ r1.body?.cardName !== 'ZZ-MUV-DEFAULT-CARD' && r1.body?.presetDir === undefined,
124
+ 'cardName=' + r1.body?.cardName + ' presetDir=' + r1.body?.presetDir)
125
+ check('顺便给出真实存在的预设清单',
126
+ Array.isArray(r1.body?.availablePresets) && r1.body.availablePresets.includes('real-preset'),
127
+ JSON.stringify(r1.body?.availablePresets))
128
+
129
+ // ── 2) 合法 preset:数据必须完整到手(① 也一起走通) ────────
130
+ console.log('\n[2] ?presetId=real-preset(合法)')
131
+ const r2 = await call('?presetId=real-preset')
132
+ show('返回', r2)
133
+ check('found:true', r2.body?.found === true, 'found=' + r2.body?.found)
134
+ check('cardName 正确', r2.body?.cardName === 'ZZ-MUV-REAL-CARD', r2.body?.cardName)
135
+ check('presetDir 正确', r2.body?.presetDir === 'real-preset', r2.body?.presetDir)
136
+ check('presetSource=explicit', r2.body?.presetSource === 'explicit', r2.body?.presetSource)
137
+ const listVal = r2.body?.initvarData?.人际交往?.最近互动记录
138
+ check('★列表内容端到端到手(①)', Array.isArray(listVal) && listVal.length === 2,
139
+ JSON.stringify(listVal))
140
+ const listField = (r2.body?.schemas || []).find(s => s.name === '人际交往')
141
+ ?.children?.find(c => c.name === '最近互动记录')
142
+ check('列表进了 schema', !!listField && listField.isList === true,
143
+ JSON.stringify(listField))
144
+
145
+ // ── 3) 路径穿越 ───────────────────────────────────────────
146
+ console.log('\n[3] ?presetId=../tavern-lite(越界)')
147
+ const r3 = await call('?presetId=' + encodeURIComponent('../tavern-lite'))
148
+ show('返回', r3)
149
+ check('found:false(不解析带分隔符的 id)', r3.body?.found === false, 'found=' + r3.body?.found)
150
+
151
+ // ── 4) 会话绑定 ──────────────────────────────────────────
152
+ console.log('\n[4] ?sessionId=session-bound-real')
153
+ const r4 = await call('?sessionId=session-bound-real')
154
+ show('返回', r4)
155
+ check('found:true', r4.body?.found === true, 'found=' + r4.body?.found)
156
+ check('按绑定取到 real-preset', r4.body?.presetDir === 'real-preset', r4.body?.presetDir)
157
+ check('presetSource=session', r4.body?.presetSource === 'session', r4.body?.presetSource)
158
+
159
+ console.log('\n[5] ?sessionId=session-bound-gone(绑定指向一个已改名的预设)')
160
+ const r5 = await call('?sessionId=session-bound-gone')
161
+ show('返回', r5)
162
+ check('found:false(悬空绑定不再静默换卡)', r5.body?.found === false, 'found=' + r5.body?.found)
163
+ check('错误里点名了那个已消失的预设',
164
+ typeof r5.body?.error === 'string' && r5.body.error.includes('renamed-away'),
165
+ JSON.stringify(r5.body?.error))
166
+ check('★没有偷偷换成默认预设的卡', r5.body?.cardName !== 'ZZ-MUV-DEFAULT-CARD', 'cardName=' + r5.body?.cardName)
167
+
168
+ console.log('\n[6] ?sessionId=session-bound-default(绑定是 default)')
169
+ const r6 = await call('?sessionId=session-bound-default')
170
+ show('返回', r6)
171
+ check('found:true(酒馆自己把 default 当 tavern-lite)', r6.body?.found === true, 'found=' + r6.body?.found)
172
+ check('落在 tavern-lite', r6.body?.presetDir === 'tavern-lite', r6.body?.presetDir)
173
+ check('★来源标明 session-default(不是谎称显式指定)',
174
+ r6.body?.presetSource === 'session-default', r6.body?.presetSource)
175
+
176
+ console.log('\n[7] ?sessionId=session-unbound(完全没有绑定记录)')
177
+ const r7 = await call('?sessionId=session-unbound')
178
+ show('返回', r7)
179
+ check('结果可解释:要么 found:false,要么标成 session-default',
180
+ r7.body?.found === false || r7.body?.presetSource === 'session-default',
181
+ 'found=' + r7.body?.found + ' source=' + r7.body?.presetSource)
182
+ check('绝不谎称 explicit', r7.body?.presetSource !== 'explicit', r7.body?.presetSource)
183
+
184
+ console.log('\n[8] 两个定位参数同时给:presetId 优先(客户端只该给一个)')
185
+ const r8 = await call('?presetId=real-preset&sessionId=session-bound-default')
186
+ show('返回', r8)
187
+ check('用 presetId 那个', r8.body?.presetDir === 'real-preset' && r8.body?.presetSource === 'explicit',
188
+ r8.body?.presetDir + '/' + r8.body?.presetSource)
189
+
190
+ // ── 9) 畸形卡不再 500(④ 的端点面) ────────────────────────
191
+ console.log('\n[9] 畸形卡(alternate_greetings 是对象)走端点')
192
+ const r9 = await call('?presetId=malformed-preset')
193
+ show('返回', r9)
194
+ check('HTTP 200(不是 500)', r9.status === 200, 'status=' + r9.status + ' err=' + r9.body?.error)
195
+ check('仍返回同一张卡、只是变量表为空',
196
+ r9.body?.found === true && r9.body?.cardName === 'ZZ-MUV-MALFORMED-CARD' && (r9.body?.schemas || []).length === 0,
197
+ JSON.stringify({ found: r9.body?.found, name: r9.body?.cardName, schemas: r9.body?.schemas?.length }))
198
+
199
+ console.log('\n[10] 同一张卡库查找仍然正常(别把库路径改坏)')
200
+ const r10 = await call('?presetId=lib-preset')
201
+ show('返回', r10)
202
+ check('从卡库按名找到', r10.body?.cardSource === 'library', r10.body?.cardSource)
203
+ check('变量读得到', Object.keys(r10.body?.initvarData || {}).join(',') === '库卡',
204
+ Object.keys(r10.body?.initvarData || {}).join(','))
205
+
206
+ // 清理
207
+ try { fs.rmSync(tmp, { recursive: true, force: true }) } catch (_) {}
208
+
209
+ console.log(`\n=== 结果: ${pass} 通过, ${fail} 失败 ===`)
210
+ process.exit(fail ? 1 : 0)