@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.
- package/CHANGELOG.md +50 -0
- package/changelog/v1.json +18 -0
- package/package.json +1 -1
- package/packages/database/src/models/file.ts +15 -1
- package/packages/database/src/repositories/aiInfra/index.test.ts +1 -1
- package/packages/database/src/repositories/dataExporter/index.test.ts +1 -1
- package/packages/database/src/repositories/tableViewer/index.test.ts +1 -1
- package/packages/types/src/aiProvider.ts +1 -1
- package/packages/types/src/document/index.ts +38 -38
- package/packages/types/src/exportConfig.ts +15 -15
- package/packages/types/src/generation/index.ts +5 -5
- package/packages/types/src/openai/chat.ts +15 -15
- package/packages/types/src/plugins/mcp.ts +29 -29
- package/packages/types/src/plugins/protocol.ts +43 -43
- package/packages/types/src/search.ts +4 -4
- package/packages/types/src/tool/plugin.ts +3 -3
- package/src/app/(backend)/f/[id]/route.ts +55 -0
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/ChatItem/Thread.tsx +1 -1
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/ChatItem/index.tsx +9 -16
- package/src/envs/app.ts +4 -3
- package/src/features/Conversation/Messages/Assistant/Actions/index.tsx +3 -5
- package/src/features/Conversation/Messages/Assistant/Extra/index.test.tsx +3 -3
- package/src/features/Conversation/Messages/Assistant/Extra/index.tsx +8 -5
- package/src/features/Conversation/Messages/Assistant/index.tsx +29 -15
- package/src/features/Conversation/Messages/Group/Actions/WithContentId.tsx +3 -5
- package/src/features/Conversation/Messages/Group/index.tsx +12 -20
- package/src/features/Conversation/Messages/Supervisor/index.tsx +14 -5
- package/src/features/Conversation/Messages/User/index.tsx +14 -8
- package/src/features/Conversation/Messages/index.tsx +16 -26
- package/src/features/Conversation/components/Extras/Usage/UsageDetail/index.tsx +7 -6
- package/src/features/Conversation/components/Extras/Usage/UsageDetail/tokens.ts +2 -5
- package/src/features/Conversation/components/Extras/Usage/index.tsx +13 -6
- package/src/features/Conversation/components/VirtualizedList/index.tsx +2 -1
- package/src/features/PluginsUI/Render/MCPType/index.tsx +26 -6
- package/src/server/modules/ContentChunk/index.test.ts +372 -0
- package/src/server/routers/desktop/mcp.ts +23 -8
- package/src/server/routers/tools/mcp.ts +24 -4
- package/src/server/services/file/impls/local.ts +4 -1
- package/src/server/services/file/index.ts +96 -1
- package/src/server/services/mcp/contentProcessor.ts +101 -0
- package/src/server/services/mcp/index.test.ts +52 -10
- package/src/server/services/mcp/index.ts +29 -26
- package/src/services/session/index.ts +0 -14
- 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 `})`;
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
158
|
-
async callTool(
|
|
159
|
-
|
|
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(
|
|
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
|
-
|
|
196
|
+
state,
|
|
177
197
|
);
|
|
178
198
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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(
|
|
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
|
+
});
|