dsh-plugin-tlrp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { createRequire } from 'node:module'
4
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ const require = createRequire(import.meta.url)
9
+ const core = require('../lib/core.cjs')
10
+
11
+ const readFrom = (map) => (field) => (field in map ? map[field] : undefined)
12
+ const firefly = core.findCharacter(core.DEFAULT_CHARACTERS, 'firefly')
13
+
14
+ const baseConfig = (extra = {}) => ({
15
+ enabled: true,
16
+ mode: 'work',
17
+ modes: [
18
+ { id: 'work', name: '战术工作态', commandAliases: ['/work'], injectCharacterPersona: false, enableStatusFooter: false },
19
+ { id: 'rp', name: '陪伴态', commandAliases: ['/rp'], injectCharacterPersona: true },
20
+ ],
21
+ characters: core.DEFAULT_CHARACTERS,
22
+ activeCharacterId: 'firefly',
23
+ workModeKeywords: core.DEFAULT_WORK_KEYWORDS,
24
+ triggers: [],
25
+ timeZone: 'Asia/Shanghai',
26
+ statusFooterLabel: '流萤状态同步',
27
+ maxTriggerInjections: 3,
28
+ enableMemoryBridge: false,
29
+ enableStatusFooter: true,
30
+ customSceneOverride: '',
31
+ styleLayerDefault: 'off',
32
+ styleEmojiHint: '一屏最多 2 个 emoji。',
33
+ stickerDir: '',
34
+ galleryDir: '',
35
+ maxStickersPerTurn: 2,
36
+ ...extra,
37
+ })
38
+
39
+ /* ── 开关解析 ── */
40
+
41
+ test('resolveStyleLayer:会话覆盖 > 形态自身 > 全局默认', () => {
42
+ assert.deepEqual(core.resolveStyleLayer({ modeLayer: 'off', globalDefault: 'on', sessionOverride: 'on' }), {
43
+ on: true,
44
+ dialog: false,
45
+ source: 'session',
46
+ })
47
+ assert.deepEqual(core.resolveStyleLayer({ modeLayer: 'on', globalDefault: 'off' }), {
48
+ on: true,
49
+ dialog: false,
50
+ source: 'mode',
51
+ })
52
+ assert.deepEqual(core.resolveStyleLayer({ modeLayer: 'inherit', globalDefault: 'on' }), {
53
+ on: true,
54
+ dialog: false,
55
+ source: 'global',
56
+ })
57
+ assert.deepEqual(core.resolveStyleLayer({}), { on: false, dialog: false, source: 'global' })
58
+ })
59
+
60
+ test('normalizeStyleLayerFlag 兼容布尔值与大小写,未知值回落 inherit', () => {
61
+ assert.equal(core.normalizeStyleLayerFlag(true), 'on')
62
+ assert.equal(core.normalizeStyleLayerFlag(false), 'off')
63
+ assert.equal(core.normalizeStyleLayerFlag('OFF'), 'off')
64
+ assert.equal(core.normalizeStyleLayerFlag(' On '), 'on')
65
+ assert.equal(core.normalizeStyleLayerFlag('whatever'), 'inherit')
66
+ assert.equal(core.normalizeStyleLayerFlag(undefined), 'inherit')
67
+ })
68
+
69
+ test('normalizeMode 把 styleLayer 带进形态定义', () => {
70
+ assert.equal(core.normalizeMode({ id: 'x', styleLayer: 'off' }).styleLayer, 'off')
71
+ assert.equal(core.normalizeMode({ id: 'x', styleLayer: 'on' }).styleLayer, 'on')
72
+ assert.equal(core.normalizeMode({ id: 'x' }).styleLayer, 'inherit')
73
+ // 内置形态默认跟随全局
74
+ for (const mode of core.normalizeModes(core.DEFAULT_MODES)) assert.equal(mode.styleLayer, 'inherit')
75
+ })
76
+
77
+ /* ── 本地图库扫描 ── */
78
+
79
+ test('scanStickerLibrary 只认图片扩展名、跳过子目录、目录缺失降级为空', () => {
80
+ const dir = mkdtempSync(join(tmpdir(), 'tlrp-stickers-'))
81
+ try {
82
+ writeFileSync(join(dir, '开心.png'), 'x')
83
+ writeFileSync(join(dir, '无语.GIF'), 'x')
84
+ writeFileSync(join(dir, '说明.txt'), 'x')
85
+ mkdirSync(join(dir, 'nested'))
86
+ const items = core.scanStickerLibrary(dir)
87
+ assert.deepEqual(items.map((item) => item.tag).sort(), ['无语', '开心'].sort())
88
+ for (const item of items) {
89
+ assert.ok(item.path.includes('/'), `路径应统一成正斜杠:${item.path}`)
90
+ assert.ok(!item.path.includes('\\'))
91
+ }
92
+ assert.deepEqual(core.scanStickerLibrary(join(dir, 'missing')), [])
93
+ assert.deepEqual(core.scanStickerLibrary(''), [])
94
+ assert.equal(core.scanStickerLibrary(dir, { limit: 1 }).length, 1)
95
+ } finally {
96
+ rmSync(dir, { recursive: true, force: true })
97
+ }
98
+ })
99
+
100
+ test('stickerDirOf / galleryDirOf 缺省落到 ~/.dsh 下', () => {
101
+ const sticker = core.stickerDirOf('', 'firefly')
102
+ const gallery = core.galleryDirOf('')
103
+ assert.match(sticker.replace(/\\/g, '/'), /\/\.dsh\/stickers\/firefly$/)
104
+ assert.match(gallery.replace(/\\/g, '/'), /\/\.dsh\/gallery$/)
105
+ assert.equal(core.stickerDirOf('D:/mine', 'firefly'), 'D:/mine')
106
+ assert.equal(core.galleryDirOf('D:/pics'), 'D:/pics')
107
+ })
108
+
109
+ /* ── 风格块渲染 ── */
110
+
111
+ test('renderStyleBlock:完整人设时只补表情与贴纸,精简时带上语言风格', () => {
112
+ const stickers = [{ tag: '开心', path: 'C:/x/stickers/开心.png' }]
113
+ const lean = core.renderStyleBlock(firefly, {
114
+ fullPersona: false,
115
+ emojiHint: '一屏最多 2 个',
116
+ stickers,
117
+ maxStickersPerTurn: 2,
118
+ })
119
+ assert.match(lean, /风格层/)
120
+ assert.match(lean, /语言风格与禁用项/)
121
+ assert.match(lean, /台词语气锚点/)
122
+ assert.match(lean, /一屏最多 2 个/)
123
+ assert.match(lean, /!\[开心\]\(C:\/x\/stickers\/开心\.png\)/)
124
+ assert.match(lean, /不写 \*斜体动作\*/)
125
+
126
+ const full = core.renderStyleBlock(firefly, {
127
+ fullPersona: true,
128
+ emojiHint: '一屏最多 2 个',
129
+ stickers,
130
+ maxStickersPerTurn: 2,
131
+ })
132
+ assert.doesNotMatch(full, /语言风格与禁用项/)
133
+ assert.doesNotMatch(full, /台词语气锚点/)
134
+ assert.match(full, /表情包(标签 → 可直接粘贴的行)/)
135
+
136
+ const silent = core.renderStyleBlock(firefly, { fullPersona: false, emojiHint: '', maxStickersPerTurn: 0 })
137
+ assert.match(silent, /本轮不使用 emoji/)
138
+ assert.match(silent, /本轮禁止贴任何表情包或图片/)
139
+ })
140
+
141
+ test('renderStyleBlock 无图库时不编造路径', () => {
142
+ const text = core.renderStyleBlock(firefly, { fullPersona: true, emojiHint: 'ok', stickers: [], gallery: [], maxStickersPerTurn: 2 })
143
+ assert.doesNotMatch(text, /!\[/)
144
+ assert.doesNotMatch(text, /本地图库/)
145
+ })
146
+
147
+ /* ── 指令接线 ── */
148
+
149
+ test('parseCommand 识别 /style 子命令', () => {
150
+ const on = core.parseCommand('/style on')
151
+ assert.equal(on.kind, 'command')
152
+ assert.equal(on.command, '/style')
153
+ assert.equal(on.arg, 'on')
154
+ assert.equal(on.mode, '')
155
+
156
+ const status = core.parseCommand('/style')
157
+ assert.equal(status.command, '/style')
158
+ assert.equal(status.arg, '')
159
+ })
160
+
161
+ /* ── 快照与注入 ── */
162
+
163
+ test('snapshot:风格层按会话 > 形态 > 全局解析,关掉时不产出 block', () => {
164
+ const state = core.createState({ read: readFrom(baseConfig({ styleLayerDefault: 'on' })) })
165
+ const agent = { session: { id: 's-style-global' } }
166
+ const on = core.snapshot(state, agent)
167
+ assert.equal(on.style.on, true)
168
+ assert.equal(on.style.source, 'global')
169
+ assert.match(on.style.block, /风格层/)
170
+
171
+ state.sessionState(agent).styleLayer = 'off'
172
+ const off = core.snapshot(state, agent)
173
+ assert.equal(off.style.on, false)
174
+ assert.equal(off.style.source, 'session')
175
+ assert.equal(off.style.block, '')
176
+
177
+ const modeState = core.createState({
178
+ read: readFrom(
179
+ baseConfig({
180
+ styleLayerDefault: 'on',
181
+ modes: [{ id: 'work', name: '战术工作态', injectCharacterPersona: false, styleLayer: 'off' }],
182
+ }),
183
+ ),
184
+ })
185
+ const modeSnap = core.snapshot(modeState, { session: { id: 's-style-mode' } })
186
+ assert.equal(modeSnap.style.on, false)
187
+ assert.equal(modeSnap.style.source, 'mode')
188
+ })
189
+
190
+ test('snapshot:图库目录里的图片进入风格块并计数', () => {
191
+ const dir = mkdtempSync(join(tmpdir(), 'tlrp-gallery-'))
192
+ try {
193
+ writeFileSync(join(dir, '天台日落.png'), 'x')
194
+ const state = core.createState({
195
+ read: readFrom(baseConfig({ styleLayerDefault: 'on', galleryDir: dir, maxStickersPerTurn: 1 })),
196
+ })
197
+ const snap = core.snapshot(state, { session: { id: 's-gallery' } })
198
+ assert.equal(snap.style.galleryCount, 1)
199
+ assert.match(snap.style.block, /天台日落\.png/)
200
+ assert.match(snap.style.block, /每轮最多贴 1 张/)
201
+ } finally {
202
+ rmSync(dir, { recursive: true, force: true })
203
+ }
204
+ })
205
+
206
+ test('renderModeDirective 把风格层追加在形态准则末尾,不改形态红线', () => {
207
+ const styleBlock = core.renderStyleBlock(firefly, { fullPersona: false, emojiHint: 'ok', maxStickersPerTurn: 0 })
208
+ const text = core.renderModeDirective({
209
+ character: firefly,
210
+ mode: 'work',
211
+ modeDef: core.findMode(core.DEFAULT_MODES, 'work'),
212
+ scene: '测试场景',
213
+ triggers: [],
214
+ footerEnabled: false,
215
+ footerLabel: '测试',
216
+ style: { on: true, source: 'global', block: styleBlock },
217
+ })
218
+ assert.match(text, /运维纪律在本轮优先级高于人设/)
219
+ assert.ok(text.trimEnd().endsWith('本轮禁止贴任何表情包或图片。'))
220
+
221
+ const withoutStyle = core.renderModeDirective({
222
+ character: firefly,
223
+ mode: 'work',
224
+ modeDef: core.findMode(core.DEFAULT_MODES, 'work'),
225
+ scene: '测试场景',
226
+ triggers: [],
227
+ footerEnabled: false,
228
+ footerLabel: '测试',
229
+ })
230
+ assert.doesNotMatch(withoutStyle, /风格层/)
231
+ })
232
+
233
+ test('renderTurnContext 报告风格层来源与图库数量', () => {
234
+ const text = core.renderTurnContext({
235
+ mode: 'work',
236
+ modeDef: core.findMode(core.DEFAULT_MODES, 'work'),
237
+ character: firefly,
238
+ scene: '测试场景',
239
+ triggers: [],
240
+ footerEnabled: false,
241
+ footerLabel: '测试',
242
+ reason: '测试',
243
+ now: '2026-11-02 星期一 18:00',
244
+ cwd: 'C:/tmp',
245
+ memory: { text: '' },
246
+ style: { on: true, source: 'session', block: '', stickerCount: 3, galleryCount: 1 },
247
+ })
248
+ assert.match(text, /风格层:开(来源:会话 \/style 覆盖)/)
249
+ assert.match(text, /表情包 3 张、图库 1 张/)
250
+
251
+ const offText = core.renderTurnContext({
252
+ mode: 'work',
253
+ modeDef: core.findMode(core.DEFAULT_MODES, 'work'),
254
+ character: firefly,
255
+ scene: '测试场景',
256
+ triggers: [],
257
+ footerEnabled: false,
258
+ footerLabel: '测试',
259
+ reason: '测试',
260
+ now: '2026-11-02 星期一 18:00',
261
+ cwd: 'C:/tmp',
262
+ memory: { text: '' },
263
+ style: { on: false, source: 'global', block: '', stickerCount: 0, galleryCount: 0 },
264
+ })
265
+ assert.match(offText, /风格层:关(来源:全局 styleLayerDefault)/)
266
+ })
267
+
268
+ /* ── 对话体档(dialog)── */
269
+
270
+ test('dialog 档:别名归一、优先级与三分量解析', () => {
271
+ assert.equal(core.normalizeStyleLayerFlag('dialog'), 'dialog')
272
+ assert.equal(core.normalizeStyleLayerFlag('对话体'), 'dialog')
273
+ assert.equal(core.normalizeStyleLayerFlag('CHAT'), 'dialog')
274
+
275
+ assert.deepEqual(core.resolveStyleLayer({ modeLayer: 'dialog', globalDefault: 'off' }), {
276
+ on: true,
277
+ dialog: true,
278
+ source: 'mode',
279
+ })
280
+ assert.deepEqual(core.resolveStyleLayer({ globalDefault: 'dialog' }), { on: true, dialog: true, source: 'global' })
281
+ assert.deepEqual(core.resolveStyleLayer({ modeLayer: 'dialog', sessionOverride: 'on' }), {
282
+ on: true,
283
+ dialog: false,
284
+ source: 'session',
285
+ })
286
+ assert.equal(core.styleLayerFlagText('dialog'), '对话体(只说话,不演动作与旁白)')
287
+ })
288
+
289
+ test('renderStyleBlock 对话体:换标题、给表演强度红线、禁旁白', () => {
290
+ const text = core.renderStyleBlock(firefly, {
291
+ fullPersona: true,
292
+ emojiHint: 'ok',
293
+ dialogOnly: true,
294
+ maxStickersPerTurn: 0,
295
+ })
296
+ assert.match(text, /### 风格层(本轮生效:对话体,只说话不演)/)
297
+ assert.doesNotMatch(text, /只带语言风格,不演动作/)
298
+ assert.match(text, /本轮的表演强度/)
299
+ assert.match(text, /只输出「流萤[^」]*」说出口的话,不写旁白/)
300
+ assert.match(text, /禁止环境与天气描写/)
301
+ assert.match(text, /禁止外貌与服饰特写/)
302
+ assert.match(text, /状态面板照旧输出/)
303
+
304
+ // 非对话体不出现这段
305
+ const plain = core.renderStyleBlock(firefly, { fullPersona: true, emojiHint: 'ok', maxStickersPerTurn: 0 })
306
+ assert.doesNotMatch(plain, /本轮的表演强度/)
307
+ })
308
+
309
+ test('snapshot 对话体:全局档位透传到 style 与状态块', () => {
310
+ const state = core.createState({ read: readFrom(baseConfig({ styleLayerDefault: 'dialog' })) })
311
+ const snap = core.snapshot(state, { session: { id: 's-dialog' } })
312
+ assert.equal(snap.style.on, true)
313
+ assert.equal(snap.style.dialog, true)
314
+ assert.equal(snap.style.source, 'global')
315
+ assert.match(snap.style.block, /对话体/)
316
+
317
+ // 形态写 off 时对话体仍然被压掉
318
+ const modeState = core.createState({
319
+ read: readFrom(
320
+ baseConfig({
321
+ styleLayerDefault: 'dialog',
322
+ modes: [{ id: 'work', name: '战术工作态', injectCharacterPersona: false, styleLayer: 'off' }],
323
+ }),
324
+ ),
325
+ })
326
+ const modeSnap = core.snapshot(modeState, { session: { id: 's-dialog-off' } })
327
+ assert.equal(modeSnap.style.on, false)
328
+ assert.equal(modeSnap.style.dialog, false)
329
+ assert.equal(modeSnap.style.block, '')
330
+ })
331
+
332
+ test('/style dialog 被解析成 dialog 档', () => {
333
+ const parsed = core.parseCommand('/style dialog')
334
+ assert.equal(parsed.command, '/style')
335
+ assert.equal(core.normalizeStyleLayerFlag(parsed.arg), 'dialog')
336
+ })