- 统一文件命名为snake_case格式(kebab-case snake_case) - 重构zulip模块为zulip_core,明确Core层职责 - 重构user-mgmt模块为user_mgmt,统一命名规范 - 调整模块依赖关系,优化架构分层 - 删除过时的文件和目录结构 - 更新相关文档和配置文件 本次重构涉及大量文件重命名和模块重组, 旨在建立更清晰的项目架构和统一的命名规范。
830 lines
30 KiB
TypeScript
830 lines
30 KiB
TypeScript
/**
|
||
* 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_core/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);
|
||
});
|
||
});
|
||
});
|