Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
重构所有代码和文档中的项目名称,将OpenFang统一更新为ZCLAW。包括: - 配置文件中的项目名称 - 代码注释和文档引用 - 环境变量和路径 - 类型定义和接口名称 - 测试用例和模拟数据 同时优化部分代码结构,移除未使用的模块,并更新相关依赖项。
697 lines
20 KiB
TypeScript
697 lines
20 KiB
TypeScript
/**
|
|
* Chat Store Tests
|
|
*
|
|
* Tests for chat state management including messages, conversations, and agents.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { useChatStore, Message, Conversation, Agent, toChatAgent } from '../../src/store/chatStore';
|
|
import { localStorageMock } from '../setup';
|
|
|
|
// Mock gateway client - use vi.hoisted to ensure mocks are available before module import
|
|
const { mockChatStream, mockChat, mockOnAgentStream, mockGetState } = vi.hoisted(() => {
|
|
return {
|
|
mockChatStream: vi.fn(),
|
|
mockChat: vi.fn(),
|
|
mockOnAgentStream: vi.fn(() => () => {}),
|
|
mockGetState: vi.fn(() => 'disconnected'),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/lib/gateway-client', () => ({
|
|
getGatewayClient: vi.fn(() => ({
|
|
chatStream: mockChatStream,
|
|
chat: mockChat,
|
|
onAgentStream: mockOnAgentStream,
|
|
getState: mockGetState,
|
|
})),
|
|
}));
|
|
|
|
// Mock intelligence client
|
|
vi.mock('../../src/lib/intelligence-client', () => ({
|
|
intelligenceClient: {
|
|
compactor: {
|
|
checkThreshold: vi.fn(() => Promise.resolve({ should_compact: false, current_tokens: 0, urgency: 'none' })),
|
|
compact: vi.fn(() => Promise.resolve({ compacted_messages: [] })),
|
|
},
|
|
memory: {
|
|
search: vi.fn(() => Promise.resolve([])),
|
|
},
|
|
identity: {
|
|
buildPrompt: vi.fn(() => Promise.resolve('')),
|
|
},
|
|
reflection: {
|
|
recordConversation: vi.fn(() => Promise.resolve()),
|
|
shouldReflect: vi.fn(() => Promise.resolve(false)),
|
|
reflect: vi.fn(() => Promise.resolve()),
|
|
},
|
|
},
|
|
}));
|
|
|
|
// Mock memory extractor
|
|
vi.mock('../../src/lib/memory-extractor', () => ({
|
|
getMemoryExtractor: vi.fn(() => ({
|
|
extractFromConversation: vi.fn(() => Promise.resolve([])),
|
|
})),
|
|
}));
|
|
|
|
// Mock agent swarm
|
|
vi.mock('../../src/lib/agent-swarm', () => ({
|
|
getAgentSwarm: vi.fn(() => ({
|
|
createTask: vi.fn(() => ({ id: 'task-1' })),
|
|
setExecutor: vi.fn(),
|
|
execute: vi.fn(() => Promise.resolve({ summary: 'Task completed', task: { id: 'task-1' } })),
|
|
})),
|
|
}));
|
|
|
|
// Mock skill discovery
|
|
vi.mock('../../src/lib/skill-discovery', () => ({
|
|
getSkillDiscovery: vi.fn(() => ({
|
|
searchSkills: vi.fn(() => ({ results: [], totalAvailable: 0 })),
|
|
})),
|
|
}));
|
|
|
|
describe('chatStore', () => {
|
|
// Store the original state to reset between tests
|
|
const initialState = {
|
|
messages: [],
|
|
conversations: [],
|
|
currentConversationId: null,
|
|
agents: [{ id: '1', name: 'ZCLAW', icon: '\u{1F99E}', color: 'bg-gradient-to-br from-orange-500 to-red-500', lastMessage: '\u{53D1}\u{9001}\u{6D88}\u{606F}\u{5F00}\u{59CB}\u{5BF9}\u{8BDD}', time: '' }],
|
|
currentAgent: { id: '1', name: 'ZCLAW', icon: '\u{1F99E}', color: 'bg-gradient-to-br from-orange-500 to-red-500', lastMessage: '\u{53D1}\u{9001}\u{6D88}\u{606F}\u{5F00}\u{59CB}\u{5BF9}\u{8BDD}', time: '' },
|
|
isStreaming: false,
|
|
currentModel: 'glm-5',
|
|
sessionKey: null,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
// Reset store state
|
|
useChatStore.setState(initialState);
|
|
// Clear localStorage
|
|
localStorageMock.clear();
|
|
// Clear all mocks
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('Initial State', () => {
|
|
it('should have empty messages array', () => {
|
|
const state = useChatStore.getState();
|
|
expect(state.messages).toEqual([]);
|
|
});
|
|
|
|
it('should have default agent set', () => {
|
|
const state = useChatStore.getState();
|
|
expect(state.currentAgent).not.toBeNull();
|
|
expect(state.currentAgent?.id).toBe('1');
|
|
expect(state.currentAgent?.name).toBe('ZCLAW');
|
|
});
|
|
|
|
it('should not be streaming initially', () => {
|
|
const state = useChatStore.getState();
|
|
expect(state.isStreaming).toBe(false);
|
|
});
|
|
|
|
it('should have default model', () => {
|
|
const state = useChatStore.getState();
|
|
expect(state.currentModel).toBe('glm-5');
|
|
});
|
|
|
|
it('should have null sessionKey initially', () => {
|
|
const state = useChatStore.getState();
|
|
expect(state.sessionKey).toBeNull();
|
|
});
|
|
|
|
it('should have empty conversations array', () => {
|
|
const state = useChatStore.getState();
|
|
expect(state.conversations).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('addMessage', () => {
|
|
it('should add a message to the store', () => {
|
|
const { addMessage } = useChatStore.getState();
|
|
const message: Message = {
|
|
id: 'test-1',
|
|
role: 'user',
|
|
content: 'Hello',
|
|
timestamp: new Date(),
|
|
};
|
|
|
|
addMessage(message);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages).toHaveLength(1);
|
|
expect(state.messages[0].id).toBe('test-1');
|
|
expect(state.messages[0].content).toBe('Hello');
|
|
});
|
|
|
|
it('should append message to existing messages', () => {
|
|
const { addMessage } = useChatStore.getState();
|
|
|
|
addMessage({
|
|
id: 'test-1',
|
|
role: 'user',
|
|
content: 'First',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
addMessage({
|
|
id: 'test-2',
|
|
role: 'assistant',
|
|
content: 'Second',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages).toHaveLength(2);
|
|
expect(state.messages[0].id).toBe('test-1');
|
|
expect(state.messages[1].id).toBe('test-2');
|
|
});
|
|
|
|
it('should preserve message with all fields', () => {
|
|
const { addMessage } = useChatStore.getState();
|
|
const message: Message = {
|
|
id: 'test-1',
|
|
role: 'tool',
|
|
content: 'Tool output',
|
|
timestamp: new Date(),
|
|
toolName: 'test-tool',
|
|
toolInput: '{"key": "value"}',
|
|
toolOutput: 'result',
|
|
runId: 'run-123',
|
|
};
|
|
|
|
addMessage(message);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages[0].toolName).toBe('test-tool');
|
|
expect(state.messages[0].toolInput).toBe('{"key": "value"}');
|
|
expect(state.messages[0].toolOutput).toBe('result');
|
|
expect(state.messages[0].runId).toBe('run-123');
|
|
});
|
|
});
|
|
|
|
describe('updateMessage', () => {
|
|
it('should update existing message content', () => {
|
|
const { addMessage, updateMessage } = useChatStore.getState();
|
|
|
|
addMessage({
|
|
id: 'test-1',
|
|
role: 'assistant',
|
|
content: 'Initial',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
updateMessage('test-1', { content: 'Updated' });
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages[0].content).toBe('Updated');
|
|
});
|
|
|
|
it('should update streaming flag', () => {
|
|
const { addMessage, updateMessage } = useChatStore.getState();
|
|
|
|
addMessage({
|
|
id: 'test-1',
|
|
role: 'assistant',
|
|
content: 'Streaming...',
|
|
timestamp: new Date(),
|
|
streaming: true,
|
|
});
|
|
|
|
updateMessage('test-1', { streaming: false });
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages[0].streaming).toBe(false);
|
|
});
|
|
|
|
it('should not modify message if id not found', () => {
|
|
const { addMessage, updateMessage } = useChatStore.getState();
|
|
|
|
addMessage({
|
|
id: 'test-1',
|
|
role: 'user',
|
|
content: 'Test',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
updateMessage('non-existent', { content: 'Should not appear' });
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages[0].content).toBe('Test');
|
|
});
|
|
|
|
it('should update runId on message', () => {
|
|
const { addMessage, updateMessage } = useChatStore.getState();
|
|
|
|
addMessage({
|
|
id: 'test-1',
|
|
role: 'assistant',
|
|
content: 'Test',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
updateMessage('test-1', { runId: 'run-456' });
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages[0].runId).toBe('run-456');
|
|
});
|
|
});
|
|
|
|
describe('setCurrentModel', () => {
|
|
it('should update current model', () => {
|
|
const { setCurrentModel } = useChatStore.getState();
|
|
|
|
setCurrentModel('gpt-4');
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.currentModel).toBe('gpt-4');
|
|
});
|
|
});
|
|
|
|
describe('newConversation', () => {
|
|
it('should clear messages and reset session', () => {
|
|
const { addMessage, newConversation } = useChatStore.getState();
|
|
|
|
addMessage({
|
|
id: 'test-1',
|
|
role: 'user',
|
|
content: 'Test message',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
useChatStore.setState({ sessionKey: 'old-session' });
|
|
|
|
newConversation();
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages).toEqual([]);
|
|
expect(state.sessionKey).toBeNull();
|
|
expect(state.isStreaming).toBe(false);
|
|
expect(state.currentConversationId).toBeNull();
|
|
});
|
|
|
|
it('should save current messages to conversations before clearing', () => {
|
|
const { addMessage, newConversation } = useChatStore.getState();
|
|
|
|
addMessage({
|
|
id: 'test-1',
|
|
role: 'user',
|
|
content: 'Test message to save',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
newConversation();
|
|
|
|
const state = useChatStore.getState();
|
|
// Conversation should be saved
|
|
expect(state.conversations.length).toBeGreaterThan(0);
|
|
expect(state.conversations[0].messages[0].content).toBe('Test message to save');
|
|
});
|
|
});
|
|
|
|
describe('switchConversation', () => {
|
|
it('should switch to existing conversation', () => {
|
|
const { addMessage, switchConversation, newConversation } = useChatStore.getState();
|
|
|
|
// Create first conversation
|
|
addMessage({
|
|
id: 'msg-1',
|
|
role: 'user',
|
|
content: 'First conversation',
|
|
timestamp: new Date(),
|
|
});
|
|
newConversation();
|
|
|
|
// Create second conversation
|
|
addMessage({
|
|
id: 'msg-2',
|
|
role: 'user',
|
|
content: 'Second conversation',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
const firstConvId = useChatStore.getState().conversations[0].id;
|
|
|
|
// Switch back to first conversation
|
|
switchConversation(firstConvId);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages[0].content).toBe('First conversation');
|
|
expect(state.currentConversationId).toBe(firstConvId);
|
|
});
|
|
});
|
|
|
|
describe('deleteConversation', () => {
|
|
it('should delete conversation by id', () => {
|
|
const { addMessage, newConversation, deleteConversation } = useChatStore.getState();
|
|
|
|
// Create a conversation
|
|
addMessage({
|
|
id: 'msg-1',
|
|
role: 'user',
|
|
content: 'Test',
|
|
timestamp: new Date(),
|
|
});
|
|
newConversation();
|
|
|
|
const convId = useChatStore.getState().conversations[0].id;
|
|
expect(useChatStore.getState().conversations).toHaveLength(1);
|
|
|
|
// Delete it
|
|
deleteConversation(convId);
|
|
|
|
expect(useChatStore.getState().conversations).toHaveLength(0);
|
|
});
|
|
|
|
it('should clear messages if deleting current conversation', () => {
|
|
const { addMessage, deleteConversation } = useChatStore.getState();
|
|
|
|
// Create a conversation without calling newConversation
|
|
addMessage({
|
|
id: 'msg-1',
|
|
role: 'user',
|
|
content: 'Test',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
// Manually set up a current conversation
|
|
const convId = 'conv-test-123';
|
|
useChatStore.setState({
|
|
currentConversationId: convId,
|
|
conversations: [{
|
|
id: convId,
|
|
title: 'Test',
|
|
messages: useChatStore.getState().messages,
|
|
sessionKey: null,
|
|
agentId: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}],
|
|
});
|
|
|
|
deleteConversation(convId);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages).toEqual([]);
|
|
expect(state.sessionKey).toBeNull();
|
|
expect(state.currentConversationId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('setCurrentAgent', () => {
|
|
it('should update current agent', () => {
|
|
const { setCurrentAgent } = useChatStore.getState();
|
|
const newAgent: Agent = {
|
|
id: 'agent-2',
|
|
name: 'New Agent',
|
|
icon: 'A',
|
|
color: 'bg-blue-500',
|
|
lastMessage: 'Hello',
|
|
time: '',
|
|
};
|
|
|
|
setCurrentAgent(newAgent);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.currentAgent).toEqual(newAgent);
|
|
});
|
|
|
|
it('should save current conversation when switching agents', () => {
|
|
const { addMessage, setCurrentAgent } = useChatStore.getState();
|
|
|
|
// Add a message first
|
|
addMessage({
|
|
id: 'msg-1',
|
|
role: 'user',
|
|
content: 'Test message',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
// Switch agent
|
|
const newAgent: Agent = {
|
|
id: 'agent-2',
|
|
name: 'New Agent',
|
|
icon: 'A',
|
|
color: 'bg-blue-500',
|
|
lastMessage: '',
|
|
time: '',
|
|
};
|
|
setCurrentAgent(newAgent);
|
|
|
|
// Messages should be cleared for new agent
|
|
expect(useChatStore.getState().messages).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('syncAgents', () => {
|
|
it('should sync agents from profiles', () => {
|
|
const { syncAgents } = useChatStore.getState();
|
|
|
|
syncAgents([
|
|
{ id: 'agent-1', name: 'Agent One', nickname: 'A1' },
|
|
{ id: 'agent-2', name: 'Agent Two', nickname: 'A2' },
|
|
]);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.agents).toHaveLength(2);
|
|
expect(state.agents[0].name).toBe('Agent One');
|
|
expect(state.agents[1].name).toBe('Agent Two');
|
|
});
|
|
|
|
it('should use default agent when no profiles provided', () => {
|
|
const { syncAgents } = useChatStore.getState();
|
|
|
|
syncAgents([]);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.agents).toHaveLength(1);
|
|
expect(state.agents[0].id).toBe('1');
|
|
});
|
|
});
|
|
|
|
describe('toChatAgent helper', () => {
|
|
it('should convert AgentProfileLike to Agent', () => {
|
|
const profile = {
|
|
id: 'test-id',
|
|
name: 'Test Agent',
|
|
nickname: 'Testy',
|
|
role: 'Developer',
|
|
};
|
|
|
|
const agent = toChatAgent(profile);
|
|
|
|
expect(agent.id).toBe('test-id');
|
|
expect(agent.name).toBe('Test Agent');
|
|
expect(agent.icon).toBe('T');
|
|
expect(agent.lastMessage).toBe('Developer');
|
|
});
|
|
|
|
it('should use default icon if no nickname', () => {
|
|
const profile = {
|
|
id: 'test-id',
|
|
name: 'Test Agent',
|
|
};
|
|
|
|
const agent = toChatAgent(profile);
|
|
|
|
expect(agent.icon).toBe('\u{1F99E}'); // lobster emoji
|
|
});
|
|
});
|
|
|
|
describe('searchSkills', () => {
|
|
it('should call skill discovery', () => {
|
|
const { searchSkills } = useChatStore.getState();
|
|
|
|
const result = searchSkills('test query');
|
|
|
|
expect(result).toHaveProperty('results');
|
|
expect(result).toHaveProperty('totalAvailable');
|
|
});
|
|
});
|
|
|
|
describe('initStreamListener', () => {
|
|
it('should return unsubscribe function', () => {
|
|
const { initStreamListener } = useChatStore.getState();
|
|
|
|
const unsubscribe = initStreamListener();
|
|
|
|
expect(typeof unsubscribe).toBe('function');
|
|
unsubscribe();
|
|
});
|
|
|
|
it('should register onAgentStream callback', () => {
|
|
const { initStreamListener } = useChatStore.getState();
|
|
|
|
initStreamListener();
|
|
|
|
expect(mockOnAgentStream).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('sendMessage', () => {
|
|
it('should add user message', async () => {
|
|
const { sendMessage } = useChatStore.getState();
|
|
|
|
// Mock gateway as disconnected to use REST fallback
|
|
mockGetState.mockReturnValue('disconnected');
|
|
mockChat.mockResolvedValue({ response: 'Test response', runId: 'run-1' });
|
|
|
|
await sendMessage('Hello world');
|
|
|
|
const state = useChatStore.getState();
|
|
// Should have user message and assistant message
|
|
expect(state.messages.length).toBeGreaterThanOrEqual(1);
|
|
const userMessage = state.messages.find(m => m.role === 'user');
|
|
expect(userMessage?.content).toBe('Hello world');
|
|
});
|
|
|
|
it('should set streaming flag while processing', async () => {
|
|
const { sendMessage } = useChatStore.getState();
|
|
|
|
mockGetState.mockReturnValue('disconnected');
|
|
mockChat.mockResolvedValue({ response: 'Test response', runId: 'run-1' });
|
|
|
|
// Start sending (don't await immediately)
|
|
const sendPromise = sendMessage('Test');
|
|
|
|
// Check streaming was set
|
|
const streamingDuring = useChatStore.getState().isStreaming;
|
|
|
|
await sendPromise;
|
|
|
|
// After completion, streaming should be false
|
|
const streamingAfter = useChatStore.getState().isStreaming;
|
|
|
|
// Streaming was set at some point (either during or reset after)
|
|
expect(streamingDuring || !streamingAfter).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('message types', () => {
|
|
it('should handle tool message', () => {
|
|
const { addMessage } = useChatStore.getState();
|
|
const toolMsg: Message = {
|
|
id: 'tool-1',
|
|
role: 'tool',
|
|
content: 'Tool executed',
|
|
timestamp: new Date(),
|
|
toolName: 'bash',
|
|
toolInput: 'echo test',
|
|
toolOutput: 'test',
|
|
};
|
|
|
|
addMessage(toolMsg);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages[0].role).toBe('tool');
|
|
expect(state.messages[0].toolName).toBe('bash');
|
|
});
|
|
|
|
it('should handle hand message', () => {
|
|
const { addMessage } = useChatStore.getState();
|
|
const handMsg: Message = {
|
|
id: 'hand-1',
|
|
role: 'hand',
|
|
content: 'Hand executed',
|
|
timestamp: new Date(),
|
|
handName: 'browser',
|
|
handStatus: 'completed',
|
|
handResult: { url: 'https://example.com' },
|
|
};
|
|
|
|
addMessage(handMsg);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages[0].role).toBe('hand');
|
|
expect(state.messages[0].handName).toBe('browser');
|
|
});
|
|
|
|
it('should handle workflow message', () => {
|
|
const { addMessage } = useChatStore.getState();
|
|
const workflowMsg: Message = {
|
|
id: 'workflow-1',
|
|
role: 'workflow',
|
|
content: 'Workflow step completed',
|
|
timestamp: new Date(),
|
|
workflowId: 'wf-123',
|
|
workflowStep: 'step-1',
|
|
workflowStatus: 'completed',
|
|
};
|
|
|
|
addMessage(workflowMsg);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages[0].role).toBe('workflow');
|
|
expect(state.messages[0].workflowId).toBe('wf-123');
|
|
});
|
|
});
|
|
|
|
describe('conversation persistence', () => {
|
|
it('should derive title from first user message', () => {
|
|
const { addMessage, newConversation } = useChatStore.getState();
|
|
|
|
addMessage({
|
|
id: 'msg-1',
|
|
role: 'user',
|
|
content: 'This is a long message that should be truncated in the title',
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
newConversation();
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.conversations[0].title).toContain('This is a long message');
|
|
expect(state.conversations[0].title.length).toBeLessThanOrEqual(33); // 30 chars + '...'
|
|
});
|
|
|
|
it('should use default title for empty messages', () => {
|
|
// Create a conversation directly with empty messages
|
|
useChatStore.setState({
|
|
conversations: [{
|
|
id: 'conv-1',
|
|
title: '',
|
|
messages: [],
|
|
sessionKey: null,
|
|
agentId: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}],
|
|
});
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.conversations).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should handle streaming errors', async () => {
|
|
const { addMessage, updateMessage } = useChatStore.getState();
|
|
|
|
// Add a streaming message
|
|
addMessage({
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
content: '',
|
|
timestamp: new Date(),
|
|
streaming: true,
|
|
});
|
|
|
|
// Simulate error
|
|
updateMessage('assistant-1', {
|
|
content: 'Error: Connection failed',
|
|
streaming: false,
|
|
error: 'Connection failed',
|
|
});
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.messages[0].error).toBe('Connection failed');
|
|
expect(state.messages[0].streaming).toBe(false);
|
|
});
|
|
});
|
|
});
|