feat(framework): server testing utils, transaction storage simplify, pathfinding tests (#384)

## Server Testing Utils
- Add TestServer, TestClient, MockRoom for unit testing
- Export testing utilities from @esengine/server/testing

## Transaction Storage (BREAKING)
- Simplify RedisStorage/MongoStorage to factory pattern only
- Remove DI client injection option
- Add lazy connection and Symbol.asyncDispose support
- Add 161 unit tests with full coverage

## Pathfinding Tests
- Add 150 unit tests covering all components
- BinaryHeap, Heuristics, AStarPathfinder, GridMap, NavMesh, PathSmoother

## Docs
- Update storage.md for new factory pattern API
This commit is contained in:
YHH
2025-12-29 15:02:13 +08:00
committed by GitHub
parent 10c3891abd
commit 3b978384c7
50 changed files with 7591 additions and 660 deletions

View File

@@ -0,0 +1,480 @@
/**
* @zh CurrencyOperation 单元测试
* @en CurrencyOperation unit tests
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { CurrencyOperation, createCurrencyOperation, type ICurrencyProvider } from '../../src/operations/CurrencyOperation.js'
import { TransactionContext } from '../../src/core/TransactionContext.js'
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
import type { ITransactionContext } from '../../src/core/types.js'
// ============================================================================
// Mock Provider | 模拟数据提供者
// ============================================================================
class MockCurrencyProvider implements ICurrencyProvider {
private _balances: Map<string, Map<string, number>> = new Map()
setInitialBalance(playerId: string, currency: string, amount: number): void {
if (!this._balances.has(playerId)) {
this._balances.set(playerId, new Map())
}
this._balances.get(playerId)!.set(currency, amount)
}
async getBalance(playerId: string, currency: string): Promise<number> {
return this._balances.get(playerId)?.get(currency) ?? 0
}
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
if (!this._balances.has(playerId)) {
this._balances.set(playerId, new Map())
}
this._balances.get(playerId)!.set(currency, amount)
}
}
// ============================================================================
// Test Suite | 测试套件
// ============================================================================
describe('CurrencyOperation', () => {
let storage: MemoryStorage
let ctx: ITransactionContext
beforeEach(() => {
storage = new MemoryStorage()
ctx = new TransactionContext({ storage })
})
// ========================================================================
// 构造器测试 | Constructor Tests
// ========================================================================
describe('Constructor', () => {
it('should create with data', () => {
const op = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
expect(op.name).toBe('currency')
expect(op.data.type).toBe('add')
expect(op.data.playerId).toBe('player-1')
expect(op.data.currency).toBe('gold')
expect(op.data.amount).toBe(100)
})
it('should use createCurrencyOperation factory', () => {
const op = createCurrencyOperation({
type: 'deduct',
playerId: 'player-2',
currency: 'diamond',
amount: 50,
})
expect(op).toBeInstanceOf(CurrencyOperation)
expect(op.data.type).toBe('deduct')
})
})
// ========================================================================
// 验证测试 | Validation Tests
// ========================================================================
describe('validate()', () => {
it('should fail validation with zero amount', async () => {
const op = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'gold',
amount: 0,
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(false)
})
it('should fail validation with negative amount', async () => {
const op = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'gold',
amount: -10,
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(false)
})
it('should pass validation for add with positive amount', async () => {
const op = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(true)
})
it('should fail validation for deduct with insufficient balance', async () => {
await storage.set('player:player-1:currency:gold', 50)
const op = new CurrencyOperation({
type: 'deduct',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(false)
})
it('should pass validation for deduct with sufficient balance', async () => {
await storage.set('player:player-1:currency:gold', 150)
const op = new CurrencyOperation({
type: 'deduct',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(true)
})
it('should use provider for validation', async () => {
const provider = new MockCurrencyProvider()
provider.setInitialBalance('player-1', 'gold', 200)
const op = new CurrencyOperation({
type: 'deduct',
playerId: 'player-1',
currency: 'gold',
amount: 150,
}).setProvider(provider)
const isValid = await op.validate(ctx)
expect(isValid).toBe(true)
})
})
// ========================================================================
// 执行测试 - 增加货币 | Execute Tests - Add Currency
// ========================================================================
describe('execute() - add', () => {
it('should add currency to empty balance', async () => {
const op = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.beforeBalance).toBe(0)
expect(result.data?.afterBalance).toBe(100)
const balance = await storage.get<number>('player:player-1:currency:gold')
expect(balance).toBe(100)
})
it('should add currency to existing balance', async () => {
await storage.set('player:player-1:currency:gold', 50)
const op = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.beforeBalance).toBe(50)
expect(result.data?.afterBalance).toBe(150)
})
it('should store context data for verification', async () => {
const op = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
await op.execute(ctx)
expect(ctx.get('currency:player-1:gold:before')).toBe(0)
expect(ctx.get('currency:player-1:gold:after')).toBe(100)
})
})
// ========================================================================
// 执行测试 - 扣除货币 | Execute Tests - Deduct Currency
// ========================================================================
describe('execute() - deduct', () => {
it('should deduct currency from balance', async () => {
await storage.set('player:player-1:currency:gold', 200)
const op = new CurrencyOperation({
type: 'deduct',
playerId: 'player-1',
currency: 'gold',
amount: 75,
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.beforeBalance).toBe(200)
expect(result.data?.afterBalance).toBe(125)
const balance = await storage.get<number>('player:player-1:currency:gold')
expect(balance).toBe(125)
})
it('should fail deduct with insufficient balance', async () => {
await storage.set('player:player-1:currency:gold', 50)
const op = new CurrencyOperation({
type: 'deduct',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
const result = await op.execute(ctx)
expect(result.success).toBe(false)
expect(result.error).toBe('Insufficient balance')
expect(result.errorCode).toBe('INSUFFICIENT_BALANCE')
})
it('should deduct exact balance', async () => {
await storage.set('player:player-1:currency:gold', 100)
const op = new CurrencyOperation({
type: 'deduct',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.afterBalance).toBe(0)
})
})
// ========================================================================
// 补偿测试 | Compensate Tests
// ========================================================================
describe('compensate()', () => {
it('should restore balance after add', async () => {
const op = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
await op.execute(ctx)
expect(await storage.get('player:player-1:currency:gold')).toBe(100)
await op.compensate(ctx)
expect(await storage.get('player:player-1:currency:gold')).toBe(0)
})
it('should restore balance after deduct', async () => {
await storage.set('player:player-1:currency:gold', 200)
const op = new CurrencyOperation({
type: 'deduct',
playerId: 'player-1',
currency: 'gold',
amount: 75,
})
await op.execute(ctx)
expect(await storage.get('player:player-1:currency:gold')).toBe(125)
await op.compensate(ctx)
expect(await storage.get('player:player-1:currency:gold')).toBe(200)
})
})
// ========================================================================
// Provider 测试 | Provider Tests
// ========================================================================
describe('Provider', () => {
it('should use provider for operations', async () => {
const provider = new MockCurrencyProvider()
provider.setInitialBalance('player-1', 'gold', 1000)
const op = new CurrencyOperation({
type: 'deduct',
playerId: 'player-1',
currency: 'gold',
amount: 300,
}).setProvider(provider)
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.beforeBalance).toBe(1000)
expect(result.data?.afterBalance).toBe(700)
const newBalance = await provider.getBalance('player-1', 'gold')
expect(newBalance).toBe(700)
})
it('should compensate using provider', async () => {
const provider = new MockCurrencyProvider()
provider.setInitialBalance('player-1', 'gold', 500)
const op = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'gold',
amount: 200,
}).setProvider(provider)
await op.execute(ctx)
expect(await provider.getBalance('player-1', 'gold')).toBe(700)
await op.compensate(ctx)
expect(await provider.getBalance('player-1', 'gold')).toBe(500)
})
it('should support method chaining with setProvider', () => {
const provider = new MockCurrencyProvider()
const op = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
const result = op.setProvider(provider)
expect(result).toBe(op)
})
})
// ========================================================================
// 多货币类型测试 | Multiple Currency Types Tests
// ========================================================================
describe('Multiple Currency Types', () => {
it('should handle different currency types independently', async () => {
await storage.set('player:player-1:currency:gold', 1000)
await storage.set('player:player-1:currency:diamond', 50)
const goldOp = new CurrencyOperation({
type: 'deduct',
playerId: 'player-1',
currency: 'gold',
amount: 500,
})
const diamondOp = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'diamond',
amount: 10,
})
await goldOp.execute(ctx)
await diamondOp.execute(ctx)
expect(await storage.get('player:player-1:currency:gold')).toBe(500)
expect(await storage.get('player:player-1:currency:diamond')).toBe(60)
})
it('should handle multiple players independently', async () => {
await storage.set('player:player-1:currency:gold', 1000)
await storage.set('player:player-2:currency:gold', 500)
const op1 = new CurrencyOperation({
type: 'deduct',
playerId: 'player-1',
currency: 'gold',
amount: 300,
})
const op2 = new CurrencyOperation({
type: 'add',
playerId: 'player-2',
currency: 'gold',
amount: 300,
})
await op1.execute(ctx)
await op2.execute(ctx)
expect(await storage.get('player:player-1:currency:gold')).toBe(700)
expect(await storage.get('player:player-2:currency:gold')).toBe(800)
})
})
// ========================================================================
// 边界情况测试 | Edge Cases
// ========================================================================
describe('Edge Cases', () => {
it('should handle zero balance for new player', async () => {
const op = new CurrencyOperation({
type: 'add',
playerId: 'new-player',
currency: 'gold',
amount: 100,
})
const result = await op.execute(ctx)
expect(result.data?.beforeBalance).toBe(0)
})
it('should handle context without storage', async () => {
const noStorageCtx = new TransactionContext()
const op = new CurrencyOperation({
type: 'add',
playerId: 'player-1',
currency: 'gold',
amount: 100,
})
const result = await op.execute(noStorageCtx)
expect(result.success).toBe(true)
expect(result.data?.beforeBalance).toBe(0)
expect(result.data?.afterBalance).toBe(100)
})
it('should include reason in data', () => {
const op = new CurrencyOperation({
type: 'deduct',
playerId: 'player-1',
currency: 'gold',
amount: 100,
reason: 'purchase_item',
})
expect(op.data.reason).toBe('purchase_item')
})
})
})

View File

@@ -0,0 +1,624 @@
/**
* @zh InventoryOperation 单元测试
* @en InventoryOperation unit tests
*/
import { describe, it, expect, beforeEach } from 'vitest'
import {
InventoryOperation,
createInventoryOperation,
type IInventoryProvider,
type ItemData,
} from '../../src/operations/InventoryOperation.js'
import { TransactionContext } from '../../src/core/TransactionContext.js'
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
import type { ITransactionContext } from '../../src/core/types.js'
// ============================================================================
// Mock Provider | 模拟数据提供者
// ============================================================================
class MockInventoryProvider implements IInventoryProvider {
private _inventory: Map<string, Map<string, ItemData>> = new Map()
private _capacity: Map<string, number> = new Map()
addItem(playerId: string, itemId: string, item: ItemData): void {
if (!this._inventory.has(playerId)) {
this._inventory.set(playerId, new Map())
}
this._inventory.get(playerId)!.set(itemId, item)
}
setCapacity(playerId: string, capacity: number): void {
this._capacity.set(playerId, capacity)
}
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
return this._inventory.get(playerId)?.get(itemId) ?? null
}
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
if (!this._inventory.has(playerId)) {
this._inventory.set(playerId, new Map())
}
if (item) {
this._inventory.get(playerId)!.set(itemId, item)
} else {
this._inventory.get(playerId)!.delete(itemId)
}
}
async hasCapacity(playerId: string, count: number): Promise<boolean> {
const capacity = this._capacity.get(playerId)
if (capacity === undefined) return true
const currentCount = this._inventory.get(playerId)?.size ?? 0
return currentCount + count <= capacity
}
}
// ============================================================================
// Test Suite | 测试套件
// ============================================================================
describe('InventoryOperation', () => {
let storage: MemoryStorage
let ctx: ITransactionContext
beforeEach(() => {
storage = new MemoryStorage()
ctx = new TransactionContext({ storage })
})
// ========================================================================
// 构造器测试 | Constructor Tests
// ========================================================================
describe('Constructor', () => {
it('should create with data', () => {
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'sword-001',
quantity: 1,
})
expect(op.name).toBe('inventory')
expect(op.data.type).toBe('add')
expect(op.data.playerId).toBe('player-1')
expect(op.data.itemId).toBe('sword-001')
expect(op.data.quantity).toBe(1)
})
it('should use createInventoryOperation factory', () => {
const op = createInventoryOperation({
type: 'remove',
playerId: 'player-2',
itemId: 'potion-hp',
quantity: 5,
})
expect(op).toBeInstanceOf(InventoryOperation)
expect(op.data.type).toBe('remove')
})
})
// ========================================================================
// 验证测试 | Validation Tests
// ========================================================================
describe('validate()', () => {
it('should fail validation with zero quantity', async () => {
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'sword',
quantity: 0,
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(false)
})
it('should fail validation with negative quantity', async () => {
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'sword',
quantity: -1,
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(false)
})
it('should pass validation for add with positive quantity', async () => {
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'sword',
quantity: 1,
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(true)
})
it('should fail validation for remove with insufficient quantity', async () => {
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 2,
})
const op = new InventoryOperation({
type: 'remove',
playerId: 'player-1',
itemId: 'sword',
quantity: 5,
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(false)
})
it('should fail validation for remove with non-existent item', async () => {
const op = new InventoryOperation({
type: 'remove',
playerId: 'player-1',
itemId: 'nonexistent',
quantity: 1,
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(false)
})
it('should pass validation for remove with sufficient quantity', async () => {
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 10,
})
const op = new InventoryOperation({
type: 'remove',
playerId: 'player-1',
itemId: 'sword',
quantity: 5,
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(true)
})
it('should check capacity with provider', async () => {
const provider = new MockInventoryProvider()
provider.setCapacity('player-1', 1)
provider.addItem('player-1', 'existing', { itemId: 'existing', quantity: 1 })
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'new-item',
quantity: 1,
}).setProvider(provider)
const isValid = await op.validate(ctx)
expect(isValid).toBe(false)
})
})
// ========================================================================
// 执行测试 - 添加物品 | Execute Tests - Add Item
// ========================================================================
describe('execute() - add', () => {
it('should add new item to empty inventory', async () => {
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'sword',
quantity: 1,
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.beforeItem).toBeUndefined()
expect(result.data?.afterItem).toEqual({
itemId: 'sword',
quantity: 1,
properties: undefined,
})
const item = await storage.get<ItemData>('player:player-1:inventory:sword')
expect(item?.quantity).toBe(1)
})
it('should stack items when adding to existing', async () => {
await storage.set('player:player-1:inventory:potion', {
itemId: 'potion',
quantity: 5,
})
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'potion',
quantity: 3,
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.beforeItem?.quantity).toBe(5)
expect(result.data?.afterItem?.quantity).toBe(8)
})
it('should add item with properties', async () => {
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'enchanted-sword',
quantity: 1,
properties: { damage: 100, enchant: 'fire' },
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.afterItem?.properties).toEqual({
damage: 100,
enchant: 'fire',
})
})
it('should store context data', async () => {
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'sword',
quantity: 1,
})
await op.execute(ctx)
expect(ctx.get('inventory:player-1:sword:before')).toBeNull()
expect(ctx.get('inventory:player-1:sword:after')).toEqual({
itemId: 'sword',
quantity: 1,
properties: undefined,
})
})
})
// ========================================================================
// 执行测试 - 移除物品 | Execute Tests - Remove Item
// ========================================================================
describe('execute() - remove', () => {
it('should remove partial quantity', async () => {
await storage.set('player:player-1:inventory:potion', {
itemId: 'potion',
quantity: 10,
})
const op = new InventoryOperation({
type: 'remove',
playerId: 'player-1',
itemId: 'potion',
quantity: 3,
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.beforeItem?.quantity).toBe(10)
expect(result.data?.afterItem?.quantity).toBe(7)
const item = await storage.get<ItemData>('player:player-1:inventory:potion')
expect(item?.quantity).toBe(7)
})
it('should delete item when removing all quantity', async () => {
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 1,
})
const op = new InventoryOperation({
type: 'remove',
playerId: 'player-1',
itemId: 'sword',
quantity: 1,
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.afterItem).toBeUndefined()
const item = await storage.get('player:player-1:inventory:sword')
expect(item).toBeNull()
})
it('should fail when item not found', async () => {
const op = new InventoryOperation({
type: 'remove',
playerId: 'player-1',
itemId: 'nonexistent',
quantity: 1,
})
const result = await op.execute(ctx)
expect(result.success).toBe(false)
expect(result.error).toBe('Insufficient item quantity')
expect(result.errorCode).toBe('INSUFFICIENT_ITEM')
})
it('should fail when quantity insufficient', async () => {
await storage.set('player:player-1:inventory:potion', {
itemId: 'potion',
quantity: 2,
})
const op = new InventoryOperation({
type: 'remove',
playerId: 'player-1',
itemId: 'potion',
quantity: 5,
})
const result = await op.execute(ctx)
expect(result.success).toBe(false)
expect(result.errorCode).toBe('INSUFFICIENT_ITEM')
})
})
// ========================================================================
// 执行测试 - 更新物品 | Execute Tests - Update Item
// ========================================================================
describe('execute() - update', () => {
it('should update item properties', async () => {
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 1,
properties: { damage: 10 },
})
const op = new InventoryOperation({
type: 'update',
playerId: 'player-1',
itemId: 'sword',
quantity: 0, // keep existing
properties: { damage: 20, enchant: 'ice' },
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.afterItem?.properties).toEqual({
damage: 20,
enchant: 'ice',
})
expect(result.data?.afterItem?.quantity).toBe(1)
})
it('should update item quantity', async () => {
await storage.set('player:player-1:inventory:potion', {
itemId: 'potion',
quantity: 5,
})
const op = new InventoryOperation({
type: 'update',
playerId: 'player-1',
itemId: 'potion',
quantity: 10,
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.afterItem?.quantity).toBe(10)
})
it('should fail when updating non-existent item', async () => {
const op = new InventoryOperation({
type: 'update',
playerId: 'player-1',
itemId: 'nonexistent',
quantity: 1,
})
const result = await op.execute(ctx)
expect(result.success).toBe(false)
expect(result.error).toBe('Item not found')
expect(result.errorCode).toBe('ITEM_NOT_FOUND')
})
})
// ========================================================================
// 补偿测试 | Compensate Tests
// ========================================================================
describe('compensate()', () => {
it('should restore state after add', async () => {
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'sword',
quantity: 1,
})
await op.execute(ctx)
expect(await storage.get('player:player-1:inventory:sword')).not.toBeNull()
await op.compensate(ctx)
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
})
it('should restore state after remove', async () => {
await storage.set('player:player-1:inventory:potion', {
itemId: 'potion',
quantity: 5,
})
const op = new InventoryOperation({
type: 'remove',
playerId: 'player-1',
itemId: 'potion',
quantity: 3,
})
await op.execute(ctx)
const afterRemove = await storage.get<ItemData>('player:player-1:inventory:potion')
expect(afterRemove?.quantity).toBe(2)
await op.compensate(ctx)
const afterCompensate = await storage.get<ItemData>('player:player-1:inventory:potion')
expect(afterCompensate?.quantity).toBe(5)
})
it('should restore deleted item after remove all', async () => {
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 1,
})
const op = new InventoryOperation({
type: 'remove',
playerId: 'player-1',
itemId: 'sword',
quantity: 1,
})
await op.execute(ctx)
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
await op.compensate(ctx)
const restored = await storage.get<ItemData>('player:player-1:inventory:sword')
expect(restored?.quantity).toBe(1)
})
it('should restore state after update', async () => {
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 1,
properties: { damage: 10 },
})
const op = new InventoryOperation({
type: 'update',
playerId: 'player-1',
itemId: 'sword',
quantity: 0,
properties: { damage: 50 },
})
await op.execute(ctx)
await op.compensate(ctx)
const restored = await storage.get<ItemData>('player:player-1:inventory:sword')
expect(restored?.properties).toEqual({ damage: 10 })
})
})
// ========================================================================
// Provider 测试 | Provider Tests
// ========================================================================
describe('Provider', () => {
it('should use provider for operations', async () => {
const provider = new MockInventoryProvider()
provider.addItem('player-1', 'sword', { itemId: 'sword', quantity: 1 })
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'sword',
quantity: 2,
}).setProvider(provider)
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.afterItem?.quantity).toBe(3)
const item = await provider.getItem('player-1', 'sword')
expect(item?.quantity).toBe(3)
})
it('should compensate using provider', async () => {
const provider = new MockInventoryProvider()
provider.addItem('player-1', 'potion', { itemId: 'potion', quantity: 10 })
const op = new InventoryOperation({
type: 'remove',
playerId: 'player-1',
itemId: 'potion',
quantity: 3,
}).setProvider(provider)
await op.execute(ctx)
expect((await provider.getItem('player-1', 'potion'))?.quantity).toBe(7)
await op.compensate(ctx)
expect((await provider.getItem('player-1', 'potion'))?.quantity).toBe(10)
})
})
// ========================================================================
// 边界情况测试 | Edge Cases
// ========================================================================
describe('Edge Cases', () => {
it('should handle context without storage', async () => {
const noStorageCtx = new TransactionContext()
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'sword',
quantity: 1,
})
const result = await op.execute(noStorageCtx)
expect(result.success).toBe(true)
})
it('should include reason in data', () => {
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'reward-sword',
quantity: 1,
reason: 'quest_reward',
})
expect(op.data.reason).toBe('quest_reward')
})
it('should preserve item properties when stacking', async () => {
await storage.set('player:player-1:inventory:potion', {
itemId: 'potion',
quantity: 5,
properties: { quality: 'rare' },
})
const op = new InventoryOperation({
type: 'add',
playerId: 'player-1',
itemId: 'potion',
quantity: 3,
})
const result = await op.execute(ctx)
expect(result.data?.afterItem?.properties).toEqual({ quality: 'rare' })
expect(result.data?.afterItem?.quantity).toBe(8)
})
})
})

View File

@@ -0,0 +1,524 @@
/**
* @zh TradeOperation 单元测试
* @en TradeOperation unit tests
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { TradeOperation, createTradeOperation, type ITradeProvider } from '../../src/operations/TradeOperation.js'
import { TransactionContext } from '../../src/core/TransactionContext.js'
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
import type { ITransactionContext } from '../../src/core/types.js'
import type { ICurrencyProvider } from '../../src/operations/CurrencyOperation.js'
import type { IInventoryProvider, ItemData } from '../../src/operations/InventoryOperation.js'
// ============================================================================
// Mock Providers | 模拟数据提供者
// ============================================================================
class MockCurrencyProvider implements ICurrencyProvider {
private _balances: Map<string, Map<string, number>> = new Map()
initBalance(playerId: string, currency: string, amount: number): void {
if (!this._balances.has(playerId)) {
this._balances.set(playerId, new Map())
}
this._balances.get(playerId)!.set(currency, amount)
}
async getBalance(playerId: string, currency: string): Promise<number> {
return this._balances.get(playerId)?.get(currency) ?? 0
}
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
if (!this._balances.has(playerId)) {
this._balances.set(playerId, new Map())
}
this._balances.get(playerId)!.set(currency, amount)
}
}
class MockInventoryProvider implements IInventoryProvider {
private _inventory: Map<string, Map<string, ItemData>> = new Map()
addItem(playerId: string, itemId: string, item: ItemData): void {
if (!this._inventory.has(playerId)) {
this._inventory.set(playerId, new Map())
}
this._inventory.get(playerId)!.set(itemId, item)
}
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
return this._inventory.get(playerId)?.get(itemId) ?? null
}
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
if (!this._inventory.has(playerId)) {
this._inventory.set(playerId, new Map())
}
if (item) {
this._inventory.get(playerId)!.set(itemId, item)
} else {
this._inventory.get(playerId)!.delete(itemId)
}
}
}
// ============================================================================
// Test Suite | 测试套件
// ============================================================================
describe('TradeOperation', () => {
let storage: MemoryStorage
let ctx: ITransactionContext
beforeEach(() => {
storage = new MemoryStorage()
ctx = new TransactionContext({ storage })
})
// ========================================================================
// 构造器测试 | Constructor Tests
// ========================================================================
describe('Constructor', () => {
it('should create with data', () => {
const op = new TradeOperation({
tradeId: 'trade-001',
partyA: {
playerId: 'player-1',
items: [{ itemId: 'sword', quantity: 1 }],
},
partyB: {
playerId: 'player-2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
})
expect(op.name).toBe('trade')
expect(op.data.tradeId).toBe('trade-001')
expect(op.data.partyA.playerId).toBe('player-1')
expect(op.data.partyB.playerId).toBe('player-2')
})
it('should use createTradeOperation factory', () => {
const op = createTradeOperation({
tradeId: 'trade-002',
partyA: { playerId: 'player-1' },
partyB: { playerId: 'player-2' },
})
expect(op).toBeInstanceOf(TradeOperation)
})
})
// ========================================================================
// 验证测试 | Validation Tests
// ========================================================================
describe('validate()', () => {
it('should validate item trade', async () => {
// Player 1 has sword, Player 2 has gold
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 1,
})
await storage.set('player:player-2:currency:gold', 1000)
const op = new TradeOperation({
tradeId: 'trade-001',
partyA: {
playerId: 'player-1',
items: [{ itemId: 'sword', quantity: 1 }],
},
partyB: {
playerId: 'player-2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(true)
})
it('should fail validation when party A lacks items', async () => {
await storage.set('player:player-2:currency:gold', 1000)
const op = new TradeOperation({
tradeId: 'trade-001',
partyA: {
playerId: 'player-1',
items: [{ itemId: 'sword', quantity: 1 }],
},
partyB: {
playerId: 'player-2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(false)
})
it('should fail validation when party B lacks currency', async () => {
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 1,
})
await storage.set('player:player-2:currency:gold', 500) // Not enough
const op = new TradeOperation({
tradeId: 'trade-001',
partyA: {
playerId: 'player-1',
items: [{ itemId: 'sword', quantity: 1 }],
},
partyB: {
playerId: 'player-2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
})
const isValid = await op.validate(ctx)
expect(isValid).toBe(false)
})
})
// ========================================================================
// 执行测试 - 物品换货币 | Execute Tests - Item for Currency
// ========================================================================
describe('execute() - item for currency', () => {
it('should trade item for currency', async () => {
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 1,
})
await storage.set('player:player-2:currency:gold', 1000)
const op = new TradeOperation({
tradeId: 'trade-001',
partyA: {
playerId: 'player-1',
items: [{ itemId: 'sword', quantity: 1 }],
},
partyB: {
playerId: 'player-2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.tradeId).toBe('trade-001')
expect(result.data?.completed).toBe(true)
// Player 1: no sword, got gold
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
expect(await storage.get('player:player-1:currency:gold')).toBe(1000)
// Player 2: got sword, no gold
const p2Sword = await storage.get<ItemData>('player:player-2:inventory:sword')
expect(p2Sword?.quantity).toBe(1)
expect(await storage.get('player:player-2:currency:gold')).toBe(0)
})
})
// ========================================================================
// 执行测试 - 物品换物品 | Execute Tests - Item for Item
// ========================================================================
describe('execute() - item for item', () => {
it('should trade items between players', async () => {
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 1,
})
await storage.set('player:player-2:inventory:shield', {
itemId: 'shield',
quantity: 1,
})
const op = new TradeOperation({
tradeId: 'trade-002',
partyA: {
playerId: 'player-1',
items: [{ itemId: 'sword', quantity: 1 }],
},
partyB: {
playerId: 'player-2',
items: [{ itemId: 'shield', quantity: 1 }],
},
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
// Player 1: got shield, no sword
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
const p1Shield = await storage.get<ItemData>('player:player-1:inventory:shield')
expect(p1Shield?.quantity).toBe(1)
// Player 2: got sword, no shield
expect(await storage.get('player:player-2:inventory:shield')).toBeNull()
const p2Sword = await storage.get<ItemData>('player:player-2:inventory:sword')
expect(p2Sword?.quantity).toBe(1)
})
})
// ========================================================================
// 执行测试 - 货币换货币 | Execute Tests - Currency for Currency
// ========================================================================
describe('execute() - currency for currency', () => {
it('should trade currencies between players', async () => {
await storage.set('player:player-1:currency:gold', 1000)
await storage.set('player:player-2:currency:diamond', 100)
const op = new TradeOperation({
tradeId: 'trade-003',
partyA: {
playerId: 'player-1',
currencies: [{ currency: 'gold', amount: 1000 }],
},
partyB: {
playerId: 'player-2',
currencies: [{ currency: 'diamond', amount: 100 }],
},
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
// Player 1: no gold, got diamonds
expect(await storage.get('player:player-1:currency:gold')).toBe(0)
expect(await storage.get('player:player-1:currency:diamond')).toBe(100)
// Player 2: got gold, no diamonds
expect(await storage.get('player:player-2:currency:gold')).toBe(1000)
expect(await storage.get('player:player-2:currency:diamond')).toBe(0)
})
})
// ========================================================================
// 执行测试 - 复杂交易 | Execute Tests - Complex Trade
// ========================================================================
describe('execute() - complex trade', () => {
it('should handle multiple items and currencies', async () => {
// Setup
await storage.set('player:player-1:inventory:sword', { itemId: 'sword', quantity: 2 })
await storage.set('player:player-1:inventory:potion', { itemId: 'potion', quantity: 10 })
await storage.set('player:player-2:currency:gold', 5000)
await storage.set('player:player-2:currency:diamond', 50)
const op = new TradeOperation({
tradeId: 'trade-004',
partyA: {
playerId: 'player-1',
items: [
{ itemId: 'sword', quantity: 1 },
{ itemId: 'potion', quantity: 5 },
],
},
partyB: {
playerId: 'player-2',
currencies: [
{ currency: 'gold', amount: 2000 },
{ currency: 'diamond', amount: 20 },
],
},
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
// Player 1
const p1Sword = await storage.get<ItemData>('player:player-1:inventory:sword')
expect(p1Sword?.quantity).toBe(1) // Had 2, gave 1
const p1Potion = await storage.get<ItemData>('player:player-1:inventory:potion')
expect(p1Potion?.quantity).toBe(5) // Had 10, gave 5
expect(await storage.get('player:player-1:currency:gold')).toBe(2000)
expect(await storage.get('player:player-1:currency:diamond')).toBe(20)
// Player 2
const p2Sword = await storage.get<ItemData>('player:player-2:inventory:sword')
expect(p2Sword?.quantity).toBe(1)
const p2Potion = await storage.get<ItemData>('player:player-2:inventory:potion')
expect(p2Potion?.quantity).toBe(5)
expect(await storage.get('player:player-2:currency:gold')).toBe(3000) // Had 5000, gave 2000
expect(await storage.get('player:player-2:currency:diamond')).toBe(30) // Had 50, gave 20
})
})
// ========================================================================
// 失败和补偿测试 | Failure and Compensation Tests
// ========================================================================
describe('failure and compensation', () => {
it('should rollback on partial failure', async () => {
// Player 1 has sword, Player 2 does NOT have enough gold
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 1,
})
await storage.set('player:player-2:currency:gold', 500) // Not enough
const op = new TradeOperation({
tradeId: 'trade-fail',
partyA: {
playerId: 'player-1',
items: [{ itemId: 'sword', quantity: 1 }],
},
partyB: {
playerId: 'player-2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
})
const result = await op.execute(ctx)
expect(result.success).toBe(false)
expect(result.errorCode).toBe('TRADE_FAILED')
// Everything should be restored
const p1Sword = await storage.get<ItemData>('player:player-1:inventory:sword')
expect(p1Sword?.quantity).toBe(1) // Restored
expect(await storage.get('player:player-2:inventory:sword')).toBeNull()
})
it('should compensate after successful execute', async () => {
await storage.set('player:player-1:inventory:sword', {
itemId: 'sword',
quantity: 1,
})
await storage.set('player:player-2:currency:gold', 1000)
const op = new TradeOperation({
tradeId: 'trade-compensate',
partyA: {
playerId: 'player-1',
items: [{ itemId: 'sword', quantity: 1 }],
},
partyB: {
playerId: 'player-2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
})
await op.execute(ctx)
// Verify trade happened
expect(await storage.get('player:player-1:inventory:sword')).toBeNull()
expect(await storage.get('player:player-1:currency:gold')).toBe(1000)
// Compensate
await op.compensate(ctx)
// Everything should be restored
const p1Sword = await storage.get<ItemData>('player:player-1:inventory:sword')
expect(p1Sword?.quantity).toBe(1)
expect(await storage.get('player:player-1:currency:gold')).toBe(0)
expect(await storage.get('player:player-2:inventory:sword')).toBeNull()
expect(await storage.get('player:player-2:currency:gold')).toBe(1000)
})
})
// ========================================================================
// Provider 测试 | Provider Tests
// ========================================================================
describe('Provider', () => {
it('should use providers for trade', async () => {
const currencyProvider = new MockCurrencyProvider()
const inventoryProvider = new MockInventoryProvider()
currencyProvider.initBalance('player-1', 'gold', 0)
currencyProvider.initBalance('player-2', 'gold', 1000)
inventoryProvider.addItem('player-1', 'sword', { itemId: 'sword', quantity: 1 })
const provider: ITradeProvider = {
currencyProvider,
inventoryProvider,
}
const op = new TradeOperation({
tradeId: 'trade-provider',
partyA: {
playerId: 'player-1',
items: [{ itemId: 'sword', quantity: 1 }],
},
partyB: {
playerId: 'player-2',
currencies: [{ currency: 'gold', amount: 1000 }],
},
}).setProvider(provider)
const result = await op.execute(ctx)
expect(result.success).toBe(true)
// Verify using providers
expect(await inventoryProvider.getItem('player-1', 'sword')).toBeNull()
expect(await currencyProvider.getBalance('player-1', 'gold')).toBe(1000)
expect((await inventoryProvider.getItem('player-2', 'sword'))?.quantity).toBe(1)
expect(await currencyProvider.getBalance('player-2', 'gold')).toBe(0)
})
})
// ========================================================================
// 边界情况测试 | Edge Cases
// ========================================================================
describe('Edge Cases', () => {
it('should handle empty trade', async () => {
const op = new TradeOperation({
tradeId: 'trade-empty',
partyA: { playerId: 'player-1' },
partyB: { playerId: 'player-2' },
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(result.data?.completed).toBe(true)
})
it('should handle one-sided gift', async () => {
await storage.set('player:player-1:inventory:gift', {
itemId: 'gift',
quantity: 1,
})
const op = new TradeOperation({
tradeId: 'trade-gift',
partyA: {
playerId: 'player-1',
items: [{ itemId: 'gift', quantity: 1 }],
},
partyB: { playerId: 'player-2' }, // Gives nothing
})
const result = await op.execute(ctx)
expect(result.success).toBe(true)
expect(await storage.get('player:player-1:inventory:gift')).toBeNull()
const p2Gift = await storage.get<ItemData>('player:player-2:inventory:gift')
expect(p2Gift?.quantity).toBe(1)
})
it('should include reason in data', () => {
const op = new TradeOperation({
tradeId: 'trade-reason',
partyA: { playerId: 'player-1' },
partyB: { playerId: 'player-2' },
reason: 'player_trade',
})
expect(op.data.reason).toBe('player_trade')
})
})
})