@zhin.js/core 1.0.25 → 1.0.27

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.
Files changed (202) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +84 -342
  3. package/lib/adapter.d.ts +17 -0
  4. package/lib/adapter.d.ts.map +1 -1
  5. package/lib/adapter.js +84 -2
  6. package/lib/adapter.js.map +1 -1
  7. package/lib/ai/agent.d.ts +126 -0
  8. package/lib/ai/agent.d.ts.map +1 -0
  9. package/lib/ai/agent.js +645 -0
  10. package/lib/ai/agent.js.map +1 -0
  11. package/lib/ai/context-manager.d.ts +213 -0
  12. package/lib/ai/context-manager.d.ts.map +1 -0
  13. package/lib/ai/context-manager.js +313 -0
  14. package/lib/ai/context-manager.js.map +1 -0
  15. package/lib/ai/conversation-memory.d.ts +181 -0
  16. package/lib/ai/conversation-memory.d.ts.map +1 -0
  17. package/lib/ai/conversation-memory.js +581 -0
  18. package/lib/ai/conversation-memory.js.map +1 -0
  19. package/lib/ai/follow-up.d.ts +131 -0
  20. package/lib/ai/follow-up.d.ts.map +1 -0
  21. package/lib/ai/follow-up.js +265 -0
  22. package/lib/ai/follow-up.js.map +1 -0
  23. package/lib/ai/index.d.ts +29 -0
  24. package/lib/ai/index.d.ts.map +1 -0
  25. package/lib/ai/index.js +34 -0
  26. package/lib/ai/index.js.map +1 -0
  27. package/lib/ai/init.d.ts +30 -0
  28. package/lib/ai/init.d.ts.map +1 -0
  29. package/lib/ai/init.js +424 -0
  30. package/lib/ai/init.js.map +1 -0
  31. package/lib/ai/output.d.ts +93 -0
  32. package/lib/ai/output.d.ts.map +1 -0
  33. package/lib/ai/output.js +176 -0
  34. package/lib/ai/output.js.map +1 -0
  35. package/lib/ai/providers/anthropic.d.ts +23 -0
  36. package/lib/ai/providers/anthropic.d.ts.map +1 -0
  37. package/lib/ai/providers/anthropic.js +322 -0
  38. package/lib/ai/providers/anthropic.js.map +1 -0
  39. package/lib/ai/providers/base.d.ts +43 -0
  40. package/lib/ai/providers/base.d.ts.map +1 -0
  41. package/lib/ai/providers/base.js +135 -0
  42. package/lib/ai/providers/base.js.map +1 -0
  43. package/lib/ai/providers/index.d.ts +12 -0
  44. package/lib/ai/providers/index.d.ts.map +1 -0
  45. package/lib/ai/providers/index.js +9 -0
  46. package/lib/ai/providers/index.js.map +1 -0
  47. package/lib/ai/providers/ollama.d.ts +25 -0
  48. package/lib/ai/providers/ollama.d.ts.map +1 -0
  49. package/lib/ai/providers/ollama.js +243 -0
  50. package/lib/ai/providers/ollama.js.map +1 -0
  51. package/lib/ai/providers/openai.d.ts +46 -0
  52. package/lib/ai/providers/openai.d.ts.map +1 -0
  53. package/lib/ai/providers/openai.js +132 -0
  54. package/lib/ai/providers/openai.js.map +1 -0
  55. package/lib/ai/rate-limiter.d.ts +38 -0
  56. package/lib/ai/rate-limiter.d.ts.map +1 -0
  57. package/lib/ai/rate-limiter.js +86 -0
  58. package/lib/ai/rate-limiter.js.map +1 -0
  59. package/lib/ai/service.d.ts +81 -0
  60. package/lib/ai/service.d.ts.map +1 -0
  61. package/lib/ai/service.js +274 -0
  62. package/lib/ai/service.js.map +1 -0
  63. package/lib/ai/session.d.ts +186 -0
  64. package/lib/ai/session.d.ts.map +1 -0
  65. package/lib/ai/session.js +443 -0
  66. package/lib/ai/session.js.map +1 -0
  67. package/lib/ai/tone-detector.d.ts +19 -0
  68. package/lib/ai/tone-detector.d.ts.map +1 -0
  69. package/lib/ai/tone-detector.js +72 -0
  70. package/lib/ai/tone-detector.js.map +1 -0
  71. package/lib/ai/tools.d.ts +45 -0
  72. package/lib/ai/tools.d.ts.map +1 -0
  73. package/lib/ai/tools.js +206 -0
  74. package/lib/ai/tools.js.map +1 -0
  75. package/lib/ai/types.d.ts +264 -0
  76. package/lib/ai/types.d.ts.map +1 -0
  77. package/lib/ai/types.js +6 -0
  78. package/lib/ai/types.js.map +1 -0
  79. package/lib/ai/user-profile.d.ts +56 -0
  80. package/lib/ai/user-profile.d.ts.map +1 -0
  81. package/lib/ai/user-profile.js +130 -0
  82. package/lib/ai/user-profile.js.map +1 -0
  83. package/lib/ai/zhin-agent.d.ts +165 -0
  84. package/lib/ai/zhin-agent.d.ts.map +1 -0
  85. package/lib/ai/zhin-agent.js +707 -0
  86. package/lib/ai/zhin-agent.js.map +1 -0
  87. package/lib/built/ai-trigger.d.ts.map +1 -1
  88. package/lib/built/ai-trigger.js +7 -3
  89. package/lib/built/ai-trigger.js.map +1 -1
  90. package/lib/built/command.d.ts +33 -17
  91. package/lib/built/command.d.ts.map +1 -1
  92. package/lib/built/command.js +71 -44
  93. package/lib/built/command.js.map +1 -1
  94. package/lib/built/component.d.ts +42 -15
  95. package/lib/built/component.d.ts.map +1 -1
  96. package/lib/built/component.js +84 -52
  97. package/lib/built/component.js.map +1 -1
  98. package/lib/built/config.d.ts +64 -5
  99. package/lib/built/config.d.ts.map +1 -1
  100. package/lib/built/config.js +129 -12
  101. package/lib/built/config.js.map +1 -1
  102. package/lib/built/cron.d.ts +41 -18
  103. package/lib/built/cron.d.ts.map +1 -1
  104. package/lib/built/cron.js +106 -63
  105. package/lib/built/cron.js.map +1 -1
  106. package/lib/built/database.d.ts +55 -6
  107. package/lib/built/database.d.ts.map +1 -1
  108. package/lib/built/database.js +93 -22
  109. package/lib/built/database.js.map +1 -1
  110. package/lib/built/dispatcher.d.ts +118 -0
  111. package/lib/built/dispatcher.d.ts.map +1 -0
  112. package/lib/built/dispatcher.js +196 -0
  113. package/lib/built/dispatcher.js.map +1 -0
  114. package/lib/built/permission.d.ts +45 -5
  115. package/lib/built/permission.d.ts.map +1 -1
  116. package/lib/built/permission.js +56 -11
  117. package/lib/built/permission.js.map +1 -1
  118. package/lib/built/skill.d.ts +117 -0
  119. package/lib/built/skill.d.ts.map +1 -0
  120. package/lib/built/skill.js +191 -0
  121. package/lib/built/skill.js.map +1 -0
  122. package/lib/built/tool.d.ts +71 -164
  123. package/lib/built/tool.d.ts.map +1 -1
  124. package/lib/built/tool.js +212 -297
  125. package/lib/built/tool.js.map +1 -1
  126. package/lib/feature.d.ts +75 -0
  127. package/lib/feature.d.ts.map +1 -0
  128. package/lib/feature.js +69 -0
  129. package/lib/feature.js.map +1 -0
  130. package/lib/index.d.ts +4 -0
  131. package/lib/index.d.ts.map +1 -1
  132. package/lib/index.js +7 -0
  133. package/lib/index.js.map +1 -1
  134. package/lib/plugin.d.ts +25 -17
  135. package/lib/plugin.d.ts.map +1 -1
  136. package/lib/plugin.js +180 -20
  137. package/lib/plugin.js.map +1 -1
  138. package/lib/types.d.ts +4 -9
  139. package/lib/types.d.ts.map +1 -1
  140. package/package.json +6 -6
  141. package/src/adapter.ts +101 -2
  142. package/src/ai/agent.ts +772 -0
  143. package/src/ai/context-manager.ts +440 -0
  144. package/src/ai/conversation-memory.ts +774 -0
  145. package/src/ai/follow-up.ts +357 -0
  146. package/src/ai/index.ts +128 -0
  147. package/src/ai/init.ts +502 -0
  148. package/src/ai/output.ts +261 -0
  149. package/src/ai/providers/anthropic.ts +375 -0
  150. package/src/ai/providers/base.ts +173 -0
  151. package/src/ai/providers/index.ts +13 -0
  152. package/src/ai/providers/ollama.ts +292 -0
  153. package/src/ai/providers/openai.ts +167 -0
  154. package/src/ai/rate-limiter.ts +129 -0
  155. package/src/ai/service.ts +319 -0
  156. package/src/ai/session.ts +544 -0
  157. package/src/ai/tone-detector.ts +89 -0
  158. package/src/ai/tools.ts +218 -0
  159. package/src/ai/types.ts +296 -0
  160. package/src/ai/user-profile.ts +181 -0
  161. package/src/ai/zhin-agent.ts +845 -0
  162. package/src/built/ai-trigger.ts +6 -3
  163. package/src/built/command.ts +75 -69
  164. package/src/built/component.ts +94 -76
  165. package/src/built/config.ts +288 -128
  166. package/src/built/cron.ts +117 -101
  167. package/src/built/database.ts +128 -33
  168. package/src/built/dispatcher.ts +332 -0
  169. package/src/built/permission.ts +146 -54
  170. package/src/built/skill.ts +280 -0
  171. package/src/built/tool.ts +245 -366
  172. package/src/feature.ts +113 -0
  173. package/src/index.ts +7 -0
  174. package/src/plugin.ts +198 -33
  175. package/src/types.ts +6 -10
  176. package/tests/adapter.test.ts +153 -1
  177. package/tests/ai/agent.test.ts +614 -0
  178. package/tests/ai/ai-trigger.test.ts +368 -0
  179. package/tests/ai/context-manager.test.ts +413 -0
  180. package/tests/ai/conversation-memory.test.ts +128 -0
  181. package/tests/ai/follow-up.test.ts +175 -0
  182. package/tests/ai/integration.test.ts +584 -0
  183. package/tests/ai/output.test.ts +128 -0
  184. package/tests/ai/providers.integration.test.ts +227 -0
  185. package/tests/ai/rate-limiter.test.ts +108 -0
  186. package/tests/ai/session.test.ts +375 -0
  187. package/tests/ai/setup.ts +308 -0
  188. package/tests/ai/tone-detector.test.ts +80 -0
  189. package/tests/ai/tool.test.ts +800 -0
  190. package/tests/ai/tools-builtin.test.ts +346 -0
  191. package/tests/ai/user-profile.test.ts +73 -0
  192. package/tests/ai/zhin-agent.test.ts +177 -0
  193. package/tests/config.test.ts +46 -0
  194. package/tests/cron.test.ts +94 -5
  195. package/tests/dispatcher.test.ts +146 -0
  196. package/tests/feature.test.ts +145 -0
  197. package/tests/features-builtin.test.ts +191 -0
  198. package/tests/plugin.test.ts +88 -14
  199. package/tests/skill-feature.test.ts +179 -0
  200. package/tests/tool-feature.test.ts +254 -0
  201. package/test/minimal-bot.ts +0 -31
  202. package/test/stress-test.ts +0 -123
@@ -276,13 +276,102 @@ describe('Cron定时任务系统测试', () => {
276
276
  })
277
277
 
278
278
  describe('Cron错误处理', () => {
279
- it('should throw error for invalid cron expression that cannot determine next run', () => {
280
- // 创建一个永远不会执行的 cron 表达式
281
- // 例如:2月30日(不存在的日期)
279
+ it('should throw error for truly invalid cron expression', () => {
280
+ // 完全无效的 cron 表达式应该抛出错误
282
281
  expect(() => {
283
- cron = new Cron('0 0 30 2 *', mockCallback)
284
- cron.start()
282
+ cron = new Cron('not a valid cron', mockCallback)
283
+ cron.run()
285
284
  }).toThrow()
286
285
  })
286
+
287
+ it('should accept edge-case cron expression like Feb 30 without throwing', () => {
288
+ // 2月30日虽然不存在,但 cron 库接受该表达式(不会抛错)
289
+ expect(() => {
290
+ cron = new Cron('0 0 30 2 *', mockCallback)
291
+ cron.run()
292
+ }).not.toThrow()
293
+ })
294
+ })
295
+ })
296
+
297
+ // ============================================================================
298
+ // CronFeature 补全测试
299
+ // ============================================================================
300
+ describe('CronFeature', () => {
301
+ let feature: import('../src/built/cron.js').CronFeature
302
+ let mockCallback: ReturnType<typeof vi.fn>
303
+
304
+ beforeEach(async () => {
305
+ vi.useFakeTimers()
306
+ mockCallback = vi.fn()
307
+ const { CronFeature } = await import('../src/built/cron.js')
308
+ feature = new CronFeature()
309
+ })
310
+
311
+ afterEach(() => {
312
+ feature?.dispose()
313
+ vi.useRealTimers()
314
+ })
315
+
316
+ it('add 应自动启动任务', () => {
317
+ const cron = new Cron('* * * * * *', mockCallback)
318
+ feature.add(cron, 'test-plugin')
319
+ expect(cron.running).toBe(true)
320
+ expect(feature.items).toHaveLength(1)
321
+ })
322
+
323
+ it('add 应返回 dispose 函数', () => {
324
+ const cron = new Cron('* * * * * *', mockCallback)
325
+ const dispose = feature.add(cron, 'test-plugin')
326
+ expect(typeof dispose).toBe('function')
327
+ })
328
+
329
+ it('remove 应自动停止任务', () => {
330
+ const cron = new Cron('* * * * * *', mockCallback)
331
+ feature.add(cron, 'test-plugin')
332
+ feature.remove(cron)
333
+ expect(cron.running).toBe(false)
334
+ expect(feature.items).toHaveLength(0)
335
+ })
336
+
337
+ it('stopAll 应停止所有任务', () => {
338
+ const cron1 = new Cron('* * * * * *', mockCallback)
339
+ const cron2 = new Cron('*/2 * * * * *', mockCallback)
340
+ feature.add(cron1, 'p1')
341
+ feature.add(cron2, 'p2')
342
+
343
+ feature.stopAll()
344
+ expect(cron1.running).toBe(false)
345
+ expect(cron2.running).toBe(false)
346
+ })
347
+
348
+ it('startAll 应启动所有已停止的任务', () => {
349
+ const cron1 = new Cron('* * * * * *', mockCallback)
350
+ const cron2 = new Cron('*/2 * * * * *', mockCallback)
351
+ feature.add(cron1, 'p1')
352
+ feature.add(cron2, 'p2')
353
+ feature.stopAll()
354
+
355
+ feature.startAll()
356
+ expect(cron1.running).toBe(true)
357
+ expect(cron2.running).toBe(true)
358
+ })
359
+
360
+ it('toJSON 应返回正确结构', () => {
361
+ const cron = new Cron('* * * * * *', mockCallback)
362
+ feature.add(cron, 'test-plugin')
363
+ const json = feature.toJSON()
364
+ expect(json.name).toBe('cron')
365
+ expect(json.icon).toBe('Clock')
366
+ expect(json.count).toBe(1)
367
+ expect(json.items[0]).toHaveProperty('expression')
368
+ expect(json.items[0]).toHaveProperty('running', true)
369
+ })
370
+
371
+ it('dispose 应停止所有任务', () => {
372
+ const cron = new Cron('* * * * * *', mockCallback)
373
+ feature.add(cron, 'test-plugin')
374
+ feature.dispose()
375
+ expect(cron.running).toBe(false)
287
376
  })
288
377
  })
@@ -0,0 +1,146 @@
1
+ /**
2
+ * MessageDispatcher 测试
3
+ */
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
5
+ import { createMessageDispatcher, type MessageDispatcherService, type RouteResult } from '../src/built/dispatcher.js';
6
+ import type { Message } from '../src/message.js';
7
+
8
+ function makeMessage(text: string, overrides: Partial<Message<any>> = {}): Message<any> {
9
+ return {
10
+ $id: '1',
11
+ $content: [{ type: 'text', data: { text } }],
12
+ $raw: text,
13
+ $sender: { id: 'user1', name: 'User' },
14
+ $channel: { id: 'ch1', type: 'group' },
15
+ $adapter: 'test',
16
+ $bot: 'bot1',
17
+ $timestamp: Date.now(),
18
+ $reply: vi.fn(),
19
+ $recall: vi.fn(),
20
+ ...overrides,
21
+ } as any;
22
+ }
23
+
24
+ describe('createMessageDispatcher', () => {
25
+ let context: ReturnType<typeof createMessageDispatcher>;
26
+ let service: MessageDispatcherService;
27
+
28
+ beforeEach(() => {
29
+ context = createMessageDispatcher();
30
+ service = context.value;
31
+ });
32
+
33
+ it('应返回正确的 context 结构', () => {
34
+ expect(context.name).toBe('dispatcher');
35
+ expect(context.description).toContain('消息调度器');
36
+ expect(context.value).toBeDefined();
37
+ expect(typeof context.value.dispatch).toBe('function');
38
+ expect(typeof context.value.addGuardrail).toBe('function');
39
+ expect(typeof context.value.setCommandMatcher).toBe('function');
40
+ expect(typeof context.value.setAITriggerMatcher).toBe('function');
41
+ expect(typeof context.value.setAIHandler).toBe('function');
42
+ expect(typeof context.value.hasAIHandler).toBe('function');
43
+ });
44
+
45
+ describe('Guardrail', () => {
46
+ it('应能添加和移除 Guardrail', () => {
47
+ const guardrail = vi.fn(async (_msg: any, next: any) => next());
48
+ const remove = service.addGuardrail(guardrail);
49
+
50
+ expect(typeof remove).toBe('function');
51
+ remove();
52
+ });
53
+
54
+ it('Guardrail 应在 dispatch 中执行', async () => {
55
+ const calls: string[] = [];
56
+
57
+ service.addGuardrail(async (_msg, next) => {
58
+ calls.push('g1');
59
+ await next();
60
+ });
61
+ service.addGuardrail(async (_msg, next) => {
62
+ calls.push('g2');
63
+ await next();
64
+ });
65
+
66
+ const msg = makeMessage('hello');
67
+ await service.dispatch(msg);
68
+
69
+ expect(calls).toEqual(['g1', 'g2']);
70
+ });
71
+
72
+ it('Guardrail 抛异常应拦截消息', async () => {
73
+ service.addGuardrail(async () => {
74
+ throw new Error('blocked');
75
+ });
76
+
77
+ const aiHandler = vi.fn();
78
+ service.setAIHandler(aiHandler);
79
+ service.setAITriggerMatcher(() => ({ triggered: true, content: 'test' }));
80
+
81
+ const msg = makeMessage('hello');
82
+ await service.dispatch(msg);
83
+
84
+ expect(aiHandler).not.toHaveBeenCalled();
85
+ });
86
+ });
87
+
88
+ describe('AI Handler', () => {
89
+ it('初始无 AI handler', () => {
90
+ expect(service.hasAIHandler()).toBe(false);
91
+ });
92
+
93
+ it('注册后 hasAIHandler 返回 true', () => {
94
+ service.setAIHandler(async () => {});
95
+ expect(service.hasAIHandler()).toBe(true);
96
+ });
97
+
98
+ it('AI 触发时应调用 handler', async () => {
99
+ const handler = vi.fn();
100
+ service.setAIHandler(handler);
101
+ service.setAITriggerMatcher((msg) => ({
102
+ triggered: true,
103
+ content: 'processed content',
104
+ }));
105
+
106
+ const msg = makeMessage('你好');
107
+ await service.dispatch(msg);
108
+
109
+ expect(handler).toHaveBeenCalledWith(msg, 'processed content');
110
+ });
111
+
112
+ it('AI 不触发时不应调用 handler', async () => {
113
+ const handler = vi.fn();
114
+ service.setAIHandler(handler);
115
+ service.setAITriggerMatcher(() => ({ triggered: false, content: '' }));
116
+
117
+ const msg = makeMessage('random');
118
+ await service.dispatch(msg);
119
+
120
+ expect(handler).not.toHaveBeenCalled();
121
+ });
122
+ });
123
+
124
+ describe('Command Matcher', () => {
125
+ it('自定义命令匹配器应优先', async () => {
126
+ const aiHandler = vi.fn();
127
+ service.setAIHandler(aiHandler);
128
+ service.setAITriggerMatcher(() => ({ triggered: true, content: '' }));
129
+
130
+ // 设置命令匹配器:以 / 开头视为命令
131
+ service.setCommandMatcher((text) => text.startsWith('/'));
132
+
133
+ const msg = makeMessage('/help');
134
+ await service.dispatch(msg);
135
+
136
+ // 命令路径不应触发 AI
137
+ expect(aiHandler).not.toHaveBeenCalled();
138
+ });
139
+ });
140
+
141
+ describe('extensions', () => {
142
+ it('应提供 addGuardrail 扩展', () => {
143
+ expect(typeof context.extensions.addGuardrail).toBe('function');
144
+ });
145
+ });
146
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Feature 基类测试
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import { Feature, FeatureJSON } from '../src/feature.js';
6
+
7
+ // 创建一个具体的 Feature 子类用于测试
8
+ class TestFeature extends Feature<{ id: string; value: number }> {
9
+ readonly name = 'test';
10
+ readonly icon = 'TestIcon';
11
+ readonly desc = '测试 Feature';
12
+
13
+ toJSON(pluginName?: string): FeatureJSON {
14
+ const list = pluginName ? this.getByPlugin(pluginName) : this.items;
15
+ return {
16
+ name: this.name,
17
+ icon: this.icon,
18
+ desc: this.desc,
19
+ count: list.length,
20
+ items: list.map(item => ({ id: item.id, value: item.value })),
21
+ };
22
+ }
23
+ }
24
+
25
+ describe('Feature 基类', () => {
26
+ describe('add / remove / getAll', () => {
27
+ it('应该能添加 item 并返回 dispose 函数', () => {
28
+ const feature = new TestFeature();
29
+ const item = { id: 'a', value: 1 };
30
+ const dispose = feature.add(item, 'plugin-a');
31
+
32
+ expect(feature.items).toHaveLength(1);
33
+ expect(feature.items[0]).toBe(item);
34
+ expect(typeof dispose).toBe('function');
35
+ });
36
+
37
+ it('dispose 函数应移除 item', () => {
38
+ const feature = new TestFeature();
39
+ const item = { id: 'a', value: 1 };
40
+ const dispose = feature.add(item, 'plugin-a');
41
+
42
+ dispose();
43
+ expect(feature.items).toHaveLength(0);
44
+ });
45
+
46
+ it('remove 应返回 true 并移除 item', () => {
47
+ const feature = new TestFeature();
48
+ const item = { id: 'a', value: 1 };
49
+ feature.add(item, 'plugin-a');
50
+
51
+ const result = feature.remove(item);
52
+ expect(result).toBe(true);
53
+ expect(feature.items).toHaveLength(0);
54
+ });
55
+
56
+ it('remove 不存在的 item 应返回 false', () => {
57
+ const feature = new TestFeature();
58
+ const result = feature.remove({ id: 'nonexistent', value: 0 });
59
+ expect(result).toBe(false);
60
+ });
61
+
62
+ it('应支持多个 item', () => {
63
+ const feature = new TestFeature();
64
+ feature.add({ id: 'a', value: 1 }, 'plugin-a');
65
+ feature.add({ id: 'b', value: 2 }, 'plugin-b');
66
+ feature.add({ id: 'c', value: 3 }, 'plugin-a');
67
+
68
+ expect(feature.items).toHaveLength(3);
69
+ expect(feature.count).toBe(3);
70
+ });
71
+ });
72
+
73
+ describe('插件追踪: getByPlugin / countByPlugin', () => {
74
+ it('应按插件名分组 items', () => {
75
+ const feature = new TestFeature();
76
+ feature.add({ id: 'a', value: 1 }, 'plugin-a');
77
+ feature.add({ id: 'b', value: 2 }, 'plugin-b');
78
+ feature.add({ id: 'c', value: 3 }, 'plugin-a');
79
+
80
+ const pluginAItems = feature.getByPlugin('plugin-a');
81
+ expect(pluginAItems).toHaveLength(2);
82
+ expect(pluginAItems.map(i => i.id)).toEqual(['a', 'c']);
83
+
84
+ const pluginBItems = feature.getByPlugin('plugin-b');
85
+ expect(pluginBItems).toHaveLength(1);
86
+ expect(pluginBItems[0].id).toBe('b');
87
+ });
88
+
89
+ it('不存在的插件应返回空数组', () => {
90
+ const feature = new TestFeature();
91
+ expect(feature.getByPlugin('nonexistent')).toEqual([]);
92
+ });
93
+
94
+ it('countByPlugin 应返回正确数量', () => {
95
+ const feature = new TestFeature();
96
+ feature.add({ id: 'a', value: 1 }, 'plugin-a');
97
+ feature.add({ id: 'b', value: 2 }, 'plugin-a');
98
+
99
+ expect(feature.countByPlugin('plugin-a')).toBe(2);
100
+ expect(feature.countByPlugin('plugin-b')).toBe(0);
101
+ });
102
+
103
+ it('remove 应同时从 pluginItems 中移除', () => {
104
+ const feature = new TestFeature();
105
+ const item = { id: 'a', value: 1 };
106
+ feature.add(item, 'plugin-a');
107
+ feature.remove(item);
108
+
109
+ expect(feature.getByPlugin('plugin-a')).toHaveLength(0);
110
+ });
111
+ });
112
+
113
+ describe('toJSON 序列化', () => {
114
+ it('无参数时返回全部 items', () => {
115
+ const feature = new TestFeature();
116
+ feature.add({ id: 'a', value: 1 }, 'plugin-a');
117
+ feature.add({ id: 'b', value: 2 }, 'plugin-b');
118
+
119
+ const json = feature.toJSON();
120
+ expect(json.name).toBe('test');
121
+ expect(json.icon).toBe('TestIcon');
122
+ expect(json.desc).toBe('测试 Feature');
123
+ expect(json.count).toBe(2);
124
+ expect(json.items).toHaveLength(2);
125
+ });
126
+
127
+ it('传 pluginName 时只返回该插件的 items', () => {
128
+ const feature = new TestFeature();
129
+ feature.add({ id: 'a', value: 1 }, 'plugin-a');
130
+ feature.add({ id: 'b', value: 2 }, 'plugin-b');
131
+
132
+ const json = feature.toJSON('plugin-a');
133
+ expect(json.count).toBe(1);
134
+ expect(json.items).toHaveLength(1);
135
+ expect(json.items[0].id).toBe('a');
136
+ });
137
+ });
138
+
139
+ describe('extensions getter', () => {
140
+ it('默认返回空对象', () => {
141
+ const feature = new TestFeature();
142
+ expect(feature.extensions).toEqual({});
143
+ });
144
+ });
145
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * 内置 Feature 测试
3
+ * DatabaseFeature / PermissionFeature / ComponentFeature
4
+ */
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
6
+ import { PermissionFeature, Permissions } from '../src/built/permission.js';
7
+ import { ComponentFeature } from '../src/built/component.js';
8
+ import type { Message, MessageBase } from '../src/message.js';
9
+
10
+ // ============================================================================
11
+ // PermissionFeature 测试
12
+ // ============================================================================
13
+
14
+ describe('PermissionFeature', () => {
15
+ let feature: PermissionFeature;
16
+
17
+ beforeEach(() => {
18
+ feature = new PermissionFeature();
19
+ });
20
+
21
+ function makeMessage(overrides: Partial<MessageBase> = {}): Message<any> {
22
+ return {
23
+ $id: '1',
24
+ $adapter: 'test' as any,
25
+ $bot: 'bot1',
26
+ $content: [],
27
+ $raw: '',
28
+ $sender: { id: 'user1', name: 'User' },
29
+ $channel: { id: 'ch1', type: 'group' },
30
+ $timestamp: Date.now(),
31
+ $reply: vi.fn(),
32
+ $recall: vi.fn(),
33
+ ...overrides,
34
+ } as any;
35
+ }
36
+
37
+ it('应有正确的元数据', () => {
38
+ expect(feature.name).toBe('permission');
39
+ expect(feature.icon).toBe('Shield');
40
+ expect(feature.desc).toBe('权限');
41
+ });
42
+
43
+ it('构造时应注册内置权限检查器', () => {
44
+ // 构造时已注册 adapter(), group(), private(), channel(), user() 检查器
45
+ expect(feature.items.length).toBeGreaterThanOrEqual(5);
46
+ });
47
+
48
+ describe('check', () => {
49
+ it('adapter() 匹配应返回 true', async () => {
50
+ const msg = makeMessage({ $adapter: 'qq' as any });
51
+ expect(await feature.check('adapter(qq)', msg)).toBe(true);
52
+ });
53
+
54
+ it('adapter() 不匹配应返回 false', async () => {
55
+ const msg = makeMessage({ $adapter: 'qq' as any });
56
+ expect(await feature.check('adapter(discord)', msg)).toBe(false);
57
+ });
58
+
59
+ it('group() 匹配应返回 true', async () => {
60
+ const msg = makeMessage({ $channel: { id: 'g1', type: 'group' } });
61
+ expect(await feature.check('group(g1)', msg)).toBe(true);
62
+ });
63
+
64
+ it('group(*) 通配应返回 true', async () => {
65
+ const msg = makeMessage({ $channel: { id: 'g123', type: 'group' } });
66
+ expect(await feature.check('group(*)', msg)).toBe(true);
67
+ });
68
+
69
+ it('private() 匹配应返回 true', async () => {
70
+ const msg = makeMessage({ $channel: { id: 'p1', type: 'private' } });
71
+ expect(await feature.check('private(p1)', msg)).toBe(true);
72
+ });
73
+
74
+ it('user() 匹配应返回 true', async () => {
75
+ const msg = makeMessage({ $sender: { id: 'u1', name: 'U' } });
76
+ expect(await feature.check('user(u1)', msg)).toBe(true);
77
+ });
78
+
79
+ it('user() 不匹配应返回 false', async () => {
80
+ const msg = makeMessage({ $sender: { id: 'u1', name: 'U' } });
81
+ expect(await feature.check('user(u2)', msg)).toBe(false);
82
+ });
83
+
84
+ it('未注册的权限名应返回 false', async () => {
85
+ const msg = makeMessage();
86
+ expect(await feature.check('unknown_permission', msg)).toBe(false);
87
+ });
88
+ });
89
+
90
+ describe('自定义权限', () => {
91
+ it('应能添加自定义权限检查器', async () => {
92
+ const perm = Permissions.define('admin', (_name, msg) => {
93
+ return msg.$sender.id === 'admin_user';
94
+ });
95
+
96
+ feature.add(perm, 'test-plugin');
97
+
98
+ const adminMsg = makeMessage({ $sender: { id: 'admin_user', name: 'Admin' } });
99
+ const normalMsg = makeMessage({ $sender: { id: 'normal_user', name: 'Normal' } });
100
+
101
+ expect(await feature.check('admin', adminMsg)).toBe(true);
102
+ expect(await feature.check('admin', normalMsg)).toBe(false);
103
+ });
104
+ });
105
+
106
+ describe('toJSON', () => {
107
+ it('应序列化所有权限', () => {
108
+ const json = feature.toJSON();
109
+ expect(json.name).toBe('permission');
110
+ expect(json.count).toBeGreaterThan(0);
111
+ });
112
+
113
+ it('应按插件名过滤', () => {
114
+ feature.add(Permissions.define('custom', () => true), 'my-plugin');
115
+ const json = feature.toJSON('my-plugin');
116
+ expect(json.count).toBe(1);
117
+ });
118
+ });
119
+ });
120
+
121
+ // ============================================================================
122
+ // ComponentFeature 测试
123
+ // ============================================================================
124
+
125
+ describe('ComponentFeature', () => {
126
+ let feature: ComponentFeature;
127
+
128
+ beforeEach(() => {
129
+ feature = new ComponentFeature();
130
+ });
131
+
132
+ const mockComponent = {
133
+ name: 'test-component',
134
+ render: () => ({ type: 'text', data: { text: 'test' } }),
135
+ } as any;
136
+
137
+ it('应有正确的元数据', () => {
138
+ expect(feature.name).toBe('component');
139
+ expect(feature.icon).toBe('Box');
140
+ expect(feature.desc).toBe('组件');
141
+ });
142
+
143
+ it('add 应添加组件', () => {
144
+ feature.add(mockComponent, 'test-plugin');
145
+ expect(feature.items).toHaveLength(1);
146
+ expect(feature.byName.get('test-component')).toBe(mockComponent);
147
+ });
148
+
149
+ it('remove 应移除组件', () => {
150
+ feature.add(mockComponent, 'test-plugin');
151
+ feature.remove(mockComponent);
152
+ expect(feature.items).toHaveLength(0);
153
+ expect(feature.byName.has('test-component')).toBe(false);
154
+ });
155
+
156
+ it('get 应按名称获取', () => {
157
+ feature.add(mockComponent, 'test-plugin');
158
+ expect(feature.get('test-component')).toBe(mockComponent);
159
+ expect(feature.get('nonexistent')).toBeUndefined();
160
+ });
161
+
162
+ it('getAllNames 应返回所有名称', () => {
163
+ feature.add(mockComponent, 'test-plugin');
164
+ feature.add({ name: 'another', render: () => null } as any, 'test-plugin');
165
+ expect(feature.getAllNames()).toEqual(['test-component', 'another']);
166
+ });
167
+
168
+ describe('toJSON', () => {
169
+ it('应返回正确结构', () => {
170
+ feature.add(mockComponent, 'test-plugin');
171
+ const json = feature.toJSON();
172
+ expect(json.name).toBe('component');
173
+ expect(json.count).toBe(1);
174
+ expect(json.items[0]).toEqual({ name: 'test-component', type: 'component' });
175
+ });
176
+
177
+ it('按插件名过滤', () => {
178
+ feature.add(mockComponent, 'plugin-a');
179
+ feature.add({ name: 'other', render: () => null } as any, 'plugin-b');
180
+ const json = feature.toJSON('plugin-a');
181
+ expect(json.count).toBe(1);
182
+ });
183
+ });
184
+ });
185
+
186
+ // ============================================================================
187
+ // DatabaseFeature 说明
188
+ // ============================================================================
189
+ // DatabaseFeature 需要实际数据库连接(sqlite3 等),测试跳过。
190
+ // 其核心逻辑(add/remove/toJSON)已由 Feature 基类测试覆盖。
191
+ // 数据库相关测试见 basic/database/ 目录。