@lobehub/lobehub 2.0.0-next.45 → 2.0.0-next.47

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 (44) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/changelog/v1.json +18 -0
  3. package/package.json +1 -1
  4. package/packages/database/src/models/file.ts +15 -1
  5. package/packages/database/src/repositories/aiInfra/index.test.ts +1 -1
  6. package/packages/database/src/repositories/dataExporter/index.test.ts +1 -1
  7. package/packages/database/src/repositories/tableViewer/index.test.ts +1 -1
  8. package/packages/types/src/aiProvider.ts +1 -1
  9. package/packages/types/src/document/index.ts +38 -38
  10. package/packages/types/src/exportConfig.ts +15 -15
  11. package/packages/types/src/generation/index.ts +5 -5
  12. package/packages/types/src/openai/chat.ts +15 -15
  13. package/packages/types/src/plugins/mcp.ts +29 -29
  14. package/packages/types/src/plugins/protocol.ts +43 -43
  15. package/packages/types/src/search.ts +4 -4
  16. package/packages/types/src/tool/plugin.ts +3 -3
  17. package/src/app/(backend)/f/[id]/route.ts +55 -0
  18. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/ChatItem/Thread.tsx +1 -1
  19. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/ChatItem/index.tsx +9 -16
  20. package/src/envs/app.ts +4 -3
  21. package/src/features/Conversation/Messages/Assistant/Actions/index.tsx +3 -5
  22. package/src/features/Conversation/Messages/Assistant/Extra/index.test.tsx +3 -3
  23. package/src/features/Conversation/Messages/Assistant/Extra/index.tsx +8 -5
  24. package/src/features/Conversation/Messages/Assistant/index.tsx +29 -15
  25. package/src/features/Conversation/Messages/Group/Actions/WithContentId.tsx +3 -5
  26. package/src/features/Conversation/Messages/Group/index.tsx +12 -20
  27. package/src/features/Conversation/Messages/Supervisor/index.tsx +14 -5
  28. package/src/features/Conversation/Messages/User/index.tsx +14 -8
  29. package/src/features/Conversation/Messages/index.tsx +16 -26
  30. package/src/features/Conversation/components/Extras/Usage/UsageDetail/index.tsx +7 -6
  31. package/src/features/Conversation/components/Extras/Usage/UsageDetail/tokens.ts +2 -5
  32. package/src/features/Conversation/components/Extras/Usage/index.tsx +13 -6
  33. package/src/features/Conversation/components/VirtualizedList/index.tsx +2 -1
  34. package/src/features/PluginsUI/Render/MCPType/index.tsx +26 -6
  35. package/src/server/modules/ContentChunk/index.test.ts +372 -0
  36. package/src/server/routers/desktop/mcp.ts +23 -8
  37. package/src/server/routers/tools/mcp.ts +24 -4
  38. package/src/server/services/file/impls/local.ts +4 -1
  39. package/src/server/services/file/index.ts +96 -1
  40. package/src/server/services/mcp/contentProcessor.ts +101 -0
  41. package/src/server/services/mcp/index.test.ts +52 -10
  42. package/src/server/services/mcp/index.ts +29 -26
  43. package/src/services/session/index.ts +0 -14
  44. package/src/utils/server/routeVariants.test.ts +340 -0
@@ -0,0 +1,101 @@
1
+ import debug from 'debug';
2
+ import pMap from 'p-map';
3
+ import urlJoin from 'url-join';
4
+
5
+ import { appEnv } from '@/envs/app';
6
+ import { fileEnv } from '@/envs/file';
7
+ import { AudioContent, ImageContent, ToolCallContent } from '@/libs/mcp';
8
+ import { FileService } from '@/server/services/file';
9
+ import { nanoid } from '@/utils/uuid';
10
+
11
+ const log = debug('lobe-mcp:content-processor');
12
+
13
+ export type ProcessContentBlocksFn = (blocks: ToolCallContent[]) => Promise<ToolCallContent[]>;
14
+
15
+ /**
16
+ * 处理 MCP 返回的 content blocks
17
+ * - 上传图片/音频到存储并替换 data 为代理 URL
18
+ * - 保持其他类型的 block 不变
19
+ */
20
+ export const processContentBlocks = async (
21
+ blocks: ToolCallContent[],
22
+ fileService: FileService,
23
+ ): Promise<ToolCallContent[]> => {
24
+ // Use date-based sharding for privacy compliance (GDPR, CCPA)
25
+ const today = new Date().toISOString().split('T')[0]; // e.g., "2025-11-08"
26
+
27
+ return pMap(blocks, async (block) => {
28
+ if (block.type === 'image') {
29
+ const imageBlock = block as ImageContent;
30
+
31
+ // Extract file extension from mimeType (e.g., "image/png" -> "png")
32
+ const fileExtension = imageBlock.mimeType.split('/')[1] || 'png';
33
+
34
+ // Generate unique pathname with date-based sharding
35
+ const pathname = `${fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/mcp/images/${today}/${nanoid()}.${fileExtension}`;
36
+
37
+ // Upload base64 image and get proxy URL
38
+ const { url } = await fileService.uploadBase64(imageBlock.data, pathname);
39
+
40
+ log(`Image uploaded, proxy URL: ${url}`);
41
+
42
+ return { ...block, data: url };
43
+ }
44
+
45
+ if (block.type === 'audio') {
46
+ const audioBlock = block as AudioContent;
47
+
48
+ // Extract file extension from mimeType (e.g., "audio/mp3" -> "mp3")
49
+ const fileExtension = audioBlock.mimeType.split('/')[1] || 'mp3';
50
+
51
+ // Generate unique pathname with date-based sharding
52
+ const pathname = `${fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/mcp/audio/${today}/${nanoid()}.${fileExtension}`;
53
+
54
+ // Upload base64 audio and get proxy URL
55
+ const { url } = await fileService.uploadBase64(audioBlock.data, pathname);
56
+
57
+ log(`Audio uploaded, proxy URL: ${url}`);
58
+
59
+ return { ...block, data: url };
60
+ }
61
+
62
+ return block;
63
+ });
64
+ };
65
+
66
+ /**
67
+ * 将 content blocks 转换为字符串
68
+ * - text: 提取 text 字段
69
+ * - image/audio: 提取 data 字段(通常是上传后的代理 URL)
70
+ * - 其他: 返回空字符串
71
+ */
72
+ export const contentBlocksToString = (blocks: ToolCallContent[] | null | undefined): string => {
73
+ if (!blocks) return '';
74
+
75
+ return blocks
76
+ .map((item) => {
77
+ switch (item.type) {
78
+ case 'text': {
79
+ return item.text;
80
+ }
81
+
82
+ case 'image': {
83
+ return `![](${urlJoin(appEnv.APP_URL, item.data)})`;
84
+ }
85
+
86
+ case 'audio': {
87
+ return `<resource type="${item.type}" url="${urlJoin(appEnv.APP_URL, item.data)}" />`;
88
+ }
89
+
90
+ case 'resource': {
91
+ return `<resource type="${item.type}">${JSON.stringify(item.resource)}</resource>}`;
92
+ }
93
+
94
+ default: {
95
+ return '';
96
+ }
97
+ }
98
+ })
99
+ .filter(Boolean)
100
+ .join('\n\n');
101
+ };
@@ -39,7 +39,11 @@ describe('MCPService', () => {
39
39
  isError: false,
40
40
  });
41
41
 
42
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
42
+ const result = await mcpService.callTool({
43
+ clientParams: mockParams,
44
+ toolName: 'testTool',
45
+ argsStr: '{}',
46
+ });
43
47
 
44
48
  expect(result.content).toBe('');
45
49
  expect(result.success).toBe(true);
@@ -52,7 +56,11 @@ describe('MCPService', () => {
52
56
  isError: false,
53
57
  });
54
58
 
55
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
59
+ const result = await mcpService.callTool({
60
+ clientParams: mockParams,
61
+ toolName: 'testTool',
62
+ argsStr: '{}',
63
+ });
56
64
 
57
65
  expect(result.content).toBe('');
58
66
  expect(result.success).toBe(true);
@@ -65,7 +73,11 @@ describe('MCPService', () => {
65
73
  isError: false,
66
74
  });
67
75
 
68
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
76
+ const result = await mcpService.callTool({
77
+ clientParams: mockParams,
78
+ toolName: 'testTool',
79
+ argsStr: '{}',
80
+ });
69
81
 
70
82
  expect(result.content).toBe(JSON.stringify(jsonData));
71
83
  expect(result.success).toBe(true);
@@ -78,7 +90,11 @@ describe('MCPService', () => {
78
90
  isError: false,
79
91
  });
80
92
 
81
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
93
+ const result = await mcpService.callTool({
94
+ clientParams: mockParams,
95
+ toolName: 'testTool',
96
+ argsStr: '{}',
97
+ });
82
98
 
83
99
  expect(result.content).toBe(textData);
84
100
  expect(result.success).toBe(true);
@@ -92,7 +108,11 @@ describe('MCPService', () => {
92
108
  isError: false,
93
109
  });
94
110
 
95
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
111
+ const result = await mcpService.callTool({
112
+ clientParams: mockParams,
113
+ toolName: 'testTool',
114
+ argsStr: '{}',
115
+ });
96
116
 
97
117
  expect(result.content).toBe('');
98
118
  expect(result.success).toBe(true);
@@ -111,7 +131,11 @@ describe('MCPService', () => {
111
131
  isError: false,
112
132
  });
113
133
 
114
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
134
+ const result = await mcpService.callTool({
135
+ clientParams: mockParams,
136
+ toolName: 'testTool',
137
+ argsStr: '{}',
138
+ });
115
139
 
116
140
  expect(result.content).toBe('First message\n\nSecond message\n\n{"json": "data"}');
117
141
  expect(result.success).toBe(true);
@@ -129,7 +153,11 @@ describe('MCPService', () => {
129
153
  isError: false,
130
154
  });
131
155
 
132
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
156
+ const result = await mcpService.callTool({
157
+ clientParams: mockParams,
158
+ toolName: 'testTool',
159
+ argsStr: '{}',
160
+ });
133
161
 
134
162
  expect(result.content).toBe('First message\n\nSecond message');
135
163
  expect(result.success).toBe(true);
@@ -144,7 +172,11 @@ describe('MCPService', () => {
144
172
 
145
173
  mockClient.callTool.mockResolvedValue(errorResult);
146
174
 
147
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
175
+ const result = await mcpService.callTool({
176
+ clientParams: mockParams,
177
+ toolName: 'testTool',
178
+ argsStr: '{}',
179
+ });
148
180
 
149
181
  expect(result.content).toBe('Error occurred');
150
182
  expect(result.success).toBe(true);
@@ -155,7 +187,13 @@ describe('MCPService', () => {
155
187
  const error = new Error('MCP client error');
156
188
  mockClient.callTool.mockRejectedValue(error);
157
189
 
158
- await expect(mcpService.callTool(mockParams, 'testTool', '{}')).rejects.toThrow(TRPCError);
190
+ await expect(
191
+ mcpService.callTool({
192
+ clientParams: mockParams,
193
+ toolName: 'testTool',
194
+ argsStr: '{}',
195
+ }),
196
+ ).rejects.toThrow(TRPCError);
159
197
  });
160
198
 
161
199
  it('should parse args string correctly', async () => {
@@ -167,7 +205,11 @@ describe('MCPService', () => {
167
205
  isError: false,
168
206
  });
169
207
 
170
- await mcpService.callTool(mockParams, 'testTool', argsString);
208
+ await mcpService.callTool({
209
+ clientParams: mockParams,
210
+ toolName: 'testTool',
211
+ argsStr: argsString,
212
+ });
171
213
 
172
214
  expect(mockClient.callTool).toHaveBeenCalledWith('testTool', argsObject);
173
215
  });
@@ -15,6 +15,7 @@ import {
15
15
  StdioMCPParams,
16
16
  } from '@/libs/mcp';
17
17
 
18
+ import { ProcessContentBlocksFn, contentBlocksToString } from './contentProcessor';
18
19
  import { mcpSystemDepsCheckService } from './deps';
19
20
 
20
21
  const log = debug('lobe-mcp:service');
@@ -154,12 +155,19 @@ export class MCPService {
154
155
  }
155
156
  }
156
157
 
157
- // callTool now accepts MCPClientParams, toolName, and args
158
- async callTool(params: MCPClientParams, toolName: string, argsStr: any): Promise<any> {
159
- const client = await this.getClient(params); // Get client using params
158
+ // callTool now accepts an object with clientParams, toolName, argsStr, and processContentBlocks
159
+ async callTool(options: {
160
+ argsStr: any;
161
+ clientParams: MCPClientParams;
162
+ processContentBlocks?: ProcessContentBlocksFn;
163
+ toolName: string;
164
+ }): Promise<any> {
165
+ const { clientParams, toolName, argsStr, processContentBlocks } = options;
166
+
167
+ const client = await this.getClient(clientParams); // Get client using params
160
168
 
161
169
  const args = safeParseJSON(argsStr);
162
- const loggableParams = this.sanitizeForLogging(params);
170
+ const loggableParams = this.sanitizeForLogging(clientParams);
163
171
 
164
172
  log(
165
173
  `Calling tool "${toolName}" using client for params: %O with args: %O`,
@@ -170,32 +178,27 @@ export class MCPService {
170
178
  try {
171
179
  // Delegate the call to the MCPClient instance
172
180
  const result = await client.callTool(toolName, args); // Pass args directly
181
+
182
+ // Process content blocks (upload images, etc.)
183
+ const newContent =
184
+ result.isError || !processContentBlocks
185
+ ? result.content
186
+ : await processContentBlocks(result.content);
187
+
188
+ // Convert content blocks to string
189
+ const content = contentBlocksToString(newContent);
190
+
191
+ const state = { ...result, content: newContent };
192
+
173
193
  log(
174
194
  `Tool "${toolName}" called successfully for params: %O, result: %O`,
175
195
  loggableParams,
176
- result,
196
+ state,
177
197
  );
178
198
 
179
- // TODO: map more type
180
- const content = result.content
181
- ? result.content
182
- .map((item) => {
183
- switch (item.type) {
184
- case 'text': {
185
- return item.text;
186
- }
187
- default: {
188
- return '';
189
- }
190
- }
191
- })
192
- .filter(Boolean)
193
- .join('\n\n')
194
- : '';
195
-
196
- if (result.isError) return { content, state: result, success: true };
197
-
198
- return { content, state: result, success: true };
199
+ if (result.isError) return { content, state, success: true };
200
+
201
+ return { content, state, success: true };
199
202
  } catch (error) {
200
203
  if (error instanceof McpError) {
201
204
  const mcpError = error as McpError;
@@ -213,7 +216,7 @@ export class MCPService {
213
216
 
214
217
  console.error(
215
218
  `Error calling tool "${toolName}" for params %O:`,
216
- this.sanitizeForLogging(params),
219
+ this.sanitizeForLogging(clientParams),
217
220
  error,
218
221
  );
219
222
  // Propagate a TRPCError
@@ -4,14 +4,12 @@ import type { PartialDeep } from 'type-fest';
4
4
  import { lambdaClient } from '@/libs/trpc/client';
5
5
  import { LobeAgentChatConfig, LobeAgentConfig } from '@/types/agent';
6
6
  import { MetaData } from '@/types/meta';
7
- import { BatchTaskResult } from '@/types/service';
8
7
  import {
9
8
  ChatSessionList,
10
9
  LobeAgentSession,
11
10
  LobeSessionType,
12
11
  LobeSessions,
13
12
  SessionGroupItem,
14
- SessionGroups,
15
13
  SessionRankItem,
16
14
  UpdateSessionParams,
17
15
  } from '@/types/session';
@@ -114,18 +112,6 @@ export class SessionService {
114
112
  return lambdaClient.sessionGroup.createSessionGroup.mutate({ name, sort });
115
113
  };
116
114
 
117
- getSessionGroups = (): Promise<SessionGroupItem[]> => {
118
- return lambdaClient.sessionGroup.getSessionGroup.query();
119
- };
120
-
121
- /**
122
- * 需要废弃
123
- * @deprecated
124
- */
125
- batchCreateSessionGroups = (groups: SessionGroups): Promise<BatchTaskResult> => {
126
- return Promise.resolve({ added: 0, ids: [], skips: [], success: true });
127
- };
128
-
129
115
  removeSessionGroup = (id: string, removeChildren?: boolean) => {
130
116
  return lambdaClient.sessionGroup.removeSessionGroup.mutate({ id, removeChildren });
131
117
  };
@@ -0,0 +1,340 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { DEFAULT_LANG } from '@/const/locale';
4
+ import { DynamicLayoutProps } from '@/types/next';
5
+
6
+ import { DEFAULT_VARIANTS, IRouteVariants, RouteVariants } from './routeVariants';
7
+
8
+ describe('RouteVariants', () => {
9
+ describe('DEFAULT_VARIANTS', () => {
10
+ it('should have correct default values', () => {
11
+ expect(DEFAULT_VARIANTS).toEqual({
12
+ isMobile: false,
13
+ locale: DEFAULT_LANG,
14
+ theme: 'light',
15
+ });
16
+ });
17
+ });
18
+
19
+ describe('serializeVariants', () => {
20
+ it('should serialize variants with default values', () => {
21
+ const variants: IRouteVariants = {
22
+ isMobile: false,
23
+ locale: 'en-US',
24
+ theme: 'light',
25
+ };
26
+ const result = RouteVariants.serializeVariants(variants);
27
+ expect(result).toBe('en-US__0__light');
28
+ });
29
+
30
+ it('should serialize variants with mobile enabled', () => {
31
+ const variants: IRouteVariants = {
32
+ isMobile: true,
33
+ locale: 'zh-CN',
34
+ theme: 'dark',
35
+ };
36
+ const result = RouteVariants.serializeVariants(variants);
37
+ expect(result).toBe('zh-CN__1__dark');
38
+ });
39
+
40
+ it('should serialize variants with different locales', () => {
41
+ const variants: IRouteVariants = {
42
+ isMobile: false,
43
+ locale: 'ja-JP',
44
+ theme: 'light',
45
+ };
46
+ const result = RouteVariants.serializeVariants(variants);
47
+ expect(result).toBe('ja-JP__0__light');
48
+ });
49
+
50
+ it('should serialize variants with custom colors', () => {
51
+ const variants: IRouteVariants = {
52
+ isMobile: true,
53
+ locale: 'en-US',
54
+ neutralColor: '#cccccc',
55
+ primaryColor: '#ff0000',
56
+ theme: 'dark',
57
+ };
58
+ const result = RouteVariants.serializeVariants(variants);
59
+ expect(result).toBe('en-US__1__dark');
60
+ });
61
+ });
62
+
63
+ describe('deserializeVariants', () => {
64
+ it('should deserialize valid serialized string', () => {
65
+ const serialized = 'en-US__0__light';
66
+ const result = RouteVariants.deserializeVariants(serialized);
67
+ expect(result).toEqual({
68
+ isMobile: false,
69
+ locale: 'en-US',
70
+ theme: 'light',
71
+ });
72
+ });
73
+
74
+ it('should deserialize mobile variants', () => {
75
+ const serialized = 'zh-CN__1__dark';
76
+ const result = RouteVariants.deserializeVariants(serialized);
77
+ expect(result).toEqual({
78
+ isMobile: true,
79
+ locale: 'zh-CN',
80
+ theme: 'dark',
81
+ });
82
+ });
83
+
84
+ it('should return default values for invalid serialized string', () => {
85
+ const serialized = 'invalid';
86
+ const result = RouteVariants.deserializeVariants(serialized);
87
+ expect(result).toEqual(DEFAULT_VARIANTS);
88
+ });
89
+
90
+ it('should return default values for empty string', () => {
91
+ const serialized = '';
92
+ const result = RouteVariants.deserializeVariants(serialized);
93
+ expect(result).toEqual(DEFAULT_VARIANTS);
94
+ });
95
+
96
+ it('should handle invalid locale by falling back to default', () => {
97
+ const serialized = 'invalid-locale__0__light';
98
+ const result = RouteVariants.deserializeVariants(serialized);
99
+ expect(result).toEqual({
100
+ isMobile: false,
101
+ locale: DEFAULT_LANG,
102
+ theme: 'light',
103
+ });
104
+ });
105
+
106
+ it('should handle invalid theme by falling back to default', () => {
107
+ const serialized = 'en-US__0__invalid-theme';
108
+ const result = RouteVariants.deserializeVariants(serialized);
109
+ expect(result).toEqual({
110
+ isMobile: false,
111
+ locale: 'en-US',
112
+ theme: 'light',
113
+ });
114
+ });
115
+
116
+ it('should handle malformed serialized string', () => {
117
+ const serialized = 'en-US';
118
+ const result = RouteVariants.deserializeVariants(serialized);
119
+ expect(result).toEqual(DEFAULT_VARIANTS);
120
+ });
121
+
122
+ it('should handle isMobile value correctly for "0"', () => {
123
+ const serialized = 'en-US__0__dark';
124
+ const result = RouteVariants.deserializeVariants(serialized);
125
+ expect(result.isMobile).toBe(false);
126
+ });
127
+
128
+ it('should handle isMobile value correctly for "1"', () => {
129
+ const serialized = 'en-US__1__dark';
130
+ const result = RouteVariants.deserializeVariants(serialized);
131
+ expect(result.isMobile).toBe(true);
132
+ });
133
+
134
+ it('should handle isMobile value correctly for other values', () => {
135
+ const serialized = 'en-US__2__dark';
136
+ const result = RouteVariants.deserializeVariants(serialized);
137
+ expect(result.isMobile).toBe(false);
138
+ });
139
+ });
140
+
141
+ describe('getVariantsFromProps', () => {
142
+ it('should extract and deserialize variants from props', async () => {
143
+ const props: DynamicLayoutProps = {
144
+ params: Promise.resolve({ variants: 'en-US__0__light' }),
145
+ };
146
+ const result = await RouteVariants.getVariantsFromProps(props);
147
+ expect(result).toEqual({
148
+ isMobile: false,
149
+ locale: 'en-US',
150
+ theme: 'light',
151
+ });
152
+ });
153
+
154
+ it('should handle mobile variants from props', async () => {
155
+ const props: DynamicLayoutProps = {
156
+ params: Promise.resolve({ variants: 'zh-CN__1__dark' }),
157
+ };
158
+ const result = await RouteVariants.getVariantsFromProps(props);
159
+ expect(result).toEqual({
160
+ isMobile: true,
161
+ locale: 'zh-CN',
162
+ theme: 'dark',
163
+ });
164
+ });
165
+
166
+ it('should handle invalid variants in props', async () => {
167
+ const props: DynamicLayoutProps = {
168
+ params: Promise.resolve({ variants: 'invalid' }),
169
+ };
170
+ const result = await RouteVariants.getVariantsFromProps(props);
171
+ expect(result).toEqual(DEFAULT_VARIANTS);
172
+ });
173
+ });
174
+
175
+ describe('getIsMobile', () => {
176
+ it('should extract isMobile as false from props', async () => {
177
+ const props: DynamicLayoutProps = {
178
+ params: Promise.resolve({ variants: 'en-US__0__light' }),
179
+ };
180
+ const result = await RouteVariants.getIsMobile(props);
181
+ expect(result).toBe(false);
182
+ });
183
+
184
+ it('should extract isMobile as true from props', async () => {
185
+ const props: DynamicLayoutProps = {
186
+ params: Promise.resolve({ variants: 'en-US__1__dark' }),
187
+ };
188
+ const result = await RouteVariants.getIsMobile(props);
189
+ expect(result).toBe(true);
190
+ });
191
+
192
+ it('should return default isMobile for invalid props', async () => {
193
+ const props: DynamicLayoutProps = {
194
+ params: Promise.resolve({ variants: 'invalid' }),
195
+ };
196
+ const result = await RouteVariants.getIsMobile(props);
197
+ expect(result).toBe(DEFAULT_VARIANTS.isMobile);
198
+ });
199
+ });
200
+
201
+ describe('getLocale', () => {
202
+ it('should extract locale from props', async () => {
203
+ const props: DynamicLayoutProps = {
204
+ params: Promise.resolve({ variants: 'zh-CN__0__light' }),
205
+ };
206
+ const result = await RouteVariants.getLocale(props);
207
+ expect(result).toBe('zh-CN');
208
+ });
209
+
210
+ it('should extract different locale from props', async () => {
211
+ const props: DynamicLayoutProps = {
212
+ params: Promise.resolve({ variants: 'ja-JP__1__dark' }),
213
+ };
214
+ const result = await RouteVariants.getLocale(props);
215
+ expect(result).toBe('ja-JP');
216
+ });
217
+
218
+ it('should return default locale for invalid props', async () => {
219
+ const props: DynamicLayoutProps = {
220
+ params: Promise.resolve({ variants: 'invalid' }),
221
+ };
222
+ const result = await RouteVariants.getLocale(props);
223
+ expect(result).toBe(DEFAULT_LANG);
224
+ });
225
+
226
+ it('should return default locale for invalid locale in props', async () => {
227
+ const props: DynamicLayoutProps = {
228
+ params: Promise.resolve({ variants: 'invalid-locale__0__light' }),
229
+ };
230
+ const result = await RouteVariants.getLocale(props);
231
+ expect(result).toBe(DEFAULT_LANG);
232
+ });
233
+ });
234
+
235
+ describe('createVariants', () => {
236
+ it('should create variants with default values when no options provided', () => {
237
+ const result = RouteVariants.createVariants();
238
+ expect(result).toEqual(DEFAULT_VARIANTS);
239
+ });
240
+
241
+ it('should create variants with custom isMobile', () => {
242
+ const result = RouteVariants.createVariants({ isMobile: true });
243
+ expect(result).toEqual({
244
+ ...DEFAULT_VARIANTS,
245
+ isMobile: true,
246
+ });
247
+ });
248
+
249
+ it('should create variants with custom locale', () => {
250
+ const result = RouteVariants.createVariants({ locale: 'zh-CN' });
251
+ expect(result).toEqual({
252
+ ...DEFAULT_VARIANTS,
253
+ locale: 'zh-CN',
254
+ });
255
+ });
256
+
257
+ it('should create variants with custom theme', () => {
258
+ const result = RouteVariants.createVariants({ theme: 'dark' });
259
+ expect(result).toEqual({
260
+ ...DEFAULT_VARIANTS,
261
+ theme: 'dark',
262
+ });
263
+ });
264
+
265
+ it('should create variants with multiple custom options', () => {
266
+ const result = RouteVariants.createVariants({
267
+ isMobile: true,
268
+ locale: 'ja-JP',
269
+ theme: 'dark',
270
+ });
271
+ expect(result).toEqual({
272
+ isMobile: true,
273
+ locale: 'ja-JP',
274
+ theme: 'dark',
275
+ });
276
+ });
277
+
278
+ it('should create variants with custom colors', () => {
279
+ const result = RouteVariants.createVariants({
280
+ neutralColor: '#cccccc',
281
+ primaryColor: '#ff0000',
282
+ });
283
+ expect(result).toEqual({
284
+ ...DEFAULT_VARIANTS,
285
+ neutralColor: '#cccccc',
286
+ primaryColor: '#ff0000',
287
+ });
288
+ });
289
+
290
+ it('should create variants with all custom options', () => {
291
+ const result = RouteVariants.createVariants({
292
+ isMobile: true,
293
+ locale: 'zh-CN',
294
+ neutralColor: '#aaaaaa',
295
+ primaryColor: '#00ff00',
296
+ theme: 'dark',
297
+ });
298
+ expect(result).toEqual({
299
+ isMobile: true,
300
+ locale: 'zh-CN',
301
+ neutralColor: '#aaaaaa',
302
+ primaryColor: '#00ff00',
303
+ theme: 'dark',
304
+ });
305
+ });
306
+ });
307
+
308
+ describe('roundtrip serialization', () => {
309
+ it('should maintain data integrity through serialize and deserialize', () => {
310
+ const original: IRouteVariants = {
311
+ isMobile: true,
312
+ locale: 'zh-CN',
313
+ theme: 'dark',
314
+ };
315
+ const serialized = RouteVariants.serializeVariants(original);
316
+ const deserialized = RouteVariants.deserializeVariants(serialized);
317
+ expect(deserialized).toEqual(original);
318
+ });
319
+
320
+ it('should maintain data integrity for default variants', () => {
321
+ const serialized = RouteVariants.serializeVariants(DEFAULT_VARIANTS);
322
+ const deserialized = RouteVariants.deserializeVariants(serialized);
323
+ expect(deserialized).toEqual(DEFAULT_VARIANTS);
324
+ });
325
+
326
+ it('should maintain data integrity for various locales', () => {
327
+ const locales = ['en-US', 'zh-CN', 'ja-JP', 'ko-KR', 'fr-FR'];
328
+ locales.forEach((locale) => {
329
+ const original: IRouteVariants = {
330
+ isMobile: false,
331
+ locale: locale as any,
332
+ theme: 'light',
333
+ };
334
+ const serialized = RouteVariants.serializeVariants(original);
335
+ const deserialized = RouteVariants.deserializeVariants(serialized);
336
+ expect(deserialized).toEqual(original);
337
+ });
338
+ });
339
+ });
340
+ });