refactor:重构Zulip模块按业务功能模块化架构

- 将技术实现服务从business层迁移到core层
- 创建src/core/zulip/核心服务模块,包含API客户端、连接池等技术服务
- 保留src/business/zulip/业务逻辑,专注游戏相关的业务规则
- 通过依赖注入实现业务层与核心层的解耦
- 更新模块导入关系,确保架构分层清晰

重构后的架构符合单一职责原则,提高了代码的可维护性和可测试性
This commit is contained in:
moyin
2025-12-31 15:44:36 +08:00
parent 5140bd1a54
commit 2d10131838
36 changed files with 2773 additions and 125 deletions

View File

@@ -0,0 +1,829 @@
/**
* Zulip事件处理服务测试
*
* 功能描述:
* - 测试ZulipEventProcessorService的核心功能
* - 包含属性测试验证消息格式转换完整性
* - 包含属性测试验证消息接收和分发
*
* **Feature: zulip-integration, Property 4: 消息格式转换完整性**
* **Validates: Requirements 5.3, 5.4**
*
* **Feature: zulip-integration, Property 5: 消息接收和分发**
* **Validates: Requirements 5.1, 5.2, 5.5**
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-25
*/
import { Test, TestingModule } from '@nestjs/testing';
import * as fc from 'fast-check';
import {
ZulipEventProcessorService,
ZulipMessage,
GameMessage,
MessageDistributor,
} from './zulip_event_processor.service';
import { SessionManagerService, GameSession } from './session_manager.service';
import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip/interfaces/zulip-core.interfaces';
import { AppLoggerService } from '../../../core/utils/logger/logger.service';
describe('ZulipEventProcessorService', () => {
let service: ZulipEventProcessorService;
let mockLogger: jest.Mocked<AppLoggerService>;
let mockSessionManager: jest.Mocked<SessionManagerService>;
let mockConfigManager: jest.Mocked<IZulipConfigService>;
let mockClientPool: jest.Mocked<IZulipClientPoolService>;
let mockDistributor: jest.Mocked<MessageDistributor>;
// 创建模拟Zulip消息
const createMockZulipMessage = (overrides: Partial<ZulipMessage> = {}): ZulipMessage => ({
id: Math.floor(Math.random() * 1000000),
sender_email: 'test@example.com',
sender_full_name: 'Test User',
content: 'Hello, World!',
stream_id: 1,
subject: 'General',
timestamp: Math.floor(Date.now() / 1000),
display_recipient: 'Tavern',
type: 'stream',
...overrides,
});
// 创建模拟会话
const createMockSession = (overrides: Partial<GameSession> = {}): GameSession => ({
socketId: `socket_${Math.random().toString(36).substr(2, 9)}`,
userId: `user_${Math.random().toString(36).substr(2, 9)}`,
username: 'TestPlayer',
zulipQueueId: `queue_${Math.random().toString(36).substr(2, 9)}`,
currentMap: 'tavern',
position: { x: 100, y: 200 },
lastActivity: new Date(),
createdAt: new Date(),
...overrides,
});
beforeEach(async () => {
jest.clearAllMocks();
mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as any;
mockSessionManager = {
getSession: jest.fn(),
getSocketsInMap: jest.fn(),
createSession: jest.fn(),
destroySession: jest.fn(),
updatePlayerPosition: jest.fn(),
injectContext: jest.fn(),
} as any;
mockConfigManager = {
getMapIdByStream: jest.fn(),
getStreamByMap: jest.fn(),
getTopicByObject: jest.fn(),
getZulipConfig: jest.fn(),
hasMap: jest.fn(),
hasStream: jest.fn(),
getAllMapIds: jest.fn(),
getAllStreams: jest.fn(),
reloadConfig: jest.fn(),
validateConfig: jest.fn(),
} as any;
mockClientPool = {
getUserClient: jest.fn(),
createUserClient: jest.fn(),
destroyUserClient: jest.fn(),
hasUserClient: jest.fn(),
sendMessage: jest.fn(),
registerEventQueue: jest.fn(),
deregisterEventQueue: jest.fn(),
getPoolStats: jest.fn(),
cleanupIdleClients: jest.fn(),
} as any;
mockDistributor = {
sendChatRender: jest.fn(),
broadcastToMap: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ZulipEventProcessorService,
{
provide: AppLoggerService,
useValue: mockLogger,
},
{
provide: SessionManagerService,
useValue: mockSessionManager,
},
{
provide: 'ZULIP_CONFIG_SERVICE',
useValue: mockConfigManager,
},
{
provide: 'ZULIP_CLIENT_POOL_SERVICE',
useValue: mockClientPool,
},
],
}).compile();
service = module.get<ZulipEventProcessorService>(ZulipEventProcessorService);
service.setMessageDistributor(mockDistributor);
});
afterEach(async () => {
await service.stopEventProcessing();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('convertMessageFormat - 消息格式转换', () => {
it('应该正确转换基本的Zulip消息', async () => {
const zulipMessage = createMockZulipMessage({
sender_full_name: 'Alice',
content: 'Hello everyone!',
});
const result = await service.convertMessageFormat(zulipMessage);
expect(result.t).toBe('chat_render');
expect(result.from).toBe('Alice');
expect(result.txt).toBe('Hello everyone!');
expect(result.bubble).toBe(true);
});
it('应该从邮箱提取用户名当sender_full_name为空时', async () => {
const zulipMessage = createMockZulipMessage({
sender_full_name: '',
sender_email: 'bob@example.com',
content: 'Test message',
});
const result = await service.convertMessageFormat(zulipMessage);
expect(result.from).toBe('bob');
});
it('应该移除Markdown格式', async () => {
const zulipMessage = createMockZulipMessage({
content: '**bold** and *italic* and `code`',
});
const result = await service.convertMessageFormat(zulipMessage);
expect(result.txt).toBe('bold and italic and code');
});
it('应该截断过长的消息', async () => {
const longContent = 'A'.repeat(300);
const zulipMessage = createMockZulipMessage({
content: longContent,
});
const result = await service.convertMessageFormat(zulipMessage);
expect(result.txt.length).toBeLessThanOrEqual(200);
expect(result.txt.endsWith('...')).toBe(true);
});
});
/**
* 属性测试: 消息格式转换完整性
*
* **Feature: zulip-integration, Property 4: 消息格式转换完整性**
* **Validates: Requirements 5.3, 5.4**
*
* 对于任何在Zulip和游戏之间转发的消息转换后的消息应该包含所有必需的信息
* (发送者、内容、时间戳),并符合目标协议格式
*/
describe('Property 4: 消息格式转换完整性', () => {
/**
* 属性: 对于任何有效的Zulip消息转换后应该包含发送者信息
* 验证需求 5.3: 确定目标玩家时系统应将消息转换为游戏协议格式
* 验证需求 5.4: 转换消息格式时系统应包含发送者信息、消息内容和时间戳
*/
it('对于任何有效的Zulip消息转换后应该包含发送者信息', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的发送者全名
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
// 生成有效的发送者邮箱
fc.emailAddress(),
// 生成有效的消息内容
fc.string({ minLength: 1, maxLength: 500 }).filter(s => s.trim().length > 0),
async (senderName, senderEmail, content) => {
const zulipMessage = createMockZulipMessage({
sender_full_name: senderName.trim(),
sender_email: senderEmail,
content: content.trim(),
});
const result = await service.convertMessageFormat(zulipMessage);
// 验证消息类型正确
expect(result.t).toBe('chat_render');
// 验证发送者信息存在且非空
expect(result.from).toBeDefined();
expect(result.from.length).toBeGreaterThan(0);
// 验证发送者名称正确应该是senderName或从邮箱提取
expect(result.from).toBe(senderName.trim());
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何sender_full_name为空的消息应该从邮箱提取用户名
* 验证需求 5.4: 转换消息格式时系统应包含发送者信息
*/
it('对于任何sender_full_name为空的消息应该从邮箱提取用户名', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的邮箱用户名部分
fc.string({ minLength: 1, maxLength: 30 })
.filter(s => s.trim().length > 0 && /^[a-zA-Z0-9._-]+$/.test(s)),
// 生成有效的域名
fc.constantFrom('example.com', 'test.org', 'mail.net'),
// 生成有效的消息内容
fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
async (username, domain, content) => {
const email = `${username}@${domain}`;
const zulipMessage = createMockZulipMessage({
sender_full_name: '', // 空的全名
sender_email: email,
content: content.trim(),
});
const result = await service.convertMessageFormat(zulipMessage);
// 验证从邮箱提取了用户名
expect(result.from).toBe(username);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何消息内容,转换后应该保留核心文本信息
* 验证需求 5.4: 转换消息格式时系统应包含消息内容
*/
it('对于任何消息内容,转换后应该保留核心文本信息', async () => {
await fc.assert(
fc.asyncProperty(
// 生成纯文本消息内容不含Markdown和HTML标记
fc.string({ minLength: 1, maxLength: 150 })
.filter(s => {
const trimmed = s.trim();
// 排除Markdown标记和HTML标记
return trimmed.length > 0 &&
!/[*_`#\[\]<>]/.test(trimmed) &&
!trimmed.startsWith('>') &&
!trimmed.startsWith('-') &&
!trimmed.startsWith('+') &&
!/^\d+\./.test(trimmed);
}),
async (content) => {
const zulipMessage = createMockZulipMessage({
content: content.trim(),
});
const result = await service.convertMessageFormat(zulipMessage);
// 验证消息内容存在
expect(result.txt).toBeDefined();
expect(result.txt.length).toBeGreaterThan(0);
// 验证核心内容被保留(对于短消息应该完全匹配)
if (content.trim().length <= 200) {
expect(result.txt).toBe(content.trim());
}
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何超过200字符的消息应该被截断并添加省略号
* 验证需求 5.4: 转换消息格式时系统应正确处理消息内容
*/
it('对于任何超过200字符的消息应该被截断并添加省略号', async () => {
await fc.assert(
fc.asyncProperty(
// 生成超过200字符的纯字母数字消息内容避免Markdown/HTML标记影响长度
fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '.split('')), { minLength: 250, maxLength: 500 })
.map(arr => arr.join('')),
async (content: string) => {
const zulipMessage = createMockZulipMessage({
content: content,
});
const result = await service.convertMessageFormat(zulipMessage);
// 验证消息被截断
expect(result.txt.length).toBeLessThanOrEqual(200);
// 验证添加了省略号
expect(result.txt.endsWith('...')).toBe(true);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何包含Markdown的消息应该正确移除格式标记
* 验证需求 5.4: 转换消息格式时系统应正确处理消息内容
* 注意: 列表标记(- + *)会被转换为bullet point(•),这是预期行为,不在此测试范围
*/
it('对于任何包含Markdown的消息应该正确移除格式标记', async () => {
await fc.assert(
fc.asyncProperty(
// 生成纯字母数字基础文本(避免特殊字符干扰)
fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split('')), { minLength: 1, maxLength: 50 })
.map(arr => arr.join('')),
// 选择Markdown格式类型仅测试inline格式不测试列表
fc.constantFrom('bold', 'italic', 'code', 'link'),
async (text: string, formatType: string) => {
if (text.length === 0) return; // 跳过空字符串
let markdownContent: string;
switch (formatType) {
case 'bold':
markdownContent = `**${text}**`;
break;
case 'italic':
// 使用下划线斜体避免与列表标记冲突
markdownContent = `_${text}_`;
break;
case 'code':
markdownContent = `\`${text}\``;
break;
case 'link':
markdownContent = `[${text}](https://example.com)`;
break;
default:
markdownContent = text;
}
const zulipMessage = createMockZulipMessage({
content: markdownContent,
});
const result = await service.convertMessageFormat(zulipMessage);
// 验证Markdown标记被移除只保留文本
expect(result.txt).toBe(text);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何消息,转换结果应该符合游戏协议格式
* 验证需求 5.3: 确定目标玩家时系统应将消息转换为游戏协议格式
*/
it('对于任何消息,转换结果应该符合游戏协议格式', async () => {
await fc.assert(
fc.asyncProperty(
// 生成随机的Zulip消息属性
fc.record({
sender_full_name: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
sender_email: fc.emailAddress(),
content: fc.string({ minLength: 1, maxLength: 500 }).filter(s => s.trim().length > 0),
timestamp: fc.integer({ min: 1000000000, max: 2000000000 }),
subject: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
}),
async (props) => {
const zulipMessage = createMockZulipMessage({
sender_full_name: props.sender_full_name.trim(),
sender_email: props.sender_email,
content: props.content.trim(),
timestamp: props.timestamp,
subject: props.subject.trim(),
});
const result = await service.convertMessageFormat(zulipMessage);
// 验证游戏协议格式
expect(result).toHaveProperty('t', 'chat_render');
expect(result).toHaveProperty('from');
expect(result).toHaveProperty('txt');
expect(result).toHaveProperty('bubble');
// 验证类型正确
expect(typeof result.t).toBe('string');
expect(typeof result.from).toBe('string');
expect(typeof result.txt).toBe('string');
expect(typeof result.bubble).toBe('boolean');
// 验证bubble默认为true
expect(result.bubble).toBe(true);
}
),
{ numRuns: 100 }
);
}, 60000);
});
describe('determineTargetPlayers - 确定目标玩家', () => {
it('应该根据Stream名称确定目标地图并获取玩家列表', async () => {
const zulipMessage = createMockZulipMessage({
display_recipient: 'Tavern',
});
mockConfigManager.getMapIdByStream.mockReturnValue('tavern');
mockSessionManager.getSocketsInMap.mockResolvedValue(['socket-1', 'socket-2']);
mockSessionManager.getSession.mockImplementation(async (socketId) => {
if (socketId === 'socket-1') {
return createMockSession({ socketId: 'socket-1', userId: 'user-1' });
}
return createMockSession({ socketId: 'socket-2', userId: 'user-2' });
});
const result = await service.determineTargetPlayers(zulipMessage, 'Tavern', 'sender-user');
expect(mockConfigManager.getMapIdByStream).toHaveBeenCalledWith('Tavern');
expect(mockSessionManager.getSocketsInMap).toHaveBeenCalledWith('tavern');
expect(result).toContain('socket-1');
expect(result).toContain('socket-2');
});
it('应该排除消息发送者', async () => {
const zulipMessage = createMockZulipMessage({
display_recipient: 'Tavern',
});
mockConfigManager.getMapIdByStream.mockReturnValue('tavern');
mockSessionManager.getSocketsInMap.mockResolvedValue(['socket-1', 'socket-2']);
mockSessionManager.getSession.mockImplementation(async (socketId) => {
if (socketId === 'socket-1') {
return createMockSession({ socketId: 'socket-1', userId: 'sender-user' }); // 发送者
}
return createMockSession({ socketId: 'socket-2', userId: 'other-user' });
});
const result = await service.determineTargetPlayers(zulipMessage, 'Tavern', 'sender-user');
// 发送者应该被排除
expect(result).not.toContain('socket-1');
expect(result).toContain('socket-2');
});
it('应该在未找到地图时返回空列表', async () => {
const zulipMessage = createMockZulipMessage({
display_recipient: 'UnknownStream',
});
mockConfigManager.getMapIdByStream.mockReturnValue(null);
const result = await service.determineTargetPlayers(zulipMessage, 'UnknownStream', 'sender-user');
expect(result).toEqual([]);
});
});
describe('distributeMessage - 消息分发', () => {
it('应该向所有目标玩家发送消息', async () => {
const gameMessage: GameMessage = {
t: 'chat_render',
from: 'TestUser',
txt: 'Hello!',
bubble: true,
};
const targetPlayers = ['socket-1', 'socket-2', 'socket-3'];
await service.distributeMessage(gameMessage, targetPlayers);
expect(mockDistributor.sendChatRender).toHaveBeenCalledTimes(3);
expect(mockDistributor.sendChatRender).toHaveBeenCalledWith('socket-1', 'TestUser', 'Hello!', true);
expect(mockDistributor.sendChatRender).toHaveBeenCalledWith('socket-2', 'TestUser', 'Hello!', true);
expect(mockDistributor.sendChatRender).toHaveBeenCalledWith('socket-3', 'TestUser', 'Hello!', true);
});
it('应该在没有目标玩家时不发送任何消息', async () => {
const gameMessage: GameMessage = {
t: 'chat_render',
from: 'TestUser',
txt: 'Hello!',
bubble: true,
};
await service.distributeMessage(gameMessage, []);
expect(mockDistributor.sendChatRender).not.toHaveBeenCalled();
});
});
/**
* 属性测试: 消息接收和分发
*
* **Feature: zulip-integration, Property 5: 消息接收和分发**
* **Validates: Requirements 5.1, 5.2, 5.5**
*
* 对于任何从Zulip接收的消息系统应该正确确定目标玩家转换消息格式
* 并通过WebSocket发送给所有相关的游戏客户端
*/
describe('Property 5: 消息接收和分发', () => {
/**
* 属性: 对于任何有效的Stream消息应该正确确定目标地图
* 验证需求 5.1: Zulip中有新消息时系统应通过事件队列接收消息通知
* 验证需求 5.2: 接收到消息通知时系统应确定哪些在线玩家应该接收该消息
*/
it('对于任何有效的Stream消息应该正确确定目标地图', async () => {
await fc.assert(
fc.asyncProperty(
// 生成有效的Stream名称
fc.constantFrom('Tavern', 'Novice Village', 'Market', 'General'),
// 生成对应的地图ID
fc.constantFrom('tavern', 'novice_village', 'market', 'general'),
// 生成玩家Socket ID列表
fc.array(
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
{ minLength: 0, maxLength: 10 }
),
async (streamName, mapId, socketIds) => {
const zulipMessage = createMockZulipMessage({
display_recipient: streamName,
});
// 设置模拟返回值
mockConfigManager.getMapIdByStream.mockReturnValue(mapId);
mockSessionManager.getSocketsInMap.mockResolvedValue(socketIds);
mockSessionManager.getSession.mockImplementation(async (socketId) => {
return createMockSession({
socketId,
userId: `user_${socketId}`,
currentMap: mapId,
});
});
const result = await service.determineTargetPlayers(
zulipMessage,
streamName,
'different-sender'
);
// 验证调用了正确的方法
expect(mockConfigManager.getMapIdByStream).toHaveBeenCalledWith(streamName);
if (socketIds.length > 0) {
expect(mockSessionManager.getSocketsInMap).toHaveBeenCalledWith(mapId);
}
// 验证返回的Socket ID数量正确所有玩家都不是发送者
expect(result.length).toBe(socketIds.length);
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何消息分发,发送者应该被排除在接收者之外
* 验证需求 5.2: 接收到消息通知时系统应确定哪些在线玩家应该接收该消息
*/
it('对于任何消息分发,发送者应该被排除在接收者之外', async () => {
await fc.assert(
fc.asyncProperty(
// 生成发送者用户ID
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
// 生成其他玩家用户ID列表
fc.array(
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
{ minLength: 1, maxLength: 5 }
),
async (senderUserId, otherUserIds) => {
const zulipMessage = createMockZulipMessage({
display_recipient: 'Tavern',
});
// 创建包含发送者的Socket列表
const allSocketIds = [`socket_${senderUserId}`, ...otherUserIds.map(id => `socket_${id}`)];
mockConfigManager.getMapIdByStream.mockReturnValue('tavern');
mockSessionManager.getSocketsInMap.mockResolvedValue(allSocketIds);
mockSessionManager.getSession.mockImplementation(async (socketId) => {
const userId = socketId.replace('socket_', '');
return createMockSession({
socketId,
userId,
});
});
const result = await service.determineTargetPlayers(
zulipMessage,
'Tavern',
senderUserId
);
// 验证发送者被排除
expect(result).not.toContain(`socket_${senderUserId}`);
// 验证其他玩家都在结果中
for (const userId of otherUserIds) {
expect(result).toContain(`socket_${userId}`);
}
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何消息分发,所有目标玩家都应该收到消息
* 验证需求 5.5: 推送消息到游戏客户端时系统应通过WebSocket发送消息
*/
it('对于任何消息分发,所有目标玩家都应该收到消息', async () => {
await fc.assert(
fc.asyncProperty(
// 生成发送者名称
fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
// 生成消息内容
fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
// 生成目标玩家Socket ID列表
fc.array(
fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
{ minLength: 1, maxLength: 10 }
),
async (from, txt, targetPlayers) => {
const gameMessage: GameMessage = {
t: 'chat_render',
from: from.trim(),
txt: txt.trim(),
bubble: true,
};
// 重置mock
mockDistributor.sendChatRender.mockClear();
await service.distributeMessage(gameMessage, targetPlayers);
// 验证每个目标玩家都收到了消息
expect(mockDistributor.sendChatRender).toHaveBeenCalledTimes(targetPlayers.length);
for (const socketId of targetPlayers) {
expect(mockDistributor.sendChatRender).toHaveBeenCalledWith(
socketId,
from.trim(),
txt.trim(),
true
);
}
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 对于任何未知Stream的消息应该返回空的目标玩家列表
* 验证需求 5.2: 接收到消息通知时系统应确定哪些在线玩家应该接收该消息
*/
it('对于任何未知Stream的消息应该返回空的目标玩家列表', async () => {
await fc.assert(
fc.asyncProperty(
// 生成未知的Stream名称
fc.string({ minLength: 5, maxLength: 50 })
.filter(s => s.trim().length > 0)
.map(s => `Unknown_${s}`),
async (unknownStream) => {
const zulipMessage = createMockZulipMessage({
display_recipient: unknownStream,
});
// 模拟未找到对应地图
mockConfigManager.getMapIdByStream.mockReturnValue(null);
const result = await service.determineTargetPlayers(
zulipMessage,
unknownStream,
'sender-user'
);
// 验证返回空列表
expect(result).toEqual([]);
// 验证没有尝试获取玩家列表
expect(mockSessionManager.getSocketsInMap).not.toHaveBeenCalled();
}
),
{ numRuns: 100 }
);
}, 60000);
/**
* 属性: 完整的消息处理流程应该正确执行
* 验证需求 5.1, 5.2, 5.5: 完整的消息接收和分发流程
*/
it('完整的消息处理流程应该正确执行', async () => {
await fc.assert(
fc.asyncProperty(
// 生成发送者信息
fc.record({
senderName: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0),
senderEmail: fc.emailAddress(),
senderUserId: fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0),
}),
// 生成消息内容
fc.string({ minLength: 1, maxLength: 150 }).filter(s => s.trim().length > 0),
// 生成Stream名称
fc.constantFrom('Tavern', 'Novice Village'),
// 生成目标玩家数量
fc.integer({ min: 1, max: 5 }),
async (sender, content, streamName, playerCount) => {
const zulipMessage = createMockZulipMessage({
sender_full_name: sender.senderName.trim(),
sender_email: sender.senderEmail,
content: content.trim(),
display_recipient: streamName,
});
// 生成目标玩家
const targetSocketIds = Array.from(
{ length: playerCount },
(_, i) => `socket_player_${i}`
);
const mapId = streamName.toLowerCase().replace(' ', '_');
mockConfigManager.getMapIdByStream.mockReturnValue(mapId);
mockSessionManager.getSocketsInMap.mockResolvedValue(targetSocketIds);
mockSessionManager.getSession.mockImplementation(async (socketId) => {
return createMockSession({
socketId,
userId: socketId.replace('socket_', 'user_'),
});
});
// 重置mock
mockDistributor.sendChatRender.mockClear();
// 执行完整的消息处理
const result = await service.processMessageManually(zulipMessage, sender.senderUserId);
// 验证处理成功
expect(result.success).toBe(true);
expect(result.targetCount).toBe(playerCount);
// 验证消息被分发给所有目标玩家
expect(mockDistributor.sendChatRender).toHaveBeenCalledTimes(playerCount);
}
),
{ numRuns: 100 }
);
}, 60000);
});
describe('getProcessingStats - 获取处理统计', () => {
it('应该返回正确的统计信息', () => {
const stats = service.getProcessingStats();
expect(stats).toHaveProperty('isActive');
expect(stats).toHaveProperty('activeQueues');
expect(stats).toHaveProperty('totalQueues');
expect(stats).toHaveProperty('queueIds');
expect(stats).toHaveProperty('processedEvents');
expect(stats).toHaveProperty('processedMessages');
});
});
describe('registerEventQueue / unregisterEventQueue - 队列管理', () => {
it('应该正确注册和注销事件队列', async () => {
const queueId = 'test-queue-123';
const userId = 'user-456';
// 注册队列
await service.registerEventQueue(queueId, userId, 0);
let stats = service.getProcessingStats();
expect(stats.queueIds).toContain(queueId);
expect(stats.totalQueues).toBe(1);
// 注销队列
await service.unregisterEventQueue(queueId);
stats = service.getProcessingStats();
expect(stats.queueIds).not.toContain(queueId);
expect(stats.totalQueues).toBe(0);
});
});
});